Просмотр исходного кода

Merge branch 'main' into CURA-12138-Export_for_support_option

HellAholic 5 месяцев назад
Родитель
Сommit
7ebad9c107

+ 1 - 1
.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:

+ 2 - 2
.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/

+ 140 - 49
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<expression>.*)\s*,\s*(?P<extruder_nr_expr>.*)\s*$")
+    _instruction_regex = re.compile(r"{(?P<condition>if|else|elif|endif)?\s*(?P<expression>.*?)\s*(?:,\s*(?P<extruder_nr_expr>.*))?\s*}(?P<end_of_line>\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 @@ class GcodeStartEndFormatter(Formatter):
         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 @@ class StartSliceJob(Job):
         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):

+ 14 - 2
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
+        }
     }
 }

+ 15 - 1
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
     }
 }

+ 0 - 9
resources/qml/Actions.qml

@@ -62,7 +62,6 @@ Item
 
     property alias showProfileFolder: showProfileFolderAction
     property alias documentation: documentationAction
-    property alias showTroubleshooting: showTroubleShootingAction
     property alias openSponsershipPage: openSponsershipPageAction
     property alias reportBug: reportBugAction
     property alias whatsNew: whatsNewAction
@@ -87,14 +86,6 @@ Item
 
     UM.I18nCatalog{id: catalog; name: "cura"}
 
-
-    Action
-    {
-        id: showTroubleShootingAction
-        onTriggered: Qt.openUrlExternally("https://ultimaker.com/en/troubleshooting?utm_source=cura&utm_medium=software&utm_campaign=dropdown-troubleshooting")
-        text: catalog.i18nc("@action:inmenu", "Show Online Troubleshooting")
-    }
-
     Action
     {
         id: openSponsershipPageAction

+ 1 - 5
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
 

+ 0 - 1
resources/qml/Menus/HelpMenu.qml

@@ -14,7 +14,6 @@ Cura.Menu
     title: catalog.i18nc("@title:menu menubar:toplevel", "&Help")
 
     Cura.MenuItem { action: Cura.Actions.showProfileFolder }
-    Cura.MenuItem { action: Cura.Actions.showTroubleshooting}
     Cura.MenuItem { action: Cura.Actions.documentation }
     Cura.MenuItem { action: Cura.Actions.reportBug }
     Cura.MenuItem { action: Cura.Actions.openSponsershipPage }

+ 310 - 0
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