diff --git a/.github/workflows/printer-linter-format.yml b/.github/workflows/printer-linter-format.yml index 8e65a481c42..d8e136f1568 100644 --- a/.github/workflows/printer-linter-format.yml +++ b/.github/workflows/printer-linter-format.yml @@ -16,7 +16,7 @@ jobs: runs-on: ubuntu-latest steps: - name: Checkout - uses: actions/checkout@v3 + uses: actions/checkout@v4 - uses: technote-space/get-diff-action@v6 with: diff --git a/.github/workflows/printer-linter-pr-diagnose.yml b/.github/workflows/printer-linter-pr-diagnose.yml index ed51f8b2f81..e70cabce7d1 100644 --- a/.github/workflows/printer-linter-pr-diagnose.yml +++ b/.github/workflows/printer-linter-pr-diagnose.yml @@ -15,7 +15,7 @@ jobs: runs-on: ubuntu-latest steps: - name: Checkout - uses: actions/checkout@v3 + uses: actions/checkout@v4 with: fetch-depth: 2 @@ -55,7 +55,7 @@ jobs: echo ${{ github.event.pull_request.head.repo.full_name }} > printer-linter-result/pr-head-repo.txt echo ${{ github.event.pull_request.head.sha }} > printer-linter-result/pr-head-sha.txt - - uses: actions/upload-artifact@v2 + - uses: actions/upload-artifact@v4 with: name: printer-linter-result path: printer-linter-result/ diff --git a/plugins/CuraEngineBackend/StartSliceJob.py b/plugins/CuraEngineBackend/StartSliceJob.py index 6da0edb2a71..0789e8a6845 100644 --- a/plugins/CuraEngineBackend/StartSliceJob.py +++ b/plugins/CuraEngineBackend/StartSliceJob.py @@ -49,7 +49,20 @@ class StartJobResult(IntEnum): ObjectsWithDisabledExtruder = 8 -class GcodeStartEndFormatter(Formatter): +class GcodeConditionState(IntEnum): + OutsideCondition = 1 + ConditionFalse = 2 + ConditionTrue = 3 + ConditionDone = 4 + + +class GcodeInstruction(IntEnum): + Skip = 1 + Evaluate = 2 + EvaluateAndWrite = 3 + + +class GcodeStartEndFormatter: # Formatter class that handles token expansion in start/end gcode # Example of a start/end gcode string: # ``` @@ -63,22 +76,50 @@ class GcodeStartEndFormatter(Formatter): # will be used. Alternatively, if the expression is formatted as "{[expression], [extruder_nr]}", # then the expression will be evaluated with the extruder stack of the specified extruder_nr. - _extruder_regex = re.compile(r"^\s*(?P.*)\s*,\s*(?P.*)\s*$") + _instruction_regex = re.compile(r"{(?Pif|else|elif|endif)?\s*(?P.*?)\s*(?:,\s*(?P.*))?\s*}(?P\n?)") - def __init__(self, all_extruder_settings: Dict[str, Any], default_extruder_nr: int = -1) -> None: + def __init__(self, all_extruder_settings: Dict[str, Dict[str, Any]], default_extruder_nr: int = -1) -> None: super().__init__() - self._all_extruder_settings: Dict[str, Any] = all_extruder_settings + self._all_extruder_settings: Dict[str, Dict[str, Any]] = all_extruder_settings self._default_extruder_nr: int = default_extruder_nr + self._cura_application = CuraApplication.getInstance() + self._extruder_manager = ExtruderManager.getInstance() + + def format(self, text: str) -> str: + remaining_text: str = text + result: str = "" + + self._condition_state: GcodeConditionState = GcodeConditionState.OutsideCondition + + while len(remaining_text) > 0: + next_code_match = self._instruction_regex.search(remaining_text) + if next_code_match is not None: + expression_start, expression_end = next_code_match.span() + + if expression_start > 0: + result += self._process_statement(remaining_text[:expression_start]) - def get_field(self, field_name, args: [str], kwargs: dict) -> Tuple[str, str]: - # get_field method parses all fields in the format-string and parses them individually to the get_value method. - # e.g. for a string "Hello {foo.bar}" would the complete field "foo.bar" would be passed to get_field, and then - # the individual parts "foo" and "bar" would be passed to get_value. This poses a problem for us, because want - # to parse the entire field as a single expression. To solve this, we override the get_field method and return - # the entire field as the expression. - return self.get_value(field_name, args, kwargs), field_name + result += self._process_code(next_code_match) - def get_value(self, expression: str, args: [str], kwargs: dict) -> str: + remaining_text = remaining_text[expression_end:] + + else: + result += self._process_statement(remaining_text) + remaining_text = "" + + return result + + def _process_statement(self, statement: str) -> str: + if self._condition_state in [GcodeConditionState.OutsideCondition, GcodeConditionState.ConditionTrue]: + return statement + else: + return "" + + def _process_code(self, code: re.Match) -> str: + condition: Optional[str] = code.group("condition") + expression: Optional[str] = code.group("expression") + extruder_nr_expr: Optional[str] = code.group("extruder_nr_expr") + end_of_line: Optional[str] = code.group("end_of_line") # The following variables are not settings, but only become available after slicing. # when these variables are encountered, we return them as-is. They are replaced later @@ -87,53 +128,100 @@ def get_value(self, expression: str, args: [str], kwargs: dict) -> str: if expression in post_slice_data_variables: return f"{{{expression}}}" - extruder_nr = str(self._default_extruder_nr) + extruder_nr: str = str(self._default_extruder_nr) + instruction: GcodeInstruction = GcodeInstruction.Skip # The settings may specify a specific extruder to use. This is done by # formatting the expression as "{expression}, {extruder_nr_expr}". If the # expression is formatted like this, we extract the extruder_nr and use # it to get the value from the correct extruder stack. - match = self._extruder_regex.match(expression) - if match: - expression = match.group("expression") - extruder_nr_expr = match.group("extruder_nr_expr") + if condition is None: + # This is a classic statement + if self._condition_state in [GcodeConditionState.OutsideCondition, GcodeConditionState.ConditionTrue]: + # Skip and move to next + instruction = GcodeInstruction.EvaluateAndWrite + else: + # This is a condition statement, first check validity + if condition == "if": + if self._condition_state != GcodeConditionState.OutsideCondition: + raise SyntaxError("Nested conditions are not supported") + else: + if self._condition_state == GcodeConditionState.OutsideCondition: + raise SyntaxError("Condition should start with an 'if' statement") + + if condition == "if": + # First instruction, just evaluate it + instruction = GcodeInstruction.Evaluate - if extruder_nr_expr.isdigit(): - extruder_nr = extruder_nr_expr else: - # We get the value of the extruder_nr_expr from `_all_extruder_settings` dictionary - # rather than the global container stack. The `_all_extruder_settings["-1"]` is a - # dict-representation of the global container stack, with additional properties such - # as `initial_extruder_nr`. As users may enter such expressions we can't use the - # global container stack. - extruder_nr = str(self._all_extruder_settings["-1"].get(extruder_nr_expr, "-1")) - - if extruder_nr in self._all_extruder_settings: - additional_variables = self._all_extruder_settings[extruder_nr].copy() - else: - Logger.warning(f"Extruder {extruder_nr} does not exist, using global settings") - additional_variables = self._all_extruder_settings["-1"].copy() - - # Add the arguments and keyword arguments to the additional settings. These - # are currently _not_ used, but they are added for consistency with the - # base Formatter class. - for key, value in enumerate(args): - additional_variables[key] = value - for key, value in kwargs.items(): - additional_variables[key] = value - - if extruder_nr == "-1": - container_stack = CuraApplication.getInstance().getGlobalContainerStack() - else: - container_stack = ExtruderManager.getInstance().getExtruderStack(extruder_nr) - if not container_stack: + if self._condition_state == GcodeConditionState.ConditionTrue: + # We have reached the next condition after a valid one has been found, skip the rest + self._condition_state = GcodeConditionState.ConditionDone + + if condition == "elif": + if self._condition_state == GcodeConditionState.ConditionFalse: + # New instruction, and valid condition has not been reached so far => evaluate it + instruction = GcodeInstruction.Evaluate + else: + # New instruction, but valid condition has already been reached => skip it + instruction = GcodeInstruction.Skip + + elif condition == "else": + instruction = GcodeInstruction.Skip # Never evaluate, expression should be empty + if self._condition_state == GcodeConditionState.ConditionFalse: + # Fallback instruction, and valid condition has not been reached so far => active next + self._condition_state = GcodeConditionState.ConditionTrue + + elif condition == "endif": + instruction = GcodeInstruction.Skip # Never evaluate, expression should be empty + self._condition_state = GcodeConditionState.OutsideCondition + + if instruction >= GcodeInstruction.Evaluate and extruder_nr_expr is not None: + extruder_nr_function = SettingFunction(extruder_nr_expr) + container_stack = self._cura_application.getGlobalContainerStack() + + # We add the variables contained in `_all_extruder_settings["-1"]`, which is a dict-representation of the + # global container stack, with additional properties such as `initial_extruder_nr`. As users may enter such + # expressions we can't use the global container stack. The variables contained in the global container stack + # will then be inserted twice, which is not optimal but works well. + extruder_nr = str(extruder_nr_function(container_stack, additional_variables=self._all_extruder_settings["-1"])) + + if instruction >= GcodeInstruction.Evaluate: + if extruder_nr in self._all_extruder_settings: + additional_variables = self._all_extruder_settings[extruder_nr].copy() + else: Logger.warning(f"Extruder {extruder_nr} does not exist, using global settings") - container_stack = CuraApplication.getInstance().getGlobalContainerStack() + additional_variables = self._all_extruder_settings["-1"].copy() - setting_function = SettingFunction(expression) - value = setting_function(container_stack, additional_variables=additional_variables) + if extruder_nr == "-1": + container_stack = self._cura_application.getGlobalContainerStack() + else: + container_stack = self._extruder_manager.getExtruderStack(extruder_nr) + if not container_stack: + Logger.warning(f"Extruder {extruder_nr} does not exist, using global settings") + container_stack = self._cura_application.getGlobalContainerStack() - return value + setting_function = SettingFunction(expression) + value = setting_function(container_stack, additional_variables=additional_variables) + + if instruction == GcodeInstruction.Evaluate: + if value: + self._condition_state = GcodeConditionState.ConditionTrue + else: + self._condition_state = GcodeConditionState.ConditionFalse + + return "" + else: + value_str = str(value) + + if end_of_line is not None: + # If we are evaluating an expression that is not a condition, restore the end of line + value_str += end_of_line + + return value_str + + else: + return "" class StartSliceJob(Job): @@ -470,6 +558,9 @@ def _buildReplacementTokens(self, stack: ContainerStack) -> Dict[str, Any]: result["day"] = ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"][int(time.strftime("%w"))] result["initial_extruder_nr"] = CuraApplication.getInstance().getExtruderManager().getInitialExtruderNr() + # If adding or changing a setting here, please update the associated wiki page + # https://github.com/Ultimaker/Cura/wiki/Start-End-G%E2%80%90Code + return result def _cacheAllExtruderSettings(self): diff --git a/plugins/MachineSettingsAction/MachineSettingsExtruderTab.qml b/plugins/MachineSettingsAction/MachineSettingsExtruderTab.qml index 5e1ddc2f3f0..ab7442bd4e8 100644 --- a/plugins/MachineSettingsAction/MachineSettingsExtruderTab.qml +++ b/plugins/MachineSettingsAction/MachineSettingsExtruderTab.qml @@ -182,7 +182,7 @@ Item Cura.GcodeTextArea // "Extruder Start G-code" { anchors.top: parent.top - anchors.bottom: parent.bottom + anchors.bottom: buttonLearnMore.top anchors.bottomMargin: UM.Theme.getSize("default_margin").height anchors.left: parent.left width: base.columnWidth - UM.Theme.getSize("default_margin").width @@ -196,7 +196,7 @@ Item Cura.GcodeTextArea // "Extruder End G-code" { anchors.top: parent.top - anchors.bottom: parent.bottom + anchors.bottom: buttonLearnMore.top anchors.bottomMargin: UM.Theme.getSize("default_margin").height anchors.right: parent.right width: base.columnWidth - UM.Theme.getSize("default_margin").width @@ -206,5 +206,17 @@ Item settingKey: "machine_extruder_end_code" settingStoreIndex: propertyStoreIndex } + + Cura.TertiaryButton + { + id: buttonLearnMore + + text: catalog.i18nc("@button", "Learn more") + iconSource: UM.Theme.getIcon("LinkExternal") + isIconOnRightSide: true + onClicked: Qt.openUrlExternally("https://github.com/Ultimaker/Cura/wiki/Start-End-G%E2%80%90Code") + anchors.bottom: parent.bottom + anchors.right: parent.right + } } } diff --git a/plugins/MachineSettingsAction/MachineSettingsPrinterTab.qml b/plugins/MachineSettingsAction/MachineSettingsPrinterTab.qml index 740e2488282..b01060c1dfd 100644 --- a/plugins/MachineSettingsAction/MachineSettingsPrinterTab.qml +++ b/plugins/MachineSettingsAction/MachineSettingsPrinterTab.qml @@ -376,7 +376,7 @@ Item anchors { top: upperBlock.bottom - bottom: parent.bottom + bottom: buttonLearnMore.top left: parent.left right: parent.right margins: UM.Theme.getSize("default_margin").width @@ -403,5 +403,19 @@ Item settingKey: "machine_end_gcode" settingStoreIndex: propertyStoreIndex } + + } + + Cura.TertiaryButton + { + id: buttonLearnMore + + text: catalog.i18nc("@button", "Learn more") + iconSource: UM.Theme.getIcon("LinkExternal") + isIconOnRightSide: true + onClicked: Qt.openUrlExternally("https://github.com/Ultimaker/Cura/wiki/Start-End-G%E2%80%90Code") + anchors.bottom: parent.bottom + anchors.right: parent.right + anchors.margins: UM.Theme.getSize("default_margin").width } } diff --git a/resources/qml/MachineSettings/GcodeTextArea.qml b/resources/qml/MachineSettings/GcodeTextArea.qml index 2538cd9f650..05d3711bae7 100644 --- a/resources/qml/MachineSettings/GcodeTextArea.qml +++ b/resources/qml/MachineSettings/GcodeTextArea.qml @@ -12,20 +12,16 @@ import Cura 1.1 as Cura // // TextArea widget for editing Gcode in the Machine Settings dialog. // -UM.TooltipArea +Item { id: control UM.I18nCatalog { id: catalog; name: "cura"; } - text: tooltip - property alias containerStackId: propertyProvider.containerStackId property alias settingKey: propertyProvider.key property alias settingStoreIndex: propertyProvider.storeIndex - property string tooltip: propertyProvider.properties.description ? propertyProvider.properties.description : "" - property alias labelText: titleLabel.text property alias labelFont: titleLabel.font diff --git a/tests/Machines/TestStartEndGCode.py b/tests/Machines/TestStartEndGCode.py new file mode 100644 index 00000000000..13c14f5accb --- /dev/null +++ b/tests/Machines/TestStartEndGCode.py @@ -0,0 +1,310 @@ +# Copyright (c) 2024 Ultimaker B.V. +# Cura is released under the terms of the LGPLv3 or higher. + +import pytest +from unittest.mock import MagicMock + +from plugins.CuraEngineBackend.StartSliceJob import GcodeStartEndFormatter + + +# def createMockedInstanceContainer(container_id): +# result = MagicMock() +# result.getId = MagicMock(return_value=container_id) +# result.getMetaDataEntry = MagicMock(side_effect=getMetadataEntrySideEffect) +# return result + +class MockValueProvider: + ## Creates a mock value provider. + # + # This initialises a dictionary with key-value pairs. + def __init__(self, values): + self._values = values + + ## Provides a value. + # + # \param name The key of the value to provide. + def getProperty(self, key, property_name, context = None): + if not (key in self._values): + return None + return self._values[key] + +extruder_0_values = { + "material_temperature": 190.0 +} + +extruder_1_values = { + "material_temperature": 210.0 +} + +global_values = { + "bed_temperature": 50.0, + "initial_extruder": 0 +} + +extruder_0_provider = MockValueProvider(extruder_0_values) +extruder_1_provider = MockValueProvider(extruder_1_values) + +all_extruder_settings = {"-1": global_values, "0": extruder_0_values, "1": extruder_1_values} + +test_cases = [ + ('Static code', None, 'G0', 'G0'), + + ('Basic replacement', None, 'M128 {bed_temperature}', 'M128 50.0'), + + ( + 'Conditional expression with global setting', + None, +'''{if bed_temperature > 30} +G123 +{else} +G456 +{endif}''', +'''G123 +''' + ), + + ( + 'Conditional expression with extruder setting directly specified by index 0', + None, +'''{if material_temperature > 200, 0} +G10 +{else} +G20 +{endif}''', +'''G20 +''' + ), + ( + 'Conditional expression with extruder setting directly specified by index 1', + None, +'''{if material_temperature > 200, 1} +G100 +{else} +G200 +{endif}''', +'''G100 +''' + ), + + ( + 'Conditional expression with extruder index specified by setting', + None, +'''{if material_temperature > 200, initial_extruder} +G1000 +{else} +G2000 +{endif}''', +'''G2000 +''' + ), + + ( + 'Conditional expression with extruder index specified by formula', + None, +'''{if material_temperature > 200, (initial_extruder + 1) % 2} +X1000 +{else} +X2000 +{endif}''', +'''X1000 +''' + ), + + ( + 'Conditional expression with elsif', + None, +'''{if bed_temperature < 30} +T30 +{elif bed_temperature >= 30 and bed_temperature < 40} +T40 +{elif bed_temperature >= 40 and bed_temperature < 50} +T50 +{elif bed_temperature >= 50 and bed_temperature < 60} +T60 +{elif bed_temperature >= 60 and bed_temperature < 70} +T70 +{else} +T-800 +{endif}''', +'''T60 +''' + ), + + ( + 'Formula inside a conditional expression', + None, +'''{if bed_temperature < 30} +Z000 +{else} +Z{bed_temperature + 10} +{endif}''', +'''Z60.0 +''' + ), + + ( + 'Other commands around conditional expression', + None, +''' +R000 +# My super initial command +R111 X123 Y456 Z789 +{if bed_temperature > 30} +R987 +R654 X321 +{else} +R963 X852 Y741 +R321 X654 Y987 +{endif} +# And finally, the end of the start at the beginning of the header +R369 +R357 X951 Y843''', +''' +R000 +# My super initial command +R111 X123 Y456 Z789 +R987 +R654 X321 +# And finally, the end of the start at the beginning of the header +R369 +R357 X951 Y843''' + ), + + ( + 'Multiple conditional expressions', + None, +''' +A999 +{if bed_temperature > 30} +A000 +{else} +A100 +{endif} +A888 +{if material_temperature > 200, 0} +A200 +{else} +A300 +{endif} +A777 +''', +''' +A999 +A000 +A888 +A300 +A777 +''' + ), + + ( + 'Nested condition expression', + SyntaxError, +'''{if bed_temperature < 30} +{if material_temperature < 30, 0} +M000 +{else} +M888 +{endif} +{else} +M{bed_temperature + 10} +{endif}''', + '' + ), + + ( + 'Wrong condition expression', + SyntaxError, +'''{of material_temperature > 200, 1} +G100 +{else} +G200 +{endif}''', + '' + ), + + ( + 'Condition expression without start', + SyntaxError, +''' +W100 +{else} +W200 +{endif}''', + '' + ), + + ( + 'Formula with non-existing variable', + None, + '{material_storage_temperature}', + '0' + ), + + ( + 'Missing formula end character', + None, + '{material_temperature, 0', + '{material_temperature, 0' + ), + + ( + 'Conditional expression with missing end character', + SyntaxError, +'''{if material_temperature > 200, 0 +Q1000 +{else} +Q2000 +{endif}''', + '' + ), + +( + 'Unexpected end character', + None, +'''{if material_temperature > 200, 0}} +S1000 +{else} +S2000 +{endif}''', +'''S2000 +''' + ), +] + +def pytest_generate_tests(metafunc): + if "original_gcode" in metafunc.fixturenames: + tests_ids = [test[0] for test in test_cases] + tests_data = [test[1:] for test in test_cases] + metafunc.parametrize("exception_type, original_gcode, expected_gcode", tests_data, ids = tests_ids) + +@pytest.fixture +def cura_application(): + result = MagicMock() + result.getGlobalContainerStack = MagicMock(return_value = MockValueProvider(global_values)) + return result + +@pytest.fixture +def extruder_manager(): + def get_extruder(extruder_nr: str): + if extruder_nr == "0": + return extruder_0_provider + elif extruder_nr == "1": + return extruder_1_provider + else: + return None + + result = MagicMock() + result.getExtruderStack = MagicMock(side_effect = get_extruder) + return result + +def test_startEndGCode_replace(cura_application, extruder_manager, exception_type, original_gcode, expected_gcode): + formatter = GcodeStartEndFormatter(all_extruder_settings, -1) + formatter._cura_application = cura_application + formatter._extruder_manager = extruder_manager + + if exception_type is not None: + with pytest.raises(exception_type): + formatter.format(original_gcode) + else: + assert formatter.format(original_gcode) == expected_gcode