123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452 |
- # Copyright (c) 2018 Ultimaker B.V.
- # Cura is released under the terms of the LGPLv3 or higher.
- import json
- import math
- import os
- from typing import Dict, List, Optional, TYPE_CHECKING
- from PyQt6.QtCore import QObject, pyqtSignal, pyqtProperty, pyqtSlot, QTimer
- from UM.Logger import Logger
- from UM.Qt.Duration import Duration
- from UM.Scene.SceneNode import SceneNode
- from UM.i18n import i18nCatalog
- from UM.MimeTypeDatabase import MimeTypeDatabase, MimeTypeNotFoundError
- from UM.OutputDevice.OutputDevice import OutputDevice
- from UM.OutputDevice.ProjectOutputDevice import ProjectOutputDevice
- if TYPE_CHECKING:
- from cura.CuraApplication import CuraApplication
- catalog = i18nCatalog("cura")
- class PrintInformation(QObject):
- """A class for processing and the print times per build plate as well as managing the job name
- This class also mangles the current machine name and the filename of the first loaded mesh into a job name.
- This job name is requested by the JobSpecs qml file.
- """
- UNTITLED_JOB_NAME = "Untitled"
- def __init__(self, application: "CuraApplication", parent = None) -> None:
- super().__init__(parent)
- self._application = application
- self.initializeCuraMessagePrintTimeProperties()
- # Indexed by build plate number
- self._material_lengths = {} # type: Dict[int, List[float]]
- self._material_weights = {} # type: Dict[int, List[float]]
- self._material_costs = {} # type: Dict[int, List[float]]
- self._material_names = {} # type: Dict[int, List[str]]
- self._pre_sliced = False
- self._backend = self._application.getBackend()
- if self._backend:
- self._backend.printDurationMessage.connect(self._onPrintDurationMessage)
- self._application.getController().getScene().sceneChanged.connect(self._onSceneChangedDelayed)
- self._change_timer = QTimer()
- self._change_timer.setInterval(100)
- self._change_timer.setSingleShot(True)
- self._change_timer.timeout.connect(self._onSceneChanged)
- self._is_user_specified_job_name = False
- self._base_name = ""
- self._abbr_machine = ""
- self._job_name = ""
- self._active_build_plate = 0
- self._initVariablesByBuildPlate(self._active_build_plate)
- self._multi_build_plate_model = self._application.getMultiBuildPlateModel()
- self._application.globalContainerStackChanged.connect(self._updateJobName)
- self._application.globalContainerStackChanged.connect(self.setToZeroPrintInformation)
- self._application.fileLoaded.connect(self.setBaseName)
- self._application.workspaceLoaded.connect(self.setProjectName)
- self._application.getOutputDeviceManager().writeStarted.connect(self._onOutputStart)
- self._application.getMachineManager().rootMaterialChanged.connect(self._onActiveMaterialsChanged)
- self._application.getInstance().getPreferences().preferenceChanged.connect(self._onPreferencesChanged)
- self._multi_build_plate_model.activeBuildPlateChanged.connect(self._onActiveBuildPlateChanged)
- self._material_amounts = [] # type: List[float]
- self._onActiveMaterialsChanged()
- def initializeCuraMessagePrintTimeProperties(self) -> None:
- self._current_print_time = {} # type: Dict[int, Duration]
- self._print_time_message_translations = {
- "inset_0": catalog.i18nc("@tooltip", "Outer Wall"),
- "inset_x": catalog.i18nc("@tooltip", "Inner Walls"),
- "skin": catalog.i18nc("@tooltip", "Skin"),
- "infill": catalog.i18nc("@tooltip", "Infill"),
- "support_infill": catalog.i18nc("@tooltip", "Support Infill"),
- "support_interface": catalog.i18nc("@tooltip", "Support Interface"),
- "support": catalog.i18nc("@tooltip", "Support"),
- "skirt": catalog.i18nc("@tooltip", "Skirt"),
- "prime_tower": catalog.i18nc("@tooltip", "Prime Tower"),
- "travel": catalog.i18nc("@tooltip", "Travel"),
- "retract": catalog.i18nc("@tooltip", "Retractions"),
- "none": catalog.i18nc("@tooltip", "Other")
- }
- self._print_times_per_feature = {} # type: Dict[int, Dict[str, Duration]]
- def _initPrintTimesPerFeature(self, build_plate_number: int) -> None:
- # Full fill message values using keys from _print_time_message_translations
- self._print_times_per_feature[build_plate_number] = {}
- for key in self._print_time_message_translations.keys():
- self._print_times_per_feature[build_plate_number][key] = Duration(None, self)
- def _initVariablesByBuildPlate(self, build_plate_number: int) -> None:
- if build_plate_number not in self._print_times_per_feature:
- self._initPrintTimesPerFeature(build_plate_number)
- if self._active_build_plate not in self._material_lengths:
- self._material_lengths[self._active_build_plate] = []
- if self._active_build_plate not in self._material_weights:
- self._material_weights[self._active_build_plate] = []
- if self._active_build_plate not in self._material_costs:
- self._material_costs[self._active_build_plate] = []
- if self._active_build_plate not in self._material_names:
- self._material_names[self._active_build_plate] = []
- if self._active_build_plate not in self._current_print_time:
- self._current_print_time[self._active_build_plate] = Duration(parent = self)
- currentPrintTimeChanged = pyqtSignal()
- preSlicedChanged = pyqtSignal()
- @pyqtProperty(bool, notify=preSlicedChanged)
- def preSliced(self) -> bool:
- return self._pre_sliced
- def setPreSliced(self, pre_sliced: bool) -> None:
- if self._pre_sliced != pre_sliced:
- self._pre_sliced = pre_sliced
- self._updateJobName()
- self.preSlicedChanged.emit()
- @pyqtProperty(Duration, notify = currentPrintTimeChanged)
- def currentPrintTime(self) -> Duration:
- return self._current_print_time[self._active_build_plate]
- materialLengthsChanged = pyqtSignal()
- @pyqtProperty("QVariantList", notify = materialLengthsChanged)
- def materialLengths(self):
- return self._material_lengths[self._active_build_plate]
- materialWeightsChanged = pyqtSignal()
- @pyqtProperty("QVariantList", notify = materialWeightsChanged)
- def materialWeights(self):
- return self._material_weights[self._active_build_plate]
- materialCostsChanged = pyqtSignal()
- @pyqtProperty("QVariantList", notify = materialCostsChanged)
- def materialCosts(self):
- return self._material_costs[self._active_build_plate]
- materialNamesChanged = pyqtSignal()
- @pyqtProperty("QVariantList", notify = materialNamesChanged)
- def materialNames(self):
- return self._material_names[self._active_build_plate]
- # Get all print times (by feature) of the active buildplate.
- def printTimes(self) -> Dict[str, Duration]:
- return self._print_times_per_feature[self._active_build_plate]
- def _onPrintDurationMessage(self, build_plate_number: int, print_times_per_feature: Dict[str, int], material_amounts: List[float]) -> None:
- self._updateTotalPrintTimePerFeature(build_plate_number, print_times_per_feature)
- self.currentPrintTimeChanged.emit()
- self._material_amounts = material_amounts
- self._calculateInformation(build_plate_number)
- def _updateTotalPrintTimePerFeature(self, build_plate_number: int, print_times_per_feature: Dict[str, int]) -> None:
- total_estimated_time = 0
- if build_plate_number not in self._print_times_per_feature:
- self._initPrintTimesPerFeature(build_plate_number)
- for feature, time in print_times_per_feature.items():
- if feature not in self._print_times_per_feature[build_plate_number]:
- self._print_times_per_feature[build_plate_number][feature] = Duration(parent=self)
- duration = self._print_times_per_feature[build_plate_number][feature]
- if time != time: # Check for NaN. Engine can sometimes give us weird values.
- duration.setDuration(0)
- Logger.log("w", "Received NaN for print duration message")
- continue
- total_estimated_time += time
- duration.setDuration(time)
- if build_plate_number not in self._current_print_time:
- self._current_print_time[build_plate_number] = Duration(None, self)
- self._current_print_time[build_plate_number].setDuration(total_estimated_time)
- def _calculateInformation(self, build_plate_number: int) -> None:
- global_stack = self._application.getGlobalContainerStack()
- if global_stack is None:
- return
- self._material_lengths[build_plate_number] = []
- self._material_weights[build_plate_number] = []
- self._material_costs[build_plate_number] = []
- self._material_names[build_plate_number] = []
- try:
- material_preference_values = json.loads(self._application.getInstance().getPreferences().getValue("cura/material_settings"))
- except json.JSONDecodeError:
- Logger.warning("Material preference values are corrupt. Will revert to defaults!")
- material_preference_values = {}
- for index, extruder_stack in enumerate(global_stack.extruderList):
- if index >= len(self._material_amounts):
- continue
- amount = self._material_amounts[index]
- # Find the right extruder stack. As the list isn't sorted because it's a annoying generator, we do some
- # list comprehension filtering to solve this for us.
- density = extruder_stack.getMetaDataEntry("properties", {}).get("density", 0)
- material = extruder_stack.material
- radius = extruder_stack.getProperty("material_diameter", "value") / 2
- weight = float(amount) * float(density) / 1000
- cost = 0.
- material_guid = material.getMetaDataEntry("GUID")
- material_name = material.getName()
- if material_guid in material_preference_values:
- material_values = material_preference_values[material_guid]
- if material_values and "spool_weight" in material_values:
- weight_per_spool = float(material_values["spool_weight"])
- else:
- weight_per_spool = float(extruder_stack.getMetaDataEntry("properties", {}).get("weight", 0))
- cost_per_spool = float(material_values["spool_cost"] if material_values and "spool_cost" in material_values else 0)
- if weight_per_spool != 0:
- cost = cost_per_spool * weight / weight_per_spool
- else:
- cost = 0
- # Material amount is sent as an amount of mm^3, so calculate length from that
- if radius != 0:
- length = round((amount / (math.pi * radius ** 2)) / 1000, 2)
- else:
- length = 0
- self._material_weights[build_plate_number].append(weight)
- self._material_lengths[build_plate_number].append(length)
- self._material_costs[build_plate_number].append(cost)
- self._material_names[build_plate_number].append(material_name)
- self.materialLengthsChanged.emit()
- self.materialWeightsChanged.emit()
- self.materialCostsChanged.emit()
- self.materialNamesChanged.emit()
- def _onPreferencesChanged(self, preference: str) -> None:
- if preference != "cura/material_settings":
- return
- for build_plate_number in range(self._multi_build_plate_model.maxBuildPlate + 1):
- self._calculateInformation(build_plate_number)
- def _onActiveBuildPlateChanged(self) -> None:
- new_active_build_plate = self._multi_build_plate_model.activeBuildPlate
- if new_active_build_plate != self._active_build_plate:
- self._active_build_plate = new_active_build_plate
- self._updateJobName()
- self._initVariablesByBuildPlate(self._active_build_plate)
- self.materialLengthsChanged.emit()
- self.materialWeightsChanged.emit()
- self.materialCostsChanged.emit()
- self.materialNamesChanged.emit()
- self.currentPrintTimeChanged.emit()
- def _onActiveMaterialsChanged(self, *args, **kwargs) -> None:
- for build_plate_number in range(self._multi_build_plate_model.maxBuildPlate + 1):
- self._calculateInformation(build_plate_number)
- # Manual override of job name should also set the base name so that when the printer prefix is updated, it the
- # prefix can be added to the manually added name, not the old base name
- @pyqtSlot(str, bool)
- def setJobName(self, name: str, is_user_specified_job_name = False) -> None:
- self._is_user_specified_job_name = is_user_specified_job_name
- self._job_name = name
- self._base_name = name.replace(self._abbr_machine + "_", "")
- if name == "":
- self._is_user_specified_job_name = False
- self.jobNameChanged.emit()
- jobNameChanged = pyqtSignal()
- @pyqtProperty(str, notify = jobNameChanged)
- def jobName(self):
- return self._job_name
- def _updateJobName(self) -> None:
- if self._base_name == "":
- self._job_name = self.UNTITLED_JOB_NAME
- self._is_user_specified_job_name = False
- self._application.getController().getScene().clearMetaData()
- self.jobNameChanged.emit()
- return
- base_name = self._base_name
- self._defineAbbreviatedMachineName()
- # Only update the job name when it's not user-specified.
- if not self._is_user_specified_job_name:
- if self._application.getInstance().getPreferences().getValue("cura/jobname_prefix") and not self._pre_sliced:
- # Don't add abbreviation if it already has the exact same abbreviation.
- if base_name.startswith(self._abbr_machine + "_"):
- self._job_name = base_name
- else:
- self._job_name = self._abbr_machine + "_" + base_name
- else:
- self._job_name = base_name
- # In case there are several buildplates, a suffix is attached
- if self._multi_build_plate_model.maxBuildPlate > 0:
- connector = "_#"
- suffix = connector + str(self._active_build_plate + 1)
- if connector in self._job_name:
- self._job_name = self._job_name.split(connector)[0] # get the real name
- if self._active_build_plate != 0:
- self._job_name += suffix
- self.jobNameChanged.emit()
- @pyqtSlot(str)
- def setProjectName(self, name: str) -> None:
- self.setBaseName(name, is_project_file = True)
- baseNameChanged = pyqtSignal()
- def setBaseName(self, base_name: str, is_project_file: bool = False) -> None:
- self._is_user_specified_job_name = False
- # Ensure that we don't use entire path but only filename
- name = os.path.basename(base_name)
- # when a file is opened using the terminal; the filename comes from _onFileLoaded and still contains its
- # extension. This cuts the extension off if necessary.
- check_name = os.path.splitext(name)[0]
- filename_parts = os.path.basename(base_name).split(".")
- # If it's a gcode, also always update the job name
- is_gcode = False
- if len(filename_parts) > 1:
- # Only check the extension(s)
- is_gcode = "gcode" in filename_parts[1:]
- # if this is a profile file, always update the job name
- # name is "" when I first had some meshes and afterwards I deleted them so the naming should start again
- is_empty = check_name == ""
- if is_gcode or is_project_file or (is_empty or (self._base_name == "" and self._base_name != check_name)):
- # Only take the file name part, Note : file name might have 'dot' in name as well
- data = ""
- try:
- mime_type = MimeTypeDatabase.getMimeTypeForFile(name)
- data = mime_type.stripExtension(name)
- except MimeTypeNotFoundError:
- Logger.log("w", "Unsupported Mime Type Database file extension %s", name)
- if data is not None and check_name is not None:
- self._base_name = data
- else:
- self._base_name = ""
- # Strip the old "curaproject" extension from the name
- OLD_CURA_PROJECT_EXT = ".curaproject"
- if self._base_name.lower().endswith(OLD_CURA_PROJECT_EXT):
- self._base_name = self._base_name[:len(self._base_name) - len(OLD_CURA_PROJECT_EXT)]
- # CURA-5896 Try to strip extra extensions with an infinite amount of ".curaproject.3mf".
- OLD_CURA_PROJECT_3MF_EXT = ".curaproject.3mf"
- while self._base_name.lower().endswith(OLD_CURA_PROJECT_3MF_EXT):
- self._base_name = self._base_name[:len(self._base_name) - len(OLD_CURA_PROJECT_3MF_EXT)]
- self._updateJobName()
- @pyqtProperty(str, fset = setBaseName, notify = baseNameChanged)
- def baseName(self):
- return self._base_name
- def _defineAbbreviatedMachineName(self) -> None:
- """Created an acronym-like abbreviated machine name from the currently active machine name.
- Called each time the global stack is switched.
- """
- global_container_stack = self._application.getGlobalContainerStack()
- if not global_container_stack:
- self._abbr_machine = ""
- return
- active_machine_type_name = global_container_stack.definition.getName()
- self._abbr_machine = self._application.getMachineManager().getAbbreviatedMachineName(active_machine_type_name)
- @pyqtSlot(result = "QVariantMap")
- def getFeaturePrintTimes(self) -> Dict[str, Duration]:
- result = {}
- if self._active_build_plate not in self._print_times_per_feature:
- self._initPrintTimesPerFeature(self._active_build_plate)
- for feature, time in self._print_times_per_feature[self._active_build_plate].items():
- if feature in self._print_time_message_translations:
- result[self._print_time_message_translations[feature]] = time
- else:
- result[feature] = time
- return result
- # Simulate message with zero time duration
- def setToZeroPrintInformation(self, build_plate: Optional[int] = None) -> None:
- if build_plate is None:
- build_plate = self._active_build_plate
- # Construct the 0-time message
- temp_message = {}
- if build_plate not in self._print_times_per_feature:
- self._print_times_per_feature[build_plate] = {}
- for key in self._print_times_per_feature[build_plate].keys():
- temp_message[key] = 0
- temp_material_amounts = [0.]
- self._onPrintDurationMessage(build_plate, temp_message, temp_material_amounts)
- def _onSceneChangedDelayed(self, scene_node: SceneNode) -> None:
- # Ignore any changes that are not related to sliceable objects
- if not isinstance(scene_node, SceneNode) \
- or not scene_node.callDecoration("isSliceable") \
- or not scene_node.callDecoration("getBuildPlateNumber") == self._active_build_plate:
- return
- self._change_timer.start()
- def _onSceneChanged(self) -> None:
- """Listen to scene changes to check if we need to reset the print information"""
- self.setToZeroPrintInformation(self._active_build_plate)
- def _onOutputStart(self, output_device: OutputDevice) -> None:
- """If this is the sort of output 'device' (like local or online file storage, rather than a printer),
- the user could have altered the file-name, and thus the project name should be altered as well."""
- if isinstance(output_device, ProjectOutputDevice):
- new_name = output_device.getLastOutputName()
- if new_name is not None:
- self.setJobName(os.path.splitext(os.path.basename(new_name))[0])
|