UFPWriter.py 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266
  1. # Copyright (c) 2022 Ultimaker B.V.
  2. # Cura is released under the terms of the LGPLv3 or higher.
  3. import json
  4. from dataclasses import asdict
  5. from typing import cast, List, Dict
  6. from Charon.VirtualFile import VirtualFile # To open UFP files.
  7. from Charon.OpenMode import OpenMode # To indicate that we want to write to UFP files.
  8. from Charon.filetypes.OpenPackagingConvention import OPCError
  9. from io import StringIO # For converting g-code to bytes.
  10. from PyQt6.QtCore import QBuffer
  11. from UM.Application import Application
  12. from UM.Logger import Logger
  13. from UM.Settings.SettingFunction import SettingFunction
  14. from UM.Mesh.MeshWriter import MeshWriter # The writer we need to implement.
  15. from UM.MimeTypeDatabase import MimeTypeDatabase, MimeType
  16. from UM.PluginRegistry import PluginRegistry # To get the g-code writer.
  17. from UM.Scene.Iterator.DepthFirstIterator import DepthFirstIterator
  18. from UM.Scene.SceneNode import SceneNode
  19. from UM.Settings.InstanceContainer import InstanceContainer
  20. from cura.CuraApplication import CuraApplication
  21. from cura.Settings.GlobalStack import GlobalStack
  22. from cura.Utils.Threading import call_on_qt_thread
  23. from UM.i18n import i18nCatalog
  24. METADATA_OBJECTS_PATH = "metadata/objects"
  25. SLICE_METADATA_PATH = "Cura/slicemetadata.json"
  26. catalog = i18nCatalog("cura")
  27. class UFPWriter(MeshWriter):
  28. def __init__(self):
  29. super().__init__(add_to_recent_files = False)
  30. MimeTypeDatabase.addMimeType(
  31. MimeType(
  32. name = "application/x-ufp",
  33. comment = "UltiMaker Format Package",
  34. suffixes = ["ufp"]
  35. )
  36. )
  37. # This needs to be called on the main thread (Qt thread) because the serialization of material containers can
  38. # trigger loading other containers. Because those loaded containers are QtObjects, they must be created on the
  39. # Qt thread. The File read/write operations right now are executed on separated threads because they are scheduled
  40. # by the Job class.
  41. @call_on_qt_thread
  42. def write(self, stream, nodes, mode = MeshWriter.OutputMode.BinaryMode):
  43. archive = VirtualFile()
  44. archive.openStream(stream, "application/x-ufp", OpenMode.WriteOnly)
  45. try:
  46. self._writeObjectList(archive)
  47. # Store the g-code from the scene.
  48. archive.addContentType(extension = "gcode", mime_type = "text/x-gcode")
  49. except EnvironmentError as e:
  50. error_msg = catalog.i18nc("@info:error", "Can't write to UFP file:") + " " + str(e)
  51. self.setInformation(error_msg)
  52. Logger.error(error_msg)
  53. return False
  54. gcode_textio = StringIO() # We have to convert the g-code into bytes.
  55. gcode_writer = cast(MeshWriter, PluginRegistry.getInstance().getPluginObject("GCodeWriter"))
  56. success = gcode_writer.write(gcode_textio, None)
  57. if not success: # Writing the g-code failed. Then I can also not write the gzipped g-code.
  58. self.setInformation(gcode_writer.getInformation())
  59. return False
  60. try:
  61. gcode = archive.getStream("/3D/model.gcode")
  62. gcode.write(gcode_textio.getvalue().encode("UTF-8"))
  63. archive.addRelation(virtual_path = "/3D/model.gcode",
  64. relation_type = "http://schemas.ultimaker.org/package/2018/relationships/gcode")
  65. except EnvironmentError as e:
  66. error_msg = catalog.i18nc("@info:error", "Can't write to UFP file:") + " " + str(e)
  67. self.setInformation(error_msg)
  68. Logger.error(error_msg)
  69. return False
  70. # Write settings
  71. try:
  72. archive.addContentType(extension="json", mime_type="application/json")
  73. setting_textio = StringIO()
  74. json.dump(self._getSliceMetadata(), setting_textio, separators=(", ", ": "), indent=4)
  75. steam = archive.getStream(SLICE_METADATA_PATH)
  76. steam.write(setting_textio.getvalue().encode("UTF-8"))
  77. except EnvironmentError as e:
  78. error_msg = catalog.i18nc("@info:error", "Can't write to UFP file:") + " " + str(e)
  79. self.setInformation(error_msg)
  80. Logger.error(error_msg)
  81. return False
  82. # Attempt to store the thumbnail, if any:
  83. backend = CuraApplication.getInstance().getBackend()
  84. snapshot = None if getattr(backend, "getLatestSnapshot", None) is None else backend.getLatestSnapshot()
  85. if snapshot:
  86. try:
  87. archive.addContentType(extension = "png", mime_type = "image/png")
  88. thumbnail = archive.getStream("/Metadata/thumbnail.png")
  89. thumbnail_buffer = QBuffer()
  90. thumbnail_buffer.open(QBuffer.OpenModeFlag.ReadWrite)
  91. snapshot.save(thumbnail_buffer, "PNG")
  92. thumbnail.write(thumbnail_buffer.data())
  93. archive.addRelation(virtual_path = "/Metadata/thumbnail.png",
  94. relation_type = "http://schemas.openxmlformats.org/package/2006/relationships/metadata/thumbnail",
  95. origin = "/3D/model.gcode")
  96. except EnvironmentError as e:
  97. error_msg = catalog.i18nc("@info:error", "Can't write to UFP file:") + " " + str(e)
  98. self.setInformation(error_msg)
  99. Logger.error(error_msg)
  100. return False
  101. else:
  102. Logger.log("w", "Thumbnail not created, cannot save it")
  103. # Store the material.
  104. application = CuraApplication.getInstance()
  105. machine_manager = application.getMachineManager()
  106. container_registry = application.getContainerRegistry()
  107. global_stack = machine_manager.activeMachine
  108. material_extension = "xml.fdm_material"
  109. material_mime_type = "application/x-ultimaker-material-profile"
  110. try:
  111. archive.addContentType(extension = material_extension, mime_type = material_mime_type)
  112. except OPCError:
  113. Logger.log("w", "The material extension: %s was already added", material_extension)
  114. added_materials = []
  115. for extruder_stack in global_stack.extruderList:
  116. material = extruder_stack.material
  117. try:
  118. material_file_name = material.getMetaData()["base_file"] + ".xml.fdm_material"
  119. except KeyError:
  120. Logger.log("w", "Unable to get base_file for the material %s", material.getId())
  121. continue
  122. material_file_name = "/Materials/" + material_file_name
  123. # The same material should not be added again.
  124. if material_file_name in added_materials:
  125. continue
  126. material_root_id = material.getMetaDataEntry("base_file")
  127. material_root_query = container_registry.findContainers(id = material_root_id)
  128. if not material_root_query:
  129. Logger.log("e", "Cannot find material container with root id {root_id}".format(root_id = material_root_id))
  130. return False
  131. material_container = material_root_query[0]
  132. try:
  133. serialized_material = material_container.serialize()
  134. except NotImplementedError:
  135. Logger.log("e", "Unable serialize material container with root id: %s", material_root_id)
  136. return False
  137. try:
  138. material_file = archive.getStream(material_file_name)
  139. material_file.write(serialized_material.encode("UTF-8"))
  140. archive.addRelation(virtual_path = material_file_name,
  141. relation_type = "http://schemas.ultimaker.org/package/2018/relationships/material",
  142. origin = "/3D/model.gcode")
  143. except EnvironmentError as e:
  144. error_msg = catalog.i18nc("@info:error", "Can't write to UFP file:") + " " + str(e)
  145. self.setInformation(error_msg)
  146. Logger.error(error_msg)
  147. return False
  148. added_materials.append(material_file_name)
  149. try:
  150. archive.close()
  151. except EnvironmentError as e:
  152. error_msg = catalog.i18nc("@info:error", "Can't write to UFP file:") + " " + str(e)
  153. self.setInformation(error_msg)
  154. Logger.error(error_msg)
  155. return False
  156. return True
  157. @staticmethod
  158. def _writeObjectList(archive):
  159. """Write a json list of object names to the METADATA_OBJECTS_PATH metadata field
  160. To retrieve, use: `archive.getMetadata(METADATA_OBJECTS_PATH)`
  161. """
  162. objects_model = CuraApplication.getInstance().getObjectsModel()
  163. object_metas = []
  164. for item in objects_model.items:
  165. object_metas.extend(UFPWriter._getObjectMetadata(item["node"]))
  166. data = {METADATA_OBJECTS_PATH: object_metas}
  167. archive.setMetadata(data)
  168. @staticmethod
  169. def _getObjectMetadata(node: SceneNode) -> List[Dict[str, str]]:
  170. """Get object metadata to write for a Node.
  171. :return: List of object metadata dictionaries.
  172. Might contain > 1 element in case of a group node.
  173. Might be empty in case of nonPrintingMesh
  174. """
  175. return [{"name": item.getName()}
  176. for item in DepthFirstIterator(node)
  177. if item.getMeshData() is not None and not item.callDecoration("isNonPrintingMesh")]
  178. def _getSliceMetadata(self) -> Dict[str, Dict[str, Dict[str, str]]]:
  179. """Get all changed settings and all settings. For each extruder and the global stack"""
  180. print_information = CuraApplication.getInstance().getPrintInformation()
  181. machine_manager = CuraApplication.getInstance().getMachineManager()
  182. settings = {
  183. "material": {
  184. "length": print_information.materialLengths,
  185. "weight": print_information.materialWeights,
  186. "cost": print_information.materialCosts,
  187. },
  188. "global": {
  189. "changes": {},
  190. "all_settings": {},
  191. },
  192. "quality": asdict(machine_manager.activeQualityDisplayNameMap()),
  193. }
  194. def _retrieveValue(container: InstanceContainer, setting_: str):
  195. value_ = container.getProperty(setting_, "value")
  196. for _ in range(0, 1024): # Prevent possibly endless loop by not using a limit.
  197. if not isinstance(value_, SettingFunction):
  198. return value_ # Success!
  199. value_ = value_(container)
  200. return 0 # Fallback value after breaking possibly endless loop.
  201. global_stack = cast(GlobalStack, Application.getInstance().getGlobalContainerStack())
  202. # Add global user or quality changes
  203. global_flattened_changes = InstanceContainer.createMergedInstanceContainer(global_stack.userChanges, global_stack.qualityChanges)
  204. for setting in global_flattened_changes.getAllKeys():
  205. settings["global"]["changes"][setting] = _retrieveValue(global_flattened_changes, setting)
  206. # Get global all settings values without user or quality changes
  207. for setting in global_stack.getAllKeys():
  208. settings["global"]["all_settings"][setting] = _retrieveValue(global_stack, setting)
  209. for i, extruder in enumerate(global_stack.extruderList):
  210. # Add extruder fields to settings dictionary
  211. settings[f"extruder_{i}"] = {
  212. "changes": {},
  213. "all_settings": {},
  214. }
  215. # Add extruder user or quality changes
  216. extruder_flattened_changes = InstanceContainer.createMergedInstanceContainer(extruder.userChanges, extruder.qualityChanges)
  217. for setting in extruder_flattened_changes.getAllKeys():
  218. settings[f"extruder_{i}"]["changes"][setting] = _retrieveValue(extruder_flattened_changes, setting)
  219. # Get extruder all settings values without user or quality changes
  220. for setting in extruder.getAllKeys():
  221. settings[f"extruder_{i}"]["all_settings"][setting] = _retrieveValue(extruder, setting)
  222. return settings