PostProcessingPlugin.py 17 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337
  1. # Copyright (c) 2018 Jaime van Kessel, Ultimaker B.V.
  2. # The PostProcessingPlugin is released under the terms of the AGPLv3 or higher.
  3. from PyQt5.QtCore import QObject, pyqtProperty, pyqtSignal, pyqtSlot
  4. from typing import Dict, Type, TYPE_CHECKING, List, Optional, cast
  5. from UM.PluginRegistry import PluginRegistry
  6. from UM.Resources import Resources
  7. from UM.Application import Application
  8. from UM.Extension import Extension
  9. from UM.Logger import Logger
  10. import configparser # The script lists are stored in metadata as serialised config files.
  11. import io # To allow configparser to write to a string.
  12. import os.path
  13. import pkgutil
  14. import sys
  15. import importlib.util
  16. from UM.i18n import i18nCatalog
  17. from cura.CuraApplication import CuraApplication
  18. i18n_catalog = i18nCatalog("cura")
  19. if TYPE_CHECKING:
  20. from .Script import Script
  21. ## The post processing plugin is an Extension type plugin that enables pre-written scripts to post process generated
  22. # g-code files.
  23. class PostProcessingPlugin(QObject, Extension):
  24. def __init__(self, parent = None) -> None:
  25. QObject.__init__(self, parent)
  26. Extension.__init__(self)
  27. self.setMenuName(i18n_catalog.i18nc("@item:inmenu", "Post Processing"))
  28. self.addMenuItem(i18n_catalog.i18nc("@item:inmenu", "Modify G-Code"), self.showPopup)
  29. self._view = None
  30. # Loaded scripts are all scripts that can be used
  31. self._loaded_scripts = {} # type: Dict[str, Type[Script]]
  32. self._script_labels = {} # type: Dict[str, str]
  33. # Script list contains instances of scripts in loaded_scripts.
  34. # There can be duplicates, which will be executed in sequence.
  35. self._script_list = [] # type: List[Script]
  36. self._selected_script_index = -1
  37. self._global_container_stack = Application.getInstance().getGlobalContainerStack()
  38. if self._global_container_stack:
  39. self._global_container_stack.metaDataChanged.connect(self._restoreScriptInforFromMetadata)
  40. Application.getInstance().getOutputDeviceManager().writeStarted.connect(self.execute)
  41. Application.getInstance().globalContainerStackChanged.connect(self._onGlobalContainerStackChanged) # When the current printer changes, update the list of scripts.
  42. CuraApplication.getInstance().mainWindowChanged.connect(self._createView) # When the main window is created, create the view so that we can display the post-processing icon if necessary.
  43. selectedIndexChanged = pyqtSignal()
  44. @pyqtProperty(str, notify = selectedIndexChanged)
  45. def selectedScriptDefinitionId(self) -> Optional[str]:
  46. try:
  47. return self._script_list[self._selected_script_index].getDefinitionId()
  48. except IndexError:
  49. return ""
  50. @pyqtProperty(str, notify=selectedIndexChanged)
  51. def selectedScriptStackId(self) -> Optional[str]:
  52. try:
  53. return self._script_list[self._selected_script_index].getStackId()
  54. except IndexError:
  55. return ""
  56. ## Execute all post-processing scripts on the gcode.
  57. def execute(self, output_device) -> None:
  58. scene = Application.getInstance().getController().getScene()
  59. # If the scene does not have a gcode, do nothing
  60. if not hasattr(scene, "gcode_dict"):
  61. return
  62. gcode_dict = getattr(scene, "gcode_dict")
  63. if not gcode_dict:
  64. return
  65. # get gcode list for the active build plate
  66. active_build_plate_id = CuraApplication.getInstance().getMultiBuildPlateModel().activeBuildPlate
  67. gcode_list = gcode_dict[active_build_plate_id]
  68. if not gcode_list:
  69. return
  70. if ";POSTPROCESSED" not in gcode_list[0]:
  71. for script in self._script_list:
  72. try:
  73. gcode_list = script.execute(gcode_list)
  74. except Exception:
  75. Logger.logException("e", "Exception in post-processing script.")
  76. if len(self._script_list): # Add comment to g-code if any changes were made.
  77. gcode_list[0] += ";POSTPROCESSED\n"
  78. gcode_dict[active_build_plate_id] = gcode_list
  79. setattr(scene, "gcode_dict", gcode_dict)
  80. else:
  81. Logger.log("e", "Already post processed")
  82. @pyqtSlot(int)
  83. def setSelectedScriptIndex(self, index: int) -> None:
  84. if self._selected_script_index != index:
  85. self._selected_script_index = index
  86. self.selectedIndexChanged.emit()
  87. @pyqtProperty(int, notify = selectedIndexChanged)
  88. def selectedScriptIndex(self) -> int:
  89. return self._selected_script_index
  90. @pyqtSlot(int, int)
  91. def moveScript(self, index: int, new_index: int) -> None:
  92. if new_index < 0 or new_index > len(self._script_list) - 1:
  93. return # nothing needs to be done
  94. else:
  95. # Magical switch code.
  96. self._script_list[new_index], self._script_list[index] = self._script_list[index], self._script_list[new_index]
  97. self.scriptListChanged.emit()
  98. self.selectedIndexChanged.emit() #Ensure that settings are updated
  99. self._propertyChanged()
  100. ## Remove a script from the active script list by index.
  101. @pyqtSlot(int)
  102. def removeScriptByIndex(self, index: int) -> None:
  103. self._script_list.pop(index)
  104. if len(self._script_list) - 1 < self._selected_script_index:
  105. self._selected_script_index = len(self._script_list) - 1
  106. self.scriptListChanged.emit()
  107. self.selectedIndexChanged.emit() # Ensure that settings are updated
  108. self._propertyChanged()
  109. ## Load all scripts from all paths where scripts can be found.
  110. #
  111. # This should probably only be done on init.
  112. def loadAllScripts(self) -> None:
  113. if self._loaded_scripts: # Already loaded.
  114. return
  115. # The PostProcessingPlugin path is for built-in scripts.
  116. # The Resources path is where the user should store custom scripts.
  117. # The Preferences path is legacy, where the user may previously have stored scripts.
  118. for root in [PluginRegistry.getInstance().getPluginPath("PostProcessingPlugin"), Resources.getStoragePath(Resources.Resources), Resources.getStoragePath(Resources.Preferences)]:
  119. if root is None:
  120. continue
  121. path = os.path.join(root, "scripts")
  122. if not os.path.isdir(path):
  123. try:
  124. os.makedirs(path)
  125. except OSError:
  126. Logger.log("w", "Unable to create a folder for scripts: " + path)
  127. continue
  128. self.loadScripts(path)
  129. ## Load all scripts from provided path.
  130. # This should probably only be done on init.
  131. # \param path Path to check for scripts.
  132. def loadScripts(self, path: str) -> None:
  133. ## Load all scripts in the scripts folders
  134. scripts = pkgutil.iter_modules(path = [path])
  135. for loader, script_name, ispkg in scripts:
  136. # Iterate over all scripts.
  137. if script_name not in sys.modules:
  138. try:
  139. spec = importlib.util.spec_from_file_location(__name__ + "." + script_name, os.path.join(path, script_name + ".py"))
  140. loaded_script = importlib.util.module_from_spec(spec)
  141. if spec.loader is None:
  142. continue
  143. spec.loader.exec_module(loaded_script) # type: ignore
  144. sys.modules[script_name] = loaded_script #TODO: This could be a security risk. Overwrite any module with a user-provided name?
  145. loaded_class = getattr(loaded_script, script_name)
  146. temp_object = loaded_class()
  147. Logger.log("d", "Begin loading of script: %s", script_name)
  148. try:
  149. setting_data = temp_object.getSettingData()
  150. if "name" in setting_data and "key" in setting_data:
  151. self._script_labels[setting_data["key"]] = setting_data["name"]
  152. self._loaded_scripts[setting_data["key"]] = loaded_class
  153. else:
  154. Logger.log("w", "Script %s.py has no name or key", script_name)
  155. self._script_labels[script_name] = script_name
  156. self._loaded_scripts[script_name] = loaded_class
  157. except AttributeError:
  158. Logger.log("e", "Script %s.py is not a recognised script type. Ensure it inherits Script", script_name)
  159. except NotImplementedError:
  160. Logger.log("e", "Script %s.py has no implemented settings", script_name)
  161. except Exception as e:
  162. Logger.logException("e", "Exception occurred while loading post processing plugin: {error_msg}".format(error_msg = str(e)))
  163. loadedScriptListChanged = pyqtSignal()
  164. @pyqtProperty("QVariantList", notify = loadedScriptListChanged)
  165. def loadedScriptList(self) -> List[str]:
  166. return sorted(list(self._loaded_scripts.keys()))
  167. @pyqtSlot(str, result = str)
  168. def getScriptLabelByKey(self, key: str) -> Optional[str]:
  169. return self._script_labels.get(key)
  170. scriptListChanged = pyqtSignal()
  171. @pyqtProperty("QStringList", notify = scriptListChanged)
  172. def scriptList(self) -> List[str]:
  173. script_list = [script.getSettingData()["key"] for script in self._script_list]
  174. return script_list
  175. @pyqtSlot(str)
  176. def addScriptToList(self, key: str) -> None:
  177. Logger.log("d", "Adding script %s to list.", key)
  178. new_script = self._loaded_scripts[key]()
  179. new_script.initialize()
  180. self._script_list.append(new_script)
  181. self.setSelectedScriptIndex(len(self._script_list) - 1)
  182. self.scriptListChanged.emit()
  183. self._propertyChanged()
  184. def _restoreScriptInforFromMetadata(self):
  185. self.loadAllScripts()
  186. new_stack = self._global_container_stack
  187. if new_stack is None:
  188. return
  189. self._script_list.clear()
  190. if not new_stack.getMetaDataEntry("post_processing_scripts"): # Missing or empty.
  191. self.scriptListChanged.emit() # Even emit this if it didn't change. We want it to write the empty list to the stack's metadata.
  192. self.setSelectedScriptIndex(-1)
  193. return
  194. self._script_list.clear()
  195. scripts_list_strs = new_stack.getMetaDataEntry("post_processing_scripts")
  196. for script_str in scripts_list_strs.split(
  197. "\n"): # Encoded config files should never contain three newlines in a row. At most 2, just before section headers.
  198. if not script_str: # There were no scripts in this one (or a corrupt file caused more than 3 consecutive newlines here).
  199. continue
  200. script_str = script_str.replace(r"\\\n", "\n").replace(r"\\\\", "\\\\") # Unescape escape sequences.
  201. script_parser = configparser.ConfigParser(interpolation=None)
  202. script_parser.optionxform = str # type: ignore # Don't transform the setting keys as they are case-sensitive.
  203. script_parser.read_string(script_str)
  204. for script_name, settings in script_parser.items(): # There should only be one, really! Otherwise we can't guarantee the order or allow multiple uses of the same script.
  205. if script_name == "DEFAULT": # ConfigParser always has a DEFAULT section, but we don't fill it. Ignore this one.
  206. continue
  207. if script_name not in self._loaded_scripts: # Don't know this post-processing plug-in.
  208. Logger.log("e",
  209. "Unknown post-processing script {script_name} was encountered in this global stack.".format(
  210. script_name=script_name))
  211. continue
  212. new_script = self._loaded_scripts[script_name]()
  213. new_script.initialize()
  214. for setting_key, setting_value in settings.items(): # Put all setting values into the script.
  215. if new_script._instance is not None:
  216. new_script._instance.setProperty(setting_key, "value", setting_value)
  217. self._script_list.append(new_script)
  218. self.setSelectedScriptIndex(0)
  219. # Ensure that we always force an update (otherwise the fields don't update correctly!)
  220. self.selectedIndexChanged.emit()
  221. self.scriptListChanged.emit()
  222. self._propertyChanged()
  223. ## When the global container stack is changed, swap out the list of active
  224. # scripts.
  225. def _onGlobalContainerStackChanged(self) -> None:
  226. if self._global_container_stack:
  227. self._global_container_stack.metaDataChanged.disconnect(self._restoreScriptInforFromMetadata)
  228. self._global_container_stack = Application.getInstance().getGlobalContainerStack()
  229. if self._global_container_stack:
  230. self._global_container_stack.metaDataChanged.connect(self._restoreScriptInforFromMetadata)
  231. self._restoreScriptInforFromMetadata()
  232. @pyqtSlot()
  233. def writeScriptsToStack(self) -> None:
  234. script_list_strs = [] # type: List[str]
  235. for script in self._script_list:
  236. parser = configparser.ConfigParser(interpolation = None) # We'll encode the script as a config with one section. The section header is the key and its values are the settings.
  237. parser.optionxform = str # type: ignore # Don't transform the setting keys as they are case-sensitive.
  238. script_name = script.getSettingData()["key"]
  239. parser.add_section(script_name)
  240. for key in script.getSettingData()["settings"]:
  241. value = script.getSettingValueByKey(key)
  242. parser[script_name][key] = str(value)
  243. serialized = io.StringIO() # ConfigParser can only write to streams. Fine.
  244. parser.write(serialized)
  245. serialized.seek(0)
  246. script_str = serialized.read()
  247. script_str = script_str.replace("\\\\", r"\\\\").replace("\n", r"\\\n") # Escape newlines because configparser sees those as section delimiters.
  248. script_list_strs.append(script_str)
  249. script_list_string = "\n".join(script_list_strs) # ConfigParser should never output three newlines in a row when serialised, so it's a safe delimiter.
  250. if self._global_container_stack is None:
  251. return
  252. # Ensure we don't get triggered by our own write.
  253. self._global_container_stack.metaDataChanged.disconnect(self._restoreScriptInforFromMetadata)
  254. if "post_processing_scripts" not in self._global_container_stack.getMetaData():
  255. self._global_container_stack.setMetaDataEntry("post_processing_scripts", "")
  256. self._global_container_stack.setMetaDataEntry("post_processing_scripts", script_list_string)
  257. # We do want to listen to other events.
  258. self._global_container_stack.metaDataChanged.connect(self._restoreScriptInforFromMetadata)
  259. ## Creates the view used by show popup. The view is saved because of the fairly aggressive garbage collection.
  260. def _createView(self) -> None:
  261. Logger.log("d", "Creating post processing plugin view.")
  262. self.loadAllScripts()
  263. # Create the plugin dialog component
  264. path = os.path.join(cast(str, PluginRegistry.getInstance().getPluginPath("PostProcessingPlugin")), "PostProcessingPlugin.qml")
  265. self._view = CuraApplication.getInstance().createQmlComponent(path, {"manager": self})
  266. if self._view is None:
  267. Logger.log("e", "Not creating PostProcessing button near save button because the QML component failed to be created.")
  268. return
  269. Logger.log("d", "Post processing view created.")
  270. # Create the save button component
  271. CuraApplication.getInstance().addAdditionalComponent("saveButton", self._view.findChild(QObject, "postProcessingSaveAreaButton"))
  272. ## Show the (GUI) popup of the post processing plugin.
  273. def showPopup(self) -> None:
  274. if self._view is None:
  275. self._createView()
  276. if self._view is None:
  277. Logger.log("e", "Not creating PostProcessing window since the QML component failed to be created.")
  278. return
  279. self._view.show()
  280. ## Property changed: trigger re-slice
  281. # To do this we use the global container stack propertyChanged.
  282. # Re-slicing is necessary for setting changes in this plugin, because the changes
  283. # are applied only once per "fresh" gcode
  284. def _propertyChanged(self) -> None:
  285. global_container_stack = Application.getInstance().getGlobalContainerStack()
  286. if global_container_stack is not None:
  287. global_container_stack.propertyChanged.emit("post_processing_plugin", "value")