diff --git a/CMakeLists.txt b/CMakeLists.txt index 4406fd4856..d5109f0f7b 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -24,6 +24,8 @@ set(CURA_CLOUD_API_ROOT "" CACHE STRING "Alternative Cura cloud API root") set(CURA_CLOUD_API_VERSION "" CACHE STRING "Alternative Cura cloud API version") configure_file(${CMAKE_SOURCE_DIR}/cura.desktop.in ${CMAKE_BINARY_DIR}/cura.desktop @ONLY) + + configure_file(cura/CuraVersion.py.in CuraVersion.py @ONLY) diff --git a/cura/ApplicationMetadata.py b/cura/ApplicationMetadata.py index aea68c0af5..73eb9bb288 100644 --- a/cura/ApplicationMetadata.py +++ b/cura/ApplicationMetadata.py @@ -9,6 +9,7 @@ DEFAULT_CURA_DISPLAY_NAME = "Ultimaker Cura" DEFAULT_CURA_VERSION = "master" DEFAULT_CURA_BUILD_TYPE = "" DEFAULT_CURA_DEBUG_MODE = False +DEFAULT_CURA_SDK_VERSION = "6.1.0" try: from cura.CuraVersion import CuraAppName # type: ignore @@ -41,4 +42,7 @@ try: except ImportError: CuraDebugMode = DEFAULT_CURA_DEBUG_MODE -from cura.CuraVersion import CuraSDKVersion # type: ignore +# Each release has a fixed SDK version coupled with it. It doesn't make sense to make it configurable because, for +# example Cura 3.2 with SDK version 6.1 will not work. So the SDK version is hard-coded here and left out of the +# CuraVersion.py.in template. +CuraSDKVersion = "6.1.0" diff --git a/cura/CuraVersion.py.in b/cura/CuraVersion.py.in index abbc3a0a0d..4583e76f67 100644 --- a/cura/CuraVersion.py.in +++ b/cura/CuraVersion.py.in @@ -6,9 +6,6 @@ CuraAppDisplayName = "@CURA_APP_DISPLAY_NAME@" CuraVersion = "@CURA_VERSION@" CuraBuildType = "@CURA_BUILDTYPE@" CuraDebugMode = True if "@_cura_debugmode@" == "ON" else False - -CuraSDKVersion = "6.1.0" - CuraCloudAPIRoot = "@CURA_CLOUD_API_ROOT@" CuraCloudAPIVersion = "@CURA_CLOUD_API_VERSION@" CuraCloudAccountAPIRoot = "@CURA_CLOUD_ACCOUNT_API_ROOT@" diff --git a/cura/Machines/Models/IntentCategoryModel.py b/cura/Machines/Models/IntentCategoryModel.py new file mode 100644 index 0000000000..ac69191ca1 --- /dev/null +++ b/cura/Machines/Models/IntentCategoryModel.py @@ -0,0 +1,47 @@ +#Copyright (c) 2019 Ultimaker B.V. +#Cura is released under the terms of the LGPLv3 or higher. + +from PyQt5.QtCore import Qt +import collections + +from cura.Settings.IntentManager import IntentManager +from UM.Qt.ListModel import ListModel + +from UM.i18n import i18nCatalog +catalog = i18nCatalog("cura") + +## Lists the intent categories that are available for the current printer +# configuration. +class IntentCategoryModel(ListModel): + NameRole = Qt.UserRole + 1 + IntentCategoryRole = Qt.UserRole + 2 + WeightRole = Qt.UserRole + 3 + + #Translations to user-visible string. Ordered by weight. + #TODO: Create a solution for this name and weight to be used dynamically. + name_translation = collections.OrderedDict() #type: "collections.OrderedDict[str,str]" + name_translation["default"] = catalog.i18nc("@label", "Default") + name_translation["engineering"] = catalog.i18nc("@label", "Engineering") + name_translation["smooth"] = catalog.i18nc("@label", "Smooth") + + ## Creates a new model for a certain intent category. + # \param The category to list the intent profiles for. + def __init__(self, intent_category: str) -> None: + super().__init__() + self._intent_category = intent_category + + self.addRoleName(self.NameRole, "name") + self.addRoleName(self.IntentCategoryRole, "intent_category") + self.addRoleName(self.WeightRole, "weight") + + ## Updates the list of intents. + def update(self) -> None: + available_categories = IntentManager.getInstance().currentAvailableIntentCategories() + result = [] + for category in available_categories: + result.append({ + "name": self.name_translation.get(category, catalog.i18nc("@label", "Unknown")), + "intent_category": category, + "weight": list(self.name_translation.items()).index(category) + }) + super().update(result) \ No newline at end of file diff --git a/cura/Machines/QualityManager.py b/cura/Machines/QualityManager.py index 7da4f4f0d6..b4f8b8f679 100644 --- a/cura/Machines/QualityManager.py +++ b/cura/Machines/QualityManager.py @@ -194,9 +194,9 @@ class QualityManager(QObject): return quality_changes_group_dict # - # Gets all quality groups for the given machine. Both available and none available ones will be included. + # Gets all quality groups for the given machine. Both available and unavailable ones will be included. # It returns a dictionary with "quality_type"s as keys and "QualityGroup"s as values. - # Whether a QualityGroup is available can be unknown via the field QualityGroup.is_available. + # Whether a QualityGroup is available can be known via the field QualityGroup.is_available. # For more details, see QualityGroup. # def getQualityGroups(self, machine: "GlobalStack") -> Dict[str, QualityGroup]: diff --git a/cura/Settings/IntentManager.py b/cura/Settings/IntentManager.py new file mode 100644 index 0000000000..e16115ba2a --- /dev/null +++ b/cura/Settings/IntentManager.py @@ -0,0 +1,141 @@ +#Copyright (c) 2019 Ultimaker B.V. +#Cura is released under the terms of the LGPLv3 or higher. + +from PyQt5.QtCore import QObject, pyqtSignal +from typing import Any, Dict, List, Set, Tuple, TYPE_CHECKING +from cura.CuraApplication import CuraApplication +from cura.Settings.ExtruderManager import ExtruderManager +from UM.Settings.InstanceContainer import InstanceContainer + +if TYPE_CHECKING: + from UM.Settings.InstanceContainer import InstanceContainer + +## Front-end for querying which intents are available for a certain +# configuration. +# +# CURRENTLY THIS CLASS CONTAINS ONLY SOME PSEUDOCODE OF WHAT WE ARE SUPPOSED +# TO IMPLEMENT. +class IntentManager(QObject): + __instance = None + + def __init__(self) -> None: + super().__init__() + CuraApplication.getInstance().getMachineManager().activeStackChanged.connect(self.configurationChanged) + self.configurationChanged.connect(self.selectDefaultIntent) + pass + + ## This class is a singleton. + @classmethod + def getInstance(cls): + if not cls.__instance: + cls.__instance = IntentManager() + return cls.__instance + + configurationChanged = pyqtSignal() + + ## Gets the metadata dictionaries of all intent profiles for a given + # configuration. + # + # \param definition_id ID of the printer. + # \param nozzle_name Name of the nozzle. + # \param material_id ID of the material. + # \return A list of metadata dictionaries matching the search criteria, or + # an empty list if nothing was found. + def intentMetadatas(self, definition_id: str, nozzle_name: str, material_id: str) -> List[Dict[str, Any]]: + registry = CuraApplication.getInstance().getContainerRegistry() + return registry.findContainersMetadata(definition = definition_id, variant = nozzle_name, material_id = material_id) + + ## Collects and returns all intent categories available for the given + # parameters. Note that the 'default' category is always available. + # + # \param definition_id ID of the printer. + # \param nozzle_name Name of the nozzle. + # \param material_id ID of the material. + # \return A set of intent category names. + def intentCategories(self, definition_id: str, nozzle_id: str, material_id: str) -> List[str]: + categories = set() + for intent in self.intentMetadatas(definition_id, nozzle_id, material_id): + categories.add(intent["intent_category"]) + categories.add("default") #The "empty" intent is not an actual profile specific to the configuration but we do want it to appear in the categories list. + return list(categories) + + ## List of intents to be displayed in the interface. + # + # For the interface this will have to be broken up into the different + # intent categories. That is up to the model there. + # + # \return A list of tuples of intent_category and quality_type. The actual + # instance may vary per extruder. + def currentAvailableIntents(self) -> List[Tuple[str, str]]: + application = CuraApplication.getInstance() + global_stack = application.getGlobalContainerStack() + if global_stack is None: + return [("default", "normal")] + # TODO: We now do this (return a default) if the global stack is missing, but not in the code below, + # even though there should always be defaults. The problem then is what to do with the quality_types. + # Currently _also_ inconsistent with 'currentAvailableIntentCategoreis', which _does_ return default. + quality_groups = application.getQualityManager().getQualityGroups(global_stack) + available_quality_types = {quality_group.quality_type for quality_group in quality_groups.values() if quality_group.node_for_global is not None} + + final_intent_ids = set() # type: Set[str] + current_definition_id = global_stack.definition.getMetaDataEntry("id") + for extruder_stack in ExtruderManager.getInstance().getUsedExtruderStacks(): + nozzle_name = extruder_stack.variant.getMetaDataEntry("name") + material_id = extruder_stack.material.getMetaDataEntry("base_file") + final_intent_ids |= {metadata["id"] for metadata in self.intentMetadatas(current_definition_id, nozzle_name, material_id) if metadata["quality_type"] in available_quality_types} + + result = set() # type: Set[Tuple[str, str]] + for intent_id in final_intent_ids: + intent_metadata = application.getContainerRegistry().findContainersMetadata(id = intent_id)[0] + result.add((intent_metadata["intent_category"], intent_metadata["quality_type"])) + return list(result) + + ## List of intent categories available in either of the extruders. + # + # This is purposefully inconsistent with the way that the quality types + # are listed. The quality types will show all quality types available in + # the printer using any configuration. This will only list the intent + # categories that are available using the current configuration (but the + # union over the extruders). + # \return List of all categories in the current configurations of all + # extruders. + def currentAvailableIntentCategories(self) -> List[str]: + global_stack = CuraApplication.getInstance().getGlobalContainerStack() + if global_stack is None: + return ["default"] + current_definition_id = global_stack.definition.getMetaDataEntry("id") + final_intent_categories = set() # type: Set[str] + for extruder_stack in ExtruderManager.getInstance().getUsedExtruderStacks(): + nozzle_name = extruder_stack.variant.getMetaDataEntry("name") + material_id = extruder_stack.material.getMetaDataEntry("base_file") + final_intent_categories.update(self.intentCategories(current_definition_id, nozzle_name, material_id)) + return list(final_intent_categories) + + ## The intent that gets selected by default when no intent is available for + # the configuration, an extruder can't match the intent that the user + # selects, or just when creating a new printer. + def getDefaultIntent(self) -> InstanceContainer: + return CuraApplication.getInstance().empty_intent_container + + ## Apply intent on the stacks. + def selectIntent(self, intent_category: str, quality_type: str) -> None: + application = CuraApplication.getInstance() + global_stack = application.getGlobalContainerStack() + if global_stack is None: + return + current_definition_id = global_stack.definition.getMetaDataEntry("id") + for extruder_stack in ExtruderManager.getInstance().getUsedExtruderStacks(): + nozzle_name = extruder_stack.variant.getMetaDataEntry("name") + material_id = extruder_stack.material.getMetaDataEntry("base_file") + intent = application.getContainerRegistry().findContainers(definition = current_definition_id, variant = nozzle_name, material = material_id, quality_type = quality_type, intent_category = intent_category) + if intent: + extruder_stack.intent = intent[0] + else: + extruder_stack.intent = self.getDefaultIntent() + + application.getMachineManager().setQualityGroupByQualityType(quality_type) + + ## Selects the default intents on every extruder. + def selectDefaultIntent(self) -> None: + for extruder_stack in ExtruderManager.getInstance().getUsedExtruderStacks(): + extruder_stack.intent = self.getDefaultIntent() diff --git a/resources/intent/strong.inst.cfg b/resources/intent/strong.inst.cfg index 4758841bf7..702778d598 100644 --- a/resources/intent/strong.inst.cfg +++ b/resources/intent/strong.inst.cfg @@ -6,6 +6,10 @@ definition = fdmprinter [metadata] setting_version = 7 type = intent +intent_category = engineering +quality_type = draft +material = generic_abs +variant = AA 0.4 [values] diff --git a/tests/TestIntentManager.py b/tests/TestIntentManager.py new file mode 100644 index 0000000000..c9d39f607e --- /dev/null +++ b/tests/TestIntentManager.py @@ -0,0 +1,140 @@ +from unittest.mock import MagicMock, patch + +import pytest +from typing import Any, Dict, List + +from cura.Settings.IntentManager import IntentManager +from cura.Machines.QualityGroup import QualityGroup +from cura.Machines.QualityManager import QualityManager + +from tests.Settings.MockContainer import MockContainer + +@pytest.fixture() +def quality_manager(application, container_registry, global_stack) -> QualityManager: + application.getGlobalContainerStack = MagicMock(return_value = global_stack) + with patch("cura.CuraApplication.CuraApplication.getInstance", MagicMock(return_value=application)): + with patch("UM.Settings.ContainerRegistry.ContainerRegistry.getInstance", MagicMock(return_value=container_registry)): + manager = QualityManager(application) + return manager + + +@pytest.fixture() +def intent_manager(application, extruder_manager, machine_manager, quality_manager, container_registry, global_stack) -> IntentManager: + application.getExtruderManager = MagicMock(return_value = extruder_manager) + application.getGlobalContainerStack = MagicMock(return_value = global_stack) + application.getMachineManager = MagicMock(return_value = machine_manager) + application.getQualityManager = MagicMock(return_value = quality_manager) + with patch("cura.CuraApplication.CuraApplication.getInstance", MagicMock(return_value=application)): + with patch("UM.Settings.ContainerRegistry.ContainerRegistry.getInstance", MagicMock(return_value=container_registry)): + manager = IntentManager() + return manager + + +mocked_intent_metadata = [ + {"id": "um3_aa4_pla_smooth_normal", "GUID": "abcxyz", "definition": "ultimaker3", "variant": "AA 0.4", + "material_id": "generic_pla", "intent_category": "smooth", "quality_type": "normal"}, + {"id": "um3_aa4_pla_strong_abnorm", "GUID": "defqrs", "definition": "ultimaker3", "variant": "AA 0.4", + "material_id": "generic_pla", "intent_category": "strong", "quality_type": "abnorm"}] # type:List[Dict[str, str]] + +mocked_qualitygroup_metadata = { + "normal": QualityGroup("um3_aa4_pla_normal", "normal"), + "abnorm": QualityGroup("um3_aa4_pla_abnorm", "abnorm")} # type:Dict[str, QualityGroup] + + +def mockFindMetadata(**kwargs) -> List[Dict[str, Any]]: + if "id" in kwargs: + return [x for x in mocked_intent_metadata if x["id"] == kwargs["id"]] + else: + result = [] + for data in mocked_intent_metadata: + should_add = True + for key, value in kwargs.items(): + if key in data.keys(): + should_add &= (data[key] == value) + if should_add: + result.append(data) + return result + + +def mockFindContainers(**kwargs) -> List[MockContainer]: + result = [] + metadatas = mockFindMetadata(**kwargs) + for metadata in metadatas: + result.append(MockContainer(metadata)) + return result + + +def doSetup(application, extruder_manager, quality_manager, container_registry, global_stack) -> None: + container_registry.findContainersMetadata = MagicMock(side_effect=mockFindMetadata) + container_registry.findContainers = MagicMock(side_effect=mockFindContainers) + + quality_manager.getQualityGroups = MagicMock(return_value=mocked_qualitygroup_metadata) + for _, qualitygroup in mocked_qualitygroup_metadata.items(): + qualitygroup.node_for_global = MagicMock(name="Node for global") + application.getQualityManager = MagicMock(return_value=quality_manager) + + global_stack.definition = MockContainer({"id": "ultimaker3"}) + application.getGlobalContainerStack = MagicMock(return_value=global_stack) + + extruder_stack_a = MockContainer({"id": "Extruder The First"}) + extruder_stack_a.variant = MockContainer({"name": "AA 0.4"}) + extruder_stack_a.material = MockContainer({"base_file": "generic_pla"}) + extruder_stack_b = MockContainer({"id": "Extruder II: Plastic Boogaloo"}) + extruder_stack_b.variant = MockContainer({"name": "AA 0.4"}) + extruder_stack_b.material = MockContainer({"base_file": "generic_pla"}) + + extruder_manager.getUsedExtruderStacks = MagicMock(return_value=[extruder_stack_a, extruder_stack_b]) + + +def test_intentCategories(application, intent_manager, container_registry): + # Mock .findContainersMetadata so we also test .intentMetadatas (the latter is mostly a wrapper around the former). + container_registry.findContainersMetadata = MagicMock(return_value=mocked_intent_metadata) + + with patch("cura.CuraApplication.CuraApplication.getInstance", MagicMock(return_value=application)): + with patch("UM.Settings.ContainerRegistry.ContainerRegistry.getInstance", MagicMock(return_value=container_registry)): + categories = intent_manager.intentCategories("ultimaker3", "AA 0.4", "generic_pla") # type:List[str] + assert "default" in categories, "default should always be in categories" + assert "strong" in categories, "strong should be in categories" + assert "smooth" in categories, "smooth should be in categories" + + +def test_currentAvailableIntents(application, extruder_manager, quality_manager, intent_manager, container_registry, global_stack): + doSetup(application, extruder_manager, quality_manager, container_registry, global_stack) + + with patch("cura.CuraApplication.CuraApplication.getInstance", MagicMock(return_value=application)): + with patch("UM.Settings.ContainerRegistry.ContainerRegistry.getInstance", MagicMock(return_value=container_registry)): + with patch("cura.Settings.ExtruderManager.ExtruderManager.getInstance", MagicMock(return_value=extruder_manager)): + intents = intent_manager.currentAvailableIntents() + assert ("smooth", "normal") in intents + assert ("strong", "abnorm") in intents + #assert ("default", "normal") in intents # Pending to-do in 'IntentManager'. + #assert ("default", "abnorm") in intents # Pending to-do in 'IntentManager'. + assert len(intents) == 2 # Or 4? pending to-do in 'IntentManager'. + + +def test_currentAvailableIntentCategories(application, extruder_manager, quality_manager, intent_manager, container_registry, global_stack): + doSetup(application, extruder_manager, quality_manager, container_registry, global_stack) + + with patch("cura.CuraApplication.CuraApplication.getInstance", MagicMock(return_value=application)): + with patch("UM.Settings.ContainerRegistry.ContainerRegistry.getInstance", MagicMock(return_value=container_registry)): + with patch("cura.Settings.ExtruderManager.ExtruderManager.getInstance", MagicMock(return_value=extruder_manager)): + categories = intent_manager.currentAvailableIntentCategories() + assert "default" in categories # Currently inconsistent with 'currentAvailableIntents'! + assert "smooth" in categories + assert "strong" in categories + assert len(categories) == 3 + + +def test_selectIntent(application, extruder_manager, quality_manager, intent_manager, container_registry, global_stack): + doSetup(application, extruder_manager, quality_manager, container_registry, global_stack) + + with patch("cura.CuraApplication.CuraApplication.getInstance", MagicMock(return_value=application)): + with patch("UM.Settings.ContainerRegistry.ContainerRegistry.getInstance", MagicMock(return_value=container_registry)): + with patch("cura.Settings.ExtruderManager.ExtruderManager.getInstance", MagicMock(return_value=extruder_manager)): + intents = intent_manager.currentAvailableIntents() + for intent, quality in intents: + intent_manager.selectIntent(intent, quality) + extruder_stacks = extruder_manager.getUsedExtruderStacks() + assert len(extruder_stacks) == 2 + assert extruder_stacks[0].intent.getMetaDataEntry("intent_category") == intent + assert extruder_stacks[1].intent.getMetaDataEntry("intent_category") == intent diff --git a/tests/TestMachineManager.py b/tests/TestMachineManager.py index b1e155aa4f..e91cffb172 100644 --- a/tests/TestMachineManager.py +++ b/tests/TestMachineManager.py @@ -2,42 +2,6 @@ from unittest.mock import MagicMock, patch import pytest -from UM.Settings.ContainerRegistry import ContainerRegistry -from cura.Settings.ExtruderManager import ExtruderManager -from cura.Settings.MachineManager import MachineManager - - -@pytest.fixture() -def global_stack(): - return MagicMock(name="Global Stack") - -@pytest.fixture() -def container_registry() -> ContainerRegistry: - return MagicMock(name = "ContainerRegistry") - - -@pytest.fixture() -def extruder_manager(application, container_registry) -> ExtruderManager: - if ExtruderManager.getInstance() is not None: - # Reset the data - ExtruderManager._ExtruderManager__instance = None - - with patch("cura.CuraApplication.CuraApplication.getInstance", MagicMock(return_value=application)): - with patch("UM.Settings.ContainerRegistry.ContainerRegistry.getInstance", MagicMock(return_value=container_registry)): - manager = ExtruderManager() - return manager - - -@pytest.fixture() -def machine_manager(application, extruder_manager, container_registry, global_stack) -> MachineManager: - application.getExtruderManager = MagicMock(return_value = extruder_manager) - application.getGlobalContainerStack = MagicMock(return_value = global_stack) - with patch("cura.Settings.CuraContainerRegistry.CuraContainerRegistry.getInstance", MagicMock(return_value=container_registry)): - manager = MachineManager(application) - - return manager - - def test_setActiveMachine(machine_manager): registry = MagicMock() diff --git a/tests/conftest.py b/tests/conftest.py index 7f46c202b3..876fb4f541 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -3,7 +3,7 @@ # The purpose of this class is to create fixtures or methods that can be shared among all tests. -import unittest.mock +from unittest.mock import MagicMock, patch import pytest # Prevents error: "PyCapsule_GetPointer called with incorrect name" with conflicting SIP configurations between Arcus and PyQt: Import Arcus and Savitar first! @@ -13,16 +13,51 @@ from UM.Qt.QtApplication import QtApplication # QtApplication import is require # Even though your IDE says these files are not used, don't believe it. It's lying. They need to be there. from cura.CuraApplication import CuraApplication +from cura.Settings.ExtruderManager import ExtruderManager +from cura.Settings.MachineManager import MachineManager from cura.UI.MachineActionManager import MachineActionManager +from UM.Settings.ContainerRegistry import ContainerRegistry # Create a CuraApplication object that will be shared among all tests. It needs to be initialized. # Since we need to use it more that once, we create the application the first time and use its instance afterwards. @pytest.fixture() def application() -> CuraApplication: - app = unittest.mock.MagicMock() + app = MagicMock() return app # Returns a MachineActionManager instance. @pytest.fixture() def machine_action_manager(application) -> MachineActionManager: return MachineActionManager(application) + +@pytest.fixture() +def global_stack(): + return MagicMock(name="Global Stack") + +@pytest.fixture() +def container_registry(application, global_stack) -> ContainerRegistry: + result = MagicMock() + result.findContainerStacks = MagicMock(return_value = [global_stack]) + application.getContainerRegistry = MagicMock(return_value = result) + return result + +@pytest.fixture() +def extruder_manager(application, container_registry) -> ExtruderManager: + if ExtruderManager.getInstance() is not None: + # Reset the data + ExtruderManager._ExtruderManager__instance = None + + with patch("cura.CuraApplication.CuraApplication.getInstance", MagicMock(return_value=application)): + with patch("UM.Settings.ContainerRegistry.ContainerRegistry.getInstance", MagicMock(return_value=container_registry)): + manager = ExtruderManager() + return manager + + +@pytest.fixture() +def machine_manager(application, extruder_manager, container_registry, global_stack) -> MachineManager: + application.getExtruderManager = MagicMock(return_value = extruder_manager) + application.getGlobalContainerStack = MagicMock(return_value = global_stack) + with patch("UM.Settings.ContainerRegistry.ContainerRegistry.getInstance", MagicMock(return_value=container_registry)): + manager = MachineManager(application) + + return manager