PrintInformation.py 19 KB

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