ThreeMFWriter.py 14 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306
  1. # Copyright (c) 2015-2022 Ultimaker B.V.
  2. # Cura is released under the terms of the LGPLv3 or higher.
  3. import json
  4. from typing import Optional, cast, List, Dict
  5. from UM.Mesh.MeshWriter import MeshWriter
  6. from UM.Math.Vector import Vector
  7. from UM.Logger import Logger
  8. from UM.Math.Matrix import Matrix
  9. from UM.Application import Application
  10. from UM.Message import Message
  11. from UM.Scene.SceneNode import SceneNode
  12. from cura.CuraApplication import CuraApplication
  13. from cura.CuraPackageManager import CuraPackageManager
  14. from cura.Utils.Threading import call_on_qt_thread
  15. from cura.Snapshot import Snapshot
  16. from PyQt6.QtCore import QBuffer
  17. import pySavitar as Savitar
  18. import numpy
  19. import datetime
  20. MYPY = False
  21. try:
  22. if not MYPY:
  23. import xml.etree.cElementTree as ET
  24. except ImportError:
  25. Logger.log("w", "Unable to load cElementTree, switching to slower version")
  26. import xml.etree.ElementTree as ET
  27. import zipfile
  28. import UM.Application
  29. from UM.i18n import i18nCatalog
  30. catalog = i18nCatalog("cura")
  31. THUMBNAIL_PATH = "Metadata/thumbnail.png"
  32. MODEL_PATH = "3D/3dmodel.model"
  33. PACKAGE_METADATA_PATH = "Metadata/packages.json"
  34. class ThreeMFWriter(MeshWriter):
  35. def __init__(self):
  36. super().__init__()
  37. self._namespaces = {
  38. "3mf": "http://schemas.microsoft.com/3dmanufacturing/core/2015/02",
  39. "content-types": "http://schemas.openxmlformats.org/package/2006/content-types",
  40. "relationships": "http://schemas.openxmlformats.org/package/2006/relationships",
  41. "cura": "http://software.ultimaker.com/xml/cura/3mf/2015/10"
  42. }
  43. self._unit_matrix_string = self._convertMatrixToString(Matrix())
  44. self._archive: Optional[zipfile.ZipFile] = None
  45. self._store_archive = False
  46. def _convertMatrixToString(self, matrix):
  47. result = ""
  48. result += str(matrix._data[0, 0]) + " "
  49. result += str(matrix._data[1, 0]) + " "
  50. result += str(matrix._data[2, 0]) + " "
  51. result += str(matrix._data[0, 1]) + " "
  52. result += str(matrix._data[1, 1]) + " "
  53. result += str(matrix._data[2, 1]) + " "
  54. result += str(matrix._data[0, 2]) + " "
  55. result += str(matrix._data[1, 2]) + " "
  56. result += str(matrix._data[2, 2]) + " "
  57. result += str(matrix._data[0, 3]) + " "
  58. result += str(matrix._data[1, 3]) + " "
  59. result += str(matrix._data[2, 3])
  60. return result
  61. def setStoreArchive(self, store_archive):
  62. """Should we store the archive
  63. Note that if this is true, the archive will not be closed.
  64. The object that set this parameter is then responsible for closing it correctly!
  65. """
  66. self._store_archive = store_archive
  67. def _convertUMNodeToSavitarNode(self, um_node, transformation = Matrix()):
  68. """Convenience function that converts an Uranium SceneNode object to a SavitarSceneNode
  69. :returns: Uranium Scene node.
  70. """
  71. if not isinstance(um_node, SceneNode):
  72. return None
  73. active_build_plate_nr = CuraApplication.getInstance().getMultiBuildPlateModel().activeBuildPlate
  74. if um_node.callDecoration("getBuildPlateNumber") != active_build_plate_nr:
  75. return
  76. savitar_node = Savitar.SceneNode()
  77. savitar_node.setName(um_node.getName())
  78. node_matrix = um_node.getLocalTransformation()
  79. matrix_string = self._convertMatrixToString(node_matrix.preMultiply(transformation))
  80. savitar_node.setTransformation(matrix_string)
  81. mesh_data = um_node.getMeshData()
  82. if mesh_data is not None:
  83. savitar_node.getMeshData().setVerticesFromBytes(mesh_data.getVerticesAsByteArray())
  84. indices_array = mesh_data.getIndicesAsByteArray()
  85. if indices_array is not None:
  86. savitar_node.getMeshData().setFacesFromBytes(indices_array)
  87. else:
  88. savitar_node.getMeshData().setFacesFromBytes(numpy.arange(mesh_data.getVertices().size / 3, dtype=numpy.int32).tostring())
  89. # Handle per object settings (if any)
  90. stack = um_node.callDecoration("getStack")
  91. if stack is not None:
  92. changed_setting_keys = stack.getTop().getAllKeys()
  93. # Ensure that we save the extruder used for this object in a multi-extrusion setup
  94. if stack.getProperty("machine_extruder_count", "value") > 1:
  95. changed_setting_keys.add("extruder_nr")
  96. # Get values for all changed settings & save them.
  97. for key in changed_setting_keys:
  98. savitar_node.setSetting("cura:" + key, str(stack.getProperty(key, "value")))
  99. # Store the metadata.
  100. for key, value in um_node.metadata.items():
  101. savitar_node.setSetting(key, value)
  102. for child_node in um_node.getChildren():
  103. # only save the nodes on the active build plate
  104. if child_node.callDecoration("getBuildPlateNumber") != active_build_plate_nr:
  105. continue
  106. savitar_child_node = self._convertUMNodeToSavitarNode(child_node)
  107. if savitar_child_node is not None:
  108. savitar_node.addChild(savitar_child_node)
  109. return savitar_node
  110. def getArchive(self):
  111. return self._archive
  112. def write(self, stream, nodes, mode = MeshWriter.OutputMode.BinaryMode) -> bool:
  113. self._archive = None # Reset archive
  114. archive = zipfile.ZipFile(stream, "w", compression = zipfile.ZIP_DEFLATED)
  115. try:
  116. model_file = zipfile.ZipInfo(MODEL_PATH)
  117. # Because zipfile is stupid and ignores archive-level compression settings when writing with ZipInfo.
  118. model_file.compress_type = zipfile.ZIP_DEFLATED
  119. # Create content types file
  120. content_types_file = zipfile.ZipInfo("[Content_Types].xml")
  121. content_types_file.compress_type = zipfile.ZIP_DEFLATED
  122. content_types = ET.Element("Types", xmlns = self._namespaces["content-types"])
  123. rels_type = ET.SubElement(content_types, "Default", Extension = "rels", ContentType = "application/vnd.openxmlformats-package.relationships+xml")
  124. model_type = ET.SubElement(content_types, "Default", Extension = "model", ContentType = "application/vnd.ms-package.3dmanufacturing-3dmodel+xml")
  125. # Create _rels/.rels file
  126. relations_file = zipfile.ZipInfo("_rels/.rels")
  127. relations_file.compress_type = zipfile.ZIP_DEFLATED
  128. relations_element = ET.Element("Relationships", xmlns = self._namespaces["relationships"])
  129. model_relation_element = ET.SubElement(relations_element, "Relationship", Target = "/" + MODEL_PATH, Id = "rel0", Type = "http://schemas.microsoft.com/3dmanufacturing/2013/01/3dmodel")
  130. # Attempt to add a thumbnail
  131. snapshot = self._createSnapshot()
  132. if snapshot:
  133. thumbnail_buffer = QBuffer()
  134. thumbnail_buffer.open(QBuffer.OpenModeFlag.ReadWrite)
  135. snapshot.save(thumbnail_buffer, "PNG")
  136. thumbnail_file = zipfile.ZipInfo(THUMBNAIL_PATH)
  137. # Don't try to compress snapshot file, because the PNG is pretty much as compact as it will get
  138. archive.writestr(thumbnail_file, thumbnail_buffer.data())
  139. # Add PNG to content types file
  140. thumbnail_type = ET.SubElement(content_types, "Default", Extension = "png", ContentType = "image/png")
  141. # Add thumbnail relation to _rels/.rels file
  142. thumbnail_relation_element = ET.SubElement(relations_element, "Relationship", Target = "/" + THUMBNAIL_PATH, Id = "rel1", Type = "http://schemas.openxmlformats.org/package/2006/relationships/metadata/thumbnail")
  143. # Write material metadata
  144. material_metadata = self._getMaterialPackageMetadata()
  145. self._storeMetadataJson({"packages": material_metadata}, archive, PACKAGE_METADATA_PATH)
  146. savitar_scene = Savitar.Scene()
  147. scene_metadata = CuraApplication.getInstance().getController().getScene().getMetaData()
  148. for key, value in scene_metadata.items():
  149. savitar_scene.setMetaDataEntry(key, value)
  150. current_time_string = datetime.datetime.now().strftime("%Y-%m-%d %H:%M:%S")
  151. if "Application" not in scene_metadata:
  152. # This might sound a bit strange, but this field should store the original application that created
  153. # the 3mf. So if it was already set, leave it to whatever it was.
  154. savitar_scene.setMetaDataEntry("Application", CuraApplication.getInstance().getApplicationDisplayName())
  155. if "CreationDate" not in scene_metadata:
  156. savitar_scene.setMetaDataEntry("CreationDate", current_time_string)
  157. savitar_scene.setMetaDataEntry("ModificationDate", current_time_string)
  158. transformation_matrix = Matrix()
  159. transformation_matrix._data[1, 1] = 0
  160. transformation_matrix._data[1, 2] = -1
  161. transformation_matrix._data[2, 1] = 1
  162. transformation_matrix._data[2, 2] = 0
  163. global_container_stack = Application.getInstance().getGlobalContainerStack()
  164. # Second step: 3MF defines the left corner of the machine as center, whereas cura uses the center of the
  165. # build volume.
  166. if global_container_stack:
  167. translation_vector = Vector(x=global_container_stack.getProperty("machine_width", "value") / 2,
  168. y=global_container_stack.getProperty("machine_depth", "value") / 2,
  169. z=0)
  170. translation_matrix = Matrix()
  171. translation_matrix.setByTranslation(translation_vector)
  172. transformation_matrix.preMultiply(translation_matrix)
  173. root_node = UM.Application.Application.getInstance().getController().getScene().getRoot()
  174. for node in nodes:
  175. if node == root_node:
  176. for root_child in node.getChildren():
  177. savitar_node = self._convertUMNodeToSavitarNode(root_child, transformation_matrix)
  178. if savitar_node:
  179. savitar_scene.addSceneNode(savitar_node)
  180. else:
  181. savitar_node = self._convertUMNodeToSavitarNode(node, transformation_matrix)
  182. if savitar_node:
  183. savitar_scene.addSceneNode(savitar_node)
  184. parser = Savitar.ThreeMFParser()
  185. scene_string = parser.sceneToString(savitar_scene)
  186. archive.writestr(model_file, scene_string)
  187. archive.writestr(content_types_file, b'<?xml version="1.0" encoding="UTF-8"?> \n' + ET.tostring(content_types))
  188. archive.writestr(relations_file, b'<?xml version="1.0" encoding="UTF-8"?> \n' + ET.tostring(relations_element))
  189. except Exception as e:
  190. Logger.logException("e", "Error writing zip file")
  191. self.setInformation(catalog.i18nc("@error:zip", "Error writing 3mf file."))
  192. return False
  193. finally:
  194. if not self._store_archive:
  195. archive.close()
  196. else:
  197. self._archive = archive
  198. return True
  199. @staticmethod
  200. def _storeMetadataJson(metadata: Dict[str, List[Dict[str, str]]], archive: zipfile.ZipFile, path: str) -> None:
  201. """Stores metadata inside archive path as json file"""
  202. metadata_file = zipfile.ZipInfo(path)
  203. # We have to set the compress type of each file as well (it doesn't keep the type of the entire archive)
  204. metadata_file.compress_type = zipfile.ZIP_DEFLATED
  205. archive.writestr(metadata_file, json.dumps(metadata, separators=(", ", ": "), indent=4, skipkeys=True, ensure_ascii=False))
  206. @staticmethod
  207. def _getMaterialPackageMetadata() -> List[Dict[str, str]]:
  208. """Get metadata for installed materials in active extruder stack, this does not include bundled materials.
  209. :return: List of material metadata dictionaries.
  210. """
  211. metadata = {}
  212. package_manager = cast(CuraPackageManager, CuraApplication.getInstance().getPackageManager())
  213. for extruder in CuraApplication.getInstance().getExtruderManager().getActiveExtruderStacks():
  214. if not extruder.isEnabled:
  215. # Don't export materials not in use
  216. continue
  217. package_id = package_manager.getMaterialFilePackageId(extruder.material.getFileName(), extruder.material.getMetaDataEntry("GUID"))
  218. package_data = package_manager.getInstalledPackageInfo(package_id)
  219. if not package_data:
  220. message = Message(catalog.i18nc("@error:material",
  221. "It was not possible to store material package information in project file: {material}. This project may not open correctly on other systems.".format(material=extruder.getName())),
  222. title=catalog.i18nc("@info:title", "Failed to save material package information"),
  223. message_type=Message.MessageType.WARNING)
  224. message.show()
  225. continue
  226. if package_data.get("is_bundled"):
  227. continue
  228. material_metadata = {"id": package_id,
  229. "display_name": package_data.get("display_name") if package_data.get("display_name") else "",
  230. "package_version": package_data.get("package_version") if package_data.get("package_version") else "",
  231. "sdk_version_semver": package_data.get("sdk_version_semver") if package_data.get("sdk_version_semver") else ""}
  232. metadata[package_id] = material_metadata
  233. # Storing in a dict and fetching values to avoid duplicates
  234. return list(metadata.values())
  235. @call_on_qt_thread # must be called from the main thread because of OpenGL
  236. def _createSnapshot(self):
  237. Logger.log("d", "Creating thumbnail image...")
  238. if not CuraApplication.getInstance().isVisible:
  239. Logger.log("w", "Can't create snapshot when renderer not initialized.")
  240. return None
  241. try:
  242. snapshot = Snapshot.snapshot(width = 300, height = 300)
  243. except:
  244. Logger.logException("w", "Failed to create snapshot image")
  245. return None
  246. return snapshot