ThreeMFWriter.py 17 KB

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