UFPWriter.py 12 KB

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