PrintInformation.py 19 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436
  1. # Copyright (c) 2018 Ultimaker B.V.
  2. # Cura is released under the terms of the LGPLv3 or higher.
  3. import json
  4. import math
  5. import os
  6. import unicodedata
  7. from typing import Dict, List, Optional, TYPE_CHECKING
  8. from PyQt5.QtCore import QObject, pyqtSignal, pyqtProperty, pyqtSlot, QTimer
  9. from UM.Logger import Logger
  10. from UM.Qt.Duration import Duration
  11. from UM.Scene.SceneNode import SceneNode
  12. from UM.i18n import i18nCatalog
  13. from UM.MimeTypeDatabase import MimeTypeDatabase, MimeTypeNotFoundError
  14. if TYPE_CHECKING:
  15. from cura.CuraApplication import CuraApplication
  16. catalog = i18nCatalog("cura")
  17. ## A class for processing and the print times per build plate as well as managing the job name
  18. #
  19. # This class also mangles the current machine name and the filename of the first loaded mesh into a job name.
  20. # This job name is requested by the JobSpecs qml file.
  21. class PrintInformation(QObject):
  22. UNTITLED_JOB_NAME = "Untitled"
  23. def __init__(self, application: "CuraApplication", parent = None) -> None:
  24. super().__init__(parent)
  25. self._application = application
  26. self.initializeCuraMessagePrintTimeProperties()
  27. # Indexed by build plate number
  28. self._material_lengths = {} # type: Dict[int, List[float]]
  29. self._material_weights = {} # type: Dict[int, List[float]]
  30. self._material_costs = {} # type: Dict[int, List[float]]
  31. self._material_names = {} # type: Dict[int, List[str]]
  32. self._pre_sliced = False
  33. self._backend = self._application.getBackend()
  34. if self._backend:
  35. self._backend.printDurationMessage.connect(self._onPrintDurationMessage)
  36. self._application.getController().getScene().sceneChanged.connect(self._onSceneChangedDelayed)
  37. self._change_timer = QTimer()
  38. self._change_timer.setInterval(100)
  39. self._change_timer.setSingleShot(True)
  40. self._change_timer.timeout.connect(self._onSceneChanged)
  41. self._is_user_specified_job_name = False
  42. self._base_name = ""
  43. self._abbr_machine = ""
  44. self._job_name = ""
  45. self._active_build_plate = 0
  46. self._initVariablesByBuildPlate(self._active_build_plate)
  47. self._multi_build_plate_model = self._application.getMultiBuildPlateModel()
  48. self._application.globalContainerStackChanged.connect(self._updateJobName)
  49. self._application.globalContainerStackChanged.connect(self.setToZeroPrintInformation)
  50. self._application.fileLoaded.connect(self.setBaseName)
  51. self._application.workspaceLoaded.connect(self.setProjectName)
  52. self._application.getMachineManager().rootMaterialChanged.connect(self._onActiveMaterialsChanged)
  53. self._application.getInstance().getPreferences().preferenceChanged.connect(self._onPreferencesChanged)
  54. self._multi_build_plate_model.activeBuildPlateChanged.connect(self._onActiveBuildPlateChanged)
  55. self._material_amounts = [] # type: List[float]
  56. self._onActiveMaterialsChanged()
  57. def initializeCuraMessagePrintTimeProperties(self) -> None:
  58. self._current_print_time = {} # type: Dict[int, Duration]
  59. self._print_time_message_translations = {
  60. "inset_0": catalog.i18nc("@tooltip", "Outer Wall"),
  61. "inset_x": catalog.i18nc("@tooltip", "Inner Walls"),
  62. "skin": catalog.i18nc("@tooltip", "Skin"),
  63. "infill": catalog.i18nc("@tooltip", "Infill"),
  64. "support_infill": catalog.i18nc("@tooltip", "Support Infill"),
  65. "support_interface": catalog.i18nc("@tooltip", "Support Interface"),
  66. "support": catalog.i18nc("@tooltip", "Support"),
  67. "skirt": catalog.i18nc("@tooltip", "Skirt"),
  68. "prime_tower": catalog.i18nc("@tooltip", "Prime Tower"),
  69. "travel": catalog.i18nc("@tooltip", "Travel"),
  70. "retract": catalog.i18nc("@tooltip", "Retractions"),
  71. "none": catalog.i18nc("@tooltip", "Other")
  72. }
  73. self._print_times_per_feature = {} # type: Dict[int, Dict[str, Duration]]
  74. def _initPrintTimesPerFeature(self, build_plate_number: int) -> None:
  75. # Full fill message values using keys from _print_time_message_translations
  76. self._print_times_per_feature[build_plate_number] = {}
  77. for key in self._print_time_message_translations.keys():
  78. self._print_times_per_feature[build_plate_number][key] = Duration(None, self)
  79. def _initVariablesByBuildPlate(self, build_plate_number: int) -> None:
  80. if build_plate_number not in self._print_times_per_feature:
  81. self._initPrintTimesPerFeature(build_plate_number)
  82. if self._active_build_plate not in self._material_lengths:
  83. self._material_lengths[self._active_build_plate] = []
  84. if self._active_build_plate not in self._material_weights:
  85. self._material_weights[self._active_build_plate] = []
  86. if self._active_build_plate not in self._material_costs:
  87. self._material_costs[self._active_build_plate] = []
  88. if self._active_build_plate not in self._material_names:
  89. self._material_names[self._active_build_plate] = []
  90. if self._active_build_plate not in self._current_print_time:
  91. self._current_print_time[self._active_build_plate] = Duration(parent = self)
  92. currentPrintTimeChanged = pyqtSignal()
  93. preSlicedChanged = pyqtSignal()
  94. @pyqtProperty(bool, notify=preSlicedChanged)
  95. def preSliced(self) -> bool:
  96. return self._pre_sliced
  97. def setPreSliced(self, pre_sliced: bool) -> None:
  98. if self._pre_sliced != pre_sliced:
  99. self._pre_sliced = pre_sliced
  100. self._updateJobName()
  101. self.preSlicedChanged.emit()
  102. @pyqtProperty(Duration, notify = currentPrintTimeChanged)
  103. def currentPrintTime(self) -> Duration:
  104. return self._current_print_time[self._active_build_plate]
  105. materialLengthsChanged = pyqtSignal()
  106. @pyqtProperty("QVariantList", notify = materialLengthsChanged)
  107. def materialLengths(self):
  108. return self._material_lengths[self._active_build_plate]
  109. materialWeightsChanged = pyqtSignal()
  110. @pyqtProperty("QVariantList", notify = materialWeightsChanged)
  111. def materialWeights(self):
  112. return self._material_weights[self._active_build_plate]
  113. materialCostsChanged = pyqtSignal()
  114. @pyqtProperty("QVariantList", notify = materialCostsChanged)
  115. def materialCosts(self):
  116. return self._material_costs[self._active_build_plate]
  117. materialNamesChanged = pyqtSignal()
  118. @pyqtProperty("QVariantList", notify = materialNamesChanged)
  119. def materialNames(self):
  120. return self._material_names[self._active_build_plate]
  121. # Get all print times (by feature) of the active buildplate.
  122. def printTimes(self) -> Dict[str, Duration]:
  123. return self._print_times_per_feature[self._active_build_plate]
  124. def _onPrintDurationMessage(self, build_plate_number: int, print_times_per_feature: Dict[str, int], material_amounts: List[float]) -> None:
  125. self._updateTotalPrintTimePerFeature(build_plate_number, print_times_per_feature)
  126. self.currentPrintTimeChanged.emit()
  127. self._material_amounts = material_amounts
  128. self._calculateInformation(build_plate_number)
  129. def _updateTotalPrintTimePerFeature(self, build_plate_number: int, print_times_per_feature: Dict[str, int]) -> None:
  130. total_estimated_time = 0
  131. if build_plate_number not in self._print_times_per_feature:
  132. self._initPrintTimesPerFeature(build_plate_number)
  133. for feature, time in print_times_per_feature.items():
  134. if feature not in self._print_times_per_feature[build_plate_number]:
  135. self._print_times_per_feature[build_plate_number][feature] = Duration(parent=self)
  136. duration = self._print_times_per_feature[build_plate_number][feature]
  137. if time != time: # Check for NaN. Engine can sometimes give us weird values.
  138. duration.setDuration(0)
  139. Logger.log("w", "Received NaN for print duration message")
  140. continue
  141. total_estimated_time += time
  142. duration.setDuration(time)
  143. if build_plate_number not in self._current_print_time:
  144. self._current_print_time[build_plate_number] = Duration(None, self)
  145. self._current_print_time[build_plate_number].setDuration(total_estimated_time)
  146. def _calculateInformation(self, build_plate_number: int) -> None:
  147. global_stack = self._application.getGlobalContainerStack()
  148. if global_stack is None:
  149. return
  150. self._material_lengths[build_plate_number] = []
  151. self._material_weights[build_plate_number] = []
  152. self._material_costs[build_plate_number] = []
  153. self._material_names[build_plate_number] = []
  154. material_preference_values = json.loads(self._application.getInstance().getPreferences().getValue("cura/material_settings"))
  155. for index, extruder_stack in enumerate(global_stack.extruderList):
  156. if index >= len(self._material_amounts):
  157. continue
  158. amount = self._material_amounts[index]
  159. # Find the right extruder stack. As the list isn't sorted because it's a annoying generator, we do some
  160. # list comprehension filtering to solve this for us.
  161. density = extruder_stack.getMetaDataEntry("properties", {}).get("density", 0)
  162. material = extruder_stack.material
  163. radius = extruder_stack.getProperty("material_diameter", "value") / 2
  164. weight = float(amount) * float(density) / 1000
  165. cost = 0.
  166. material_guid = material.getMetaDataEntry("GUID")
  167. material_name = material.getName()
  168. if material_guid in material_preference_values:
  169. material_values = material_preference_values[material_guid]
  170. if material_values and "spool_weight" in material_values:
  171. weight_per_spool = float(material_values["spool_weight"])
  172. else:
  173. weight_per_spool = float(extruder_stack.getMetaDataEntry("properties", {}).get("weight", 0))
  174. cost_per_spool = float(material_values["spool_cost"] if material_values and "spool_cost" in material_values else 0)
  175. if weight_per_spool != 0:
  176. cost = cost_per_spool * weight / weight_per_spool
  177. else:
  178. cost = 0
  179. # Material amount is sent as an amount of mm^3, so calculate length from that
  180. if radius != 0:
  181. length = round((amount / (math.pi * radius ** 2)) / 1000, 2)
  182. else:
  183. length = 0
  184. self._material_weights[build_plate_number].append(weight)
  185. self._material_lengths[build_plate_number].append(length)
  186. self._material_costs[build_plate_number].append(cost)
  187. self._material_names[build_plate_number].append(material_name)
  188. self.materialLengthsChanged.emit()
  189. self.materialWeightsChanged.emit()
  190. self.materialCostsChanged.emit()
  191. self.materialNamesChanged.emit()
  192. def _onPreferencesChanged(self, preference: str) -> None:
  193. if preference != "cura/material_settings":
  194. return
  195. for build_plate_number in range(self._multi_build_plate_model.maxBuildPlate + 1):
  196. self._calculateInformation(build_plate_number)
  197. def _onActiveBuildPlateChanged(self) -> None:
  198. new_active_build_plate = self._multi_build_plate_model.activeBuildPlate
  199. if new_active_build_plate != self._active_build_plate:
  200. self._active_build_plate = new_active_build_plate
  201. self._updateJobName()
  202. self._initVariablesByBuildPlate(self._active_build_plate)
  203. self.materialLengthsChanged.emit()
  204. self.materialWeightsChanged.emit()
  205. self.materialCostsChanged.emit()
  206. self.materialNamesChanged.emit()
  207. self.currentPrintTimeChanged.emit()
  208. def _onActiveMaterialsChanged(self, *args, **kwargs) -> None:
  209. for build_plate_number in range(self._multi_build_plate_model.maxBuildPlate + 1):
  210. self._calculateInformation(build_plate_number)
  211. # Manual override of job name should also set the base name so that when the printer prefix is updated, it the
  212. # prefix can be added to the manually added name, not the old base name
  213. @pyqtSlot(str, bool)
  214. def setJobName(self, name: str, is_user_specified_job_name = False) -> None:
  215. self._is_user_specified_job_name = is_user_specified_job_name
  216. self._job_name = name
  217. self._base_name = name.replace(self._abbr_machine + "_", "")
  218. if name == "":
  219. self._is_user_specified_job_name = False
  220. self.jobNameChanged.emit()
  221. jobNameChanged = pyqtSignal()
  222. @pyqtProperty(str, notify = jobNameChanged)
  223. def jobName(self):
  224. return self._job_name
  225. def _updateJobName(self) -> None:
  226. if self._base_name == "":
  227. self._job_name = self.UNTITLED_JOB_NAME
  228. self._is_user_specified_job_name = False
  229. self.jobNameChanged.emit()
  230. return
  231. base_name = self._stripAccents(self._base_name)
  232. self._defineAbbreviatedMachineName()
  233. # Only update the job name when it's not user-specified.
  234. if not self._is_user_specified_job_name:
  235. if self._application.getInstance().getPreferences().getValue("cura/jobname_prefix") and not self._pre_sliced:
  236. # Don't add abbreviation if it already has the exact same abbreviation.
  237. if base_name.startswith(self._abbr_machine + "_"):
  238. self._job_name = base_name
  239. else:
  240. self._job_name = self._abbr_machine + "_" + base_name
  241. else:
  242. self._job_name = base_name
  243. # In case there are several buildplates, a suffix is attached
  244. if self._multi_build_plate_model.maxBuildPlate > 0:
  245. connector = "_#"
  246. suffix = connector + str(self._active_build_plate + 1)
  247. if connector in self._job_name:
  248. self._job_name = self._job_name.split(connector)[0] # get the real name
  249. if self._active_build_plate != 0:
  250. self._job_name += suffix
  251. self.jobNameChanged.emit()
  252. @pyqtSlot(str)
  253. def setProjectName(self, name: str) -> None:
  254. self.setBaseName(name, is_project_file = True)
  255. baseNameChanged = pyqtSignal()
  256. def setBaseName(self, base_name: str, is_project_file: bool = False) -> None:
  257. self._is_user_specified_job_name = False
  258. # Ensure that we don't use entire path but only filename
  259. name = os.path.basename(base_name)
  260. # when a file is opened using the terminal; the filename comes from _onFileLoaded and still contains its
  261. # extension. This cuts the extension off if necessary.
  262. check_name = os.path.splitext(name)[0]
  263. filename_parts = os.path.basename(base_name).split(".")
  264. # If it's a gcode, also always update the job name
  265. is_gcode = False
  266. if len(filename_parts) > 1:
  267. # Only check the extension(s)
  268. is_gcode = "gcode" in filename_parts[1:]
  269. # if this is a profile file, always update the job name
  270. # name is "" when I first had some meshes and afterwards I deleted them so the naming should start again
  271. is_empty = check_name == ""
  272. if is_gcode or is_project_file or (is_empty or (self._base_name == "" and self._base_name != check_name)):
  273. # Only take the file name part, Note : file name might have 'dot' in name as well
  274. data = ""
  275. try:
  276. mime_type = MimeTypeDatabase.getMimeTypeForFile(name)
  277. data = mime_type.stripExtension(name)
  278. except MimeTypeNotFoundError:
  279. Logger.log("w", "Unsupported Mime Type Database file extension %s", name)
  280. if data is not None and check_name is not None:
  281. self._base_name = data
  282. else:
  283. self._base_name = ""
  284. # Strip the old "curaproject" extension from the name
  285. OLD_CURA_PROJECT_EXT = ".curaproject"
  286. if self._base_name.lower().endswith(OLD_CURA_PROJECT_EXT):
  287. self._base_name = self._base_name[:len(self._base_name) - len(OLD_CURA_PROJECT_EXT)]
  288. # CURA-5896 Try to strip extra extensions with an infinite amount of ".curaproject.3mf".
  289. OLD_CURA_PROJECT_3MF_EXT = ".curaproject.3mf"
  290. while self._base_name.lower().endswith(OLD_CURA_PROJECT_3MF_EXT):
  291. self._base_name = self._base_name[:len(self._base_name) - len(OLD_CURA_PROJECT_3MF_EXT)]
  292. self._updateJobName()
  293. @pyqtProperty(str, fset = setBaseName, notify = baseNameChanged)
  294. def baseName(self):
  295. return self._base_name
  296. ## Created an acronym-like abbreviated machine name from the currently
  297. # active machine name.
  298. # Called each time the global stack is switched.
  299. def _defineAbbreviatedMachineName(self) -> None:
  300. global_container_stack = self._application.getGlobalContainerStack()
  301. if not global_container_stack:
  302. self._abbr_machine = ""
  303. return
  304. active_machine_type_name = global_container_stack.definition.getName()
  305. self._abbr_machine = self._application.getMachineManager().getAbbreviatedMachineName(active_machine_type_name)
  306. ## Utility method that strips accents from characters (eg: â -> a)
  307. def _stripAccents(self, to_strip: str) -> str:
  308. return ''.join(char for char in unicodedata.normalize('NFD', to_strip) if unicodedata.category(char) != 'Mn')
  309. @pyqtSlot(result = "QVariantMap")
  310. def getFeaturePrintTimes(self) -> Dict[str, Duration]:
  311. result = {}
  312. if self._active_build_plate not in self._print_times_per_feature:
  313. self._initPrintTimesPerFeature(self._active_build_plate)
  314. for feature, time in self._print_times_per_feature[self._active_build_plate].items():
  315. if feature in self._print_time_message_translations:
  316. result[self._print_time_message_translations[feature]] = time
  317. else:
  318. result[feature] = time
  319. return result
  320. # Simulate message with zero time duration
  321. def setToZeroPrintInformation(self, build_plate: Optional[int] = None) -> None:
  322. if build_plate is None:
  323. build_plate = self._active_build_plate
  324. # Construct the 0-time message
  325. temp_message = {}
  326. if build_plate not in self._print_times_per_feature:
  327. self._print_times_per_feature[build_plate] = {}
  328. for key in self._print_times_per_feature[build_plate].keys():
  329. temp_message[key] = 0
  330. temp_material_amounts = [0.]
  331. self._onPrintDurationMessage(build_plate, temp_message, temp_material_amounts)
  332. def _onSceneChangedDelayed(self, scene_node: SceneNode) -> None:
  333. # Ignore any changes that are not related to sliceable objects
  334. if not isinstance(scene_node, SceneNode) \
  335. or not scene_node.callDecoration("isSliceable") \
  336. or not scene_node.callDecoration("getBuildPlateNumber") == self._active_build_plate:
  337. return
  338. self._change_timer.start()
  339. ## Listen to scene changes to check if we need to reset the print information
  340. def _onSceneChanged(self) -> None:
  341. self.setToZeroPrintInformation(self._active_build_plate)