diff --git a/plugins/PCBWriter/PCBDialog.py b/plugins/3MFWriter/PCBDialog.py similarity index 100% rename from plugins/PCBWriter/PCBDialog.py rename to plugins/3MFWriter/PCBDialog.py diff --git a/plugins/PCBWriter/PCBDialog.qml b/plugins/3MFWriter/PCBDialog.qml similarity index 98% rename from plugins/PCBWriter/PCBDialog.qml rename to plugins/3MFWriter/PCBDialog.qml index 8264e3ee96..b65520961b 100644 --- a/plugins/PCBWriter/PCBDialog.qml +++ b/plugins/3MFWriter/PCBDialog.qml @@ -8,7 +8,6 @@ import QtQuick.Window 2.2 import UM 1.5 as UM import Cura 1.1 as Cura -import PCBWriter 1.0 as PCBWriter UM.Dialog { diff --git a/plugins/PCBWriter/SettingExport.py b/plugins/3MFWriter/SettingExport.py similarity index 100% rename from plugins/PCBWriter/SettingExport.py rename to plugins/3MFWriter/SettingExport.py diff --git a/plugins/PCBWriter/SettingSelection.qml b/plugins/3MFWriter/SettingSelection.qml similarity index 93% rename from plugins/PCBWriter/SettingSelection.qml rename to plugins/3MFWriter/SettingSelection.qml index 636b67fb37..478c2d393c 100644 --- a/plugins/PCBWriter/SettingSelection.qml +++ b/plugins/3MFWriter/SettingSelection.qml @@ -32,7 +32,7 @@ RowLayout UM.I18nCatalog { id: catalog; name: "cura" } text: catalog.i18nc("@tooltip", - "This setting can't be exported because it depends too much on the used printer capacities") + "This setting can't be exported because it depends on the used printer capacities") visible: !modelData.selectable } } diff --git a/plugins/PCBWriter/SettingsExportGroup.py b/plugins/3MFWriter/SettingsExportGroup.py similarity index 100% rename from plugins/PCBWriter/SettingsExportGroup.py rename to plugins/3MFWriter/SettingsExportGroup.py diff --git a/plugins/PCBWriter/SettingsExportModel.py b/plugins/3MFWriter/SettingsExportModel.py similarity index 100% rename from plugins/PCBWriter/SettingsExportModel.py rename to plugins/3MFWriter/SettingsExportModel.py diff --git a/plugins/PCBWriter/SettingsSelectionGroup.qml b/plugins/3MFWriter/SettingsSelectionGroup.qml similarity index 88% rename from plugins/PCBWriter/SettingsSelectionGroup.qml rename to plugins/3MFWriter/SettingsSelectionGroup.qml index 39299ab7c3..e77ba692bc 100644 --- a/plugins/PCBWriter/SettingsSelectionGroup.qml +++ b/plugins/3MFWriter/SettingsSelectionGroup.qml @@ -8,7 +8,7 @@ import QtQuick.Window 2.2 import UM 1.5 as UM import Cura 1.1 as Cura -import PCBWriter 1.0 as PCBWriter +import ThreeMFWriter 1.0 as ThreeMFWriter ColumnLayout { @@ -34,9 +34,9 @@ ColumnLayout { switch(modelData.category) { - case PCBWriter.SettingsExportGroup.Global: + case ThreeMFWriter.SettingsExportGroup.Global: return UM.Theme.getIcon("Sliders") - case PCBWriter.SettingsExportGroup.Model: + case ThreeMFWriter.SettingsExportGroup.Model: return UM.Theme.getIcon("View3D") default: return "" @@ -50,7 +50,7 @@ ColumnLayout { id: settingsExtruderIcon anchors.fill: parent - visible: modelData.category === PCBWriter.SettingsExportGroup.Extruder + visible: modelData.category === ThreeMFWriter.SettingsExportGroup.Extruder text: (modelData.extruder_index + 1).toString() font: UM.Theme.getFont("tiny_emphasis") materialColor: modelData.extruder_color diff --git a/plugins/3MFWriter/ThreeMFWorkspaceWriter.py b/plugins/3MFWriter/ThreeMFWorkspaceWriter.py index e89af5c70a..9715e9ac98 100644 --- a/plugins/3MFWriter/ThreeMFWorkspaceWriter.py +++ b/plugins/3MFWriter/ThreeMFWorkspaceWriter.py @@ -3,7 +3,9 @@ import configparser from io import StringIO +from threading import Lock import zipfile +from typing import Dict, Any from UM.Application import Application from UM.Logger import Logger @@ -13,15 +15,50 @@ from UM.Workspace.WorkspaceWriter import WorkspaceWriter from UM.i18n import i18nCatalog catalog = i18nCatalog("cura") -from cura.Utils.Threading import call_on_qt_thread +from .PCBDialog import PCBDialog +from .ThreeMFWriter import ThreeMFWriter +from .SettingsExportModel import SettingsExportModel +from .SettingsExportGroup import SettingsExportGroup + +USER_SETTINGS_PATH = "Cura/user-settings.json" class ThreeMFWorkspaceWriter(WorkspaceWriter): def __init__(self): super().__init__() + self._main_thread_lock = Lock() + self._success = False + self._export_model = None + self._stream = None + self._nodes = None + self._mode = None + self._config_dialog = None - @call_on_qt_thread - def write(self, stream, nodes, mode=WorkspaceWriter.OutputMode.BinaryMode): + def _preWrite(self): + is_pcb = False + if hasattr(self._stream, 'name'): + # This only works with local file, but we don't want remote PCB files yet + is_pcb = self._stream.name.endswith('.pcb') + + if is_pcb: + self._config_dialog = PCBDialog() + self._config_dialog.finished.connect(self._onPCBConfigFinished) + self._config_dialog.show() + else: + self._doWrite() + + def _onPCBConfigFinished(self, accepted: bool): + if accepted: + self._export_model = self._config_dialog.getModel() + self._doWrite() + else: + self._main_thread_lock.release() + + def _doWrite(self): + self._write() + self._main_thread_lock.release() + + def _write(self): application = Application.getInstance() machine_manager = application.getMachineManager() @@ -30,24 +67,24 @@ class ThreeMFWorkspaceWriter(WorkspaceWriter): if not mesh_writer: # We need to have the 3mf mesh writer, otherwise we can't save the entire workspace self.setInformation(catalog.i18nc("@error:zip", "3MF Writer plug-in is corrupt.")) Logger.error("3MF Writer class is unavailable. Can't write workspace.") - return False + return global_stack = machine_manager.activeMachine if global_stack is None: - self.setInformation(catalog.i18nc("@error", "There is no workspace yet to write. Please add a printer first.")) + self.setInformation( + catalog.i18nc("@error", "There is no workspace yet to write. Please add a printer first.")) Logger.error("Tried to write a 3MF workspace before there was a global stack.") - return False + return # Indicate that the 3mf mesh writer should not close the archive just yet (we still need to add stuff to it). mesh_writer.setStoreArchive(True) - if not mesh_writer.write(stream, nodes, mode): + if not mesh_writer.write(self._stream, self._nodes, self._mode, self._export_model): self.setInformation(mesh_writer.getInformation()) - return False + return archive = mesh_writer.getArchive() if archive is None: # This happens if there was no mesh data to write. - archive = zipfile.ZipFile(stream, "w", compression = zipfile.ZIP_DEFLATED) - + archive = zipfile.ZipFile(self._stream, "w", compression=zipfile.ZIP_DEFLATED) try: # Add global container stack data to the archive. @@ -62,15 +99,21 @@ class ThreeMFWorkspaceWriter(WorkspaceWriter): self._writeContainerToArchive(extruder_stack, archive) for container in extruder_stack.getContainers(): self._writeContainerToArchive(container, archive) + + # Write user settings data + if self._export_model is not None: + user_settings_data = self._getUserSettings(self._export_model) + ThreeMFWriter._storeMetadataJson(user_settings_data, archive, USER_SETTINGS_PATH) except PermissionError: self.setInformation(catalog.i18nc("@error:zip", "No permission to write the workspace here.")) Logger.error("No permission to write workspace to this stream.") - return False + return # Write preferences to archive - original_preferences = Application.getInstance().getPreferences() #Copy only the preferences that we use to the workspace. + original_preferences = Application.getInstance().getPreferences() # Copy only the preferences that we use to the workspace. temp_preferences = Preferences() - for preference in {"general/visible_settings", "cura/active_mode", "cura/categories_expanded", "metadata/setting_version"}: + for preference in {"general/visible_settings", "cura/active_mode", "cura/categories_expanded", + "metadata/setting_version"}: temp_preferences.addPreference(preference, None) temp_preferences.setValue(preference, original_preferences.getValue(preference)) preferences_string = StringIO() @@ -81,7 +124,7 @@ class ThreeMFWorkspaceWriter(WorkspaceWriter): # Save Cura version version_file = zipfile.ZipInfo("Cura/version.ini") - version_config_parser = configparser.ConfigParser(interpolation = None) + version_config_parser = configparser.ConfigParser(interpolation=None) version_config_parser.add_section("versions") version_config_parser.set("versions", "cura_version", application.getVersion()) version_config_parser.set("versions", "build_type", application.getBuildType()) @@ -98,13 +141,37 @@ class ThreeMFWorkspaceWriter(WorkspaceWriter): except PermissionError: self.setInformation(catalog.i18nc("@error:zip", "No permission to write the workspace here.")) Logger.error("No permission to write workspace to this stream.") - return False + return except EnvironmentError as e: self.setInformation(catalog.i18nc("@error:zip", str(e))) - Logger.error("EnvironmentError when writing workspace to this stream: {err}".format(err = str(e))) - return False + Logger.error("EnvironmentError when writing workspace to this stream: {err}".format(err=str(e))) + return mesh_writer.setStoreArchive(False) - return True + + self._success = True + + def write(self, stream, nodes, mode=WorkspaceWriter.OutputMode.BinaryMode): + self._success = False + self._export_model = None + self._stream = stream + self._nodes = nodes + self._mode = mode + self._config_dialog = None + + self._main_thread_lock.acquire() + # Export is done in main thread because it may require a few asynchronous configuration steps + Application.getInstance().callLater(self._preWrite) + self._main_thread_lock.acquire() # Block until lock has been released, meaning the config+write is over + + self._main_thread_lock.release() + + self._export_model = None + self._stream = None + self._nodes = None + self._mode = None + self._config_dialog = None + + return self._success @staticmethod def _writePluginMetadataToArchive(archive: zipfile.ZipFile) -> None: @@ -165,4 +232,27 @@ class ThreeMFWorkspaceWriter(WorkspaceWriter): archive.writestr(file_in_archive, serialized_data) except (FileNotFoundError, EnvironmentError): Logger.error("File became inaccessible while writing to it: {archive_filename}".format(archive_filename = archive.fp.name)) - return \ No newline at end of file + return + + @staticmethod + def _getUserSettings(model: SettingsExportModel) -> Dict[str, Dict[str, Any]]: + user_settings = {} + + for group in model.settingsGroups: + category = '' + if group.category == SettingsExportGroup.Category.Global: + category = 'global' + elif group.category == SettingsExportGroup.Category.Extruder: + category = f"extruder_{group.extruder_index}" + + if len(category) > 0: + settings_values = {} + stack = group.stack + + for setting in group.settings: + if setting.selected: + settings_values[setting.id] = stack.getProperty(setting.id, "value") + + user_settings[category] = settings_values + + return user_settings \ No newline at end of file diff --git a/plugins/3MFWriter/ThreeMFWriter.py b/plugins/3MFWriter/ThreeMFWriter.py index ad4b0d8dad..8924ac0a61 100644 --- a/plugins/3MFWriter/ThreeMFWriter.py +++ b/plugins/3MFWriter/ThreeMFWriter.py @@ -40,6 +40,9 @@ except ImportError: import zipfile import UM.Application +from .SettingsExportModel import SettingsExportModel +from .SettingsExportGroup import SettingsExportGroup + from UM.i18n import i18nCatalog catalog = i18nCatalog("cura") @@ -87,7 +90,9 @@ class ThreeMFWriter(MeshWriter): self._store_archive = store_archive @staticmethod - def _convertUMNodeToSavitarNode(um_node, transformation=Matrix()): + def _convertUMNodeToSavitarNode(um_node, + transformation = Matrix(), + exported_settings: Optional[Dict[str, Set[str]]] = None): """Convenience function that converts an Uranium SceneNode object to a SavitarSceneNode :returns: Uranium Scene node. @@ -129,13 +134,22 @@ class ThreeMFWriter(MeshWriter): if stack is not None: changed_setting_keys = stack.getTop().getAllKeys() - # Ensure that we save the extruder used for this object in a multi-extrusion setup - if stack.getProperty("machine_extruder_count", "value") > 1: - changed_setting_keys.add("extruder_nr") + if exported_settings is None: + # Ensure that we save the extruder used for this object in a multi-extrusion setup + if stack.getProperty("machine_extruder_count", "value") > 1: + changed_setting_keys.add("extruder_nr") - # Get values for all changed settings & save them. - for key in changed_setting_keys: - savitar_node.setSetting("cura:" + key, str(stack.getProperty(key, "value"))) + # Get values for all changed settings & save them. + for key in changed_setting_keys: + savitar_node.setSetting("cura:" + key, str(stack.getProperty(key, "value"))) + else: + # We want to export only the specified settings + if um_node.getName() in exported_settings: + model_exported_settings = exported_settings[um_node.getName()] + + # Get values for all exported settings & save them. + for key in model_exported_settings: + savitar_node.setSetting("cura:" + key, str(stack.getProperty(key, "value"))) # Store the metadata. for key, value in um_node.metadata.items(): @@ -145,7 +159,8 @@ class ThreeMFWriter(MeshWriter): # only save the nodes on the active build plate if child_node.callDecoration("getBuildPlateNumber") != active_build_plate_nr: continue - savitar_child_node = ThreeMFWriter._convertUMNodeToSavitarNode(child_node) + savitar_child_node = ThreeMFWriter._convertUMNodeToSavitarNode(child_node, + exported_settings = exported_settings) if savitar_child_node is not None: savitar_node.addChild(savitar_child_node) @@ -154,7 +169,7 @@ class ThreeMFWriter(MeshWriter): def getArchive(self): return self._archive - def write(self, stream, nodes, mode = MeshWriter.OutputMode.BinaryMode) -> bool: + def write(self, stream, nodes, mode = MeshWriter.OutputMode.BinaryMode, export_settings_model = None) -> bool: self._archive = None # Reset archive archive = zipfile.ZipFile(stream, "w", compression = zipfile.ZIP_DEFLATED) try: @@ -232,14 +247,19 @@ class ThreeMFWriter(MeshWriter): transformation_matrix.preMultiply(translation_matrix) root_node = UM.Application.Application.getInstance().getController().getScene().getRoot() + exported_model_settings = ThreeMFWriter._extractModelExportedSettings(export_settings_model) for node in nodes: if node == root_node: for root_child in node.getChildren(): - savitar_node = ThreeMFWriter._convertUMNodeToSavitarNode(root_child, transformation_matrix) + savitar_node = ThreeMFWriter._convertUMNodeToSavitarNode(root_child, + transformation_matrix, + exported_model_settings) if savitar_node: savitar_scene.addSceneNode(savitar_node) else: - savitar_node = self._convertUMNodeToSavitarNode(node, transformation_matrix) + savitar_node = self._convertUMNodeToSavitarNode(node, + transformation_matrix, + exported_model_settings) if savitar_node: savitar_scene.addSceneNode(savitar_node) @@ -395,3 +415,20 @@ class ThreeMFWriter(MeshWriter): parser = Savitar.ThreeMFParser() scene_string = parser.sceneToString(savitar_scene) return scene_string + + @staticmethod + def _extractModelExportedSettings(model: Optional[SettingsExportModel]) -> Dict[str, Set[str]]: + extra_settings = {} + + if model is not None: + for group in model.settingsGroups: + if group.category == SettingsExportGroup.Category.Model: + exported_model_settings = set() + + for exported_setting in group.settings: + if exported_setting.selected: + exported_model_settings.add(exported_setting.id) + + extra_settings[group.category_details] = exported_model_settings + + return extra_settings diff --git a/plugins/3MFWriter/__init__.py b/plugins/3MFWriter/__init__.py index eb8a596afe..e0d4037603 100644 --- a/plugins/3MFWriter/__init__.py +++ b/plugins/3MFWriter/__init__.py @@ -2,9 +2,12 @@ # Uranium is released under the terms of the LGPLv3 or higher. import sys +from PyQt6.QtQml import qmlRegisterType + from UM.Logger import Logger try: from . import ThreeMFWriter + from .SettingsExportGroup import SettingsExportGroup threemf_writer_was_imported = True except ImportError: Logger.log("w", "Could not import ThreeMFWriter; libSavitar may be missing") @@ -23,20 +26,36 @@ def getMetaData(): if threemf_writer_was_imported: metaData["mesh_writer"] = { - "output": [{ - "extension": "3mf", - "description": i18n_catalog.i18nc("@item:inlistbox", "3MF file"), - "mime_type": "application/vnd.ms-package.3dmanufacturing-3dmodel+xml", - "mode": ThreeMFWriter.ThreeMFWriter.OutputMode.BinaryMode - }] + "output": [ + { + "extension": "3mf", + "description": i18n_catalog.i18nc("@item:inlistbox", "3MF file"), + "mime_type": "application/vnd.ms-package.3dmanufacturing-3dmodel+xml", + "mode": ThreeMFWriter.ThreeMFWriter.OutputMode.BinaryMode + }, + { + "extension": "pcb", + "description": i18n_catalog.i18nc("@item:inlistbox", "PCB file"), + "mime_type": "application/x-pcb", + "mode": ThreeMFWriter.ThreeMFWriter.OutputMode.BinaryMode + } + ] } metaData["workspace_writer"] = { - "output": [{ - "extension": workspace_extension, - "description": i18n_catalog.i18nc("@item:inlistbox", "Cura Project 3MF file"), - "mime_type": "application/vnd.ms-package.3dmanufacturing-3dmodel+xml", - "mode": ThreeMFWorkspaceWriter.ThreeMFWorkspaceWriter.OutputMode.BinaryMode - }] + "output": [ + { + "extension": workspace_extension, + "description": i18n_catalog.i18nc("@item:inlistbox", "Cura Project 3MF file"), + "mime_type": "application/vnd.ms-package.3dmanufacturing-3dmodel+xml", + "mode": ThreeMFWorkspaceWriter.ThreeMFWorkspaceWriter.OutputMode.BinaryMode + }, + { + "extension": "pcb", + "description": i18n_catalog.i18nc("@item:inlistbox", "Pre-Configured Batch file"), + "mime_type": "application/x-pcb", + "mode": ThreeMFWorkspaceWriter.ThreeMFWorkspaceWriter.OutputMode.BinaryMode + } + ] } return metaData @@ -44,6 +63,8 @@ def getMetaData(): def register(app): if "3MFWriter.ThreeMFWriter" in sys.modules: + qmlRegisterType(SettingsExportGroup, "ThreeMFWriter", 1, 0, "SettingsExportGroup") + return {"mesh_writer": ThreeMFWriter.ThreeMFWriter(), "workspace_writer": ThreeMFWorkspaceWriter.ThreeMFWorkspaceWriter()} else: diff --git a/plugins/PCBWriter/PCBWriter.py b/plugins/PCBWriter/PCBWriter.py deleted file mode 100644 index 794eac9d4a..0000000000 --- a/plugins/PCBWriter/PCBWriter.py +++ /dev/null @@ -1,460 +0,0 @@ -# Copyright (c) 2024 Ultimaker B.V. -# Cura is released under the terms of the LGPLv3 or higher. -import json -import zipfile -import datetime -import numpy -import re -from dataclasses import asdict -from typing import Optional, cast, List, Dict, Pattern, Set, Union, Mapping, Any -from threading import Lock -from io import StringIO # For converting g-code to bytes. - -import pySavitar as Savitar - -from PyQt6.QtCore import QBuffer - -from UM.Mesh.MeshWriter import MeshWriter -from UM.Logger import Logger -from UM.Scene.SceneNode import SceneNode -from UM.Scene.Iterator.DepthFirstIterator import DepthFirstIterator -from UM.i18n import i18nCatalog -from UM.Settings.InstanceContainer import InstanceContainer -from UM.Settings.SettingFunction import SettingFunction -from UM.Settings.ContainerRegistry import ContainerRegistry -from UM.Math.Matrix import Matrix -from UM.Math.Vector import Vector - -from cura.CuraApplication import CuraApplication -from cura.CuraPackageManager import CuraPackageManager -from cura.Settings import CuraContainerStack -from cura.Settings.GlobalStack import GlobalStack -from cura.Utils.Threading import call_on_qt_thread -from cura.Snapshot import Snapshot - -from .PCBDialog import PCBDialog -from .SettingsExportModel import SettingsExportModel -from .SettingsExportGroup import SettingsExportGroup - -MYPY = False -try: - if not MYPY: - import xml.etree.cElementTree as ET -except ImportError: - Logger.log("w", "Unable to load cElementTree, switching to slower version") - import xml.etree.ElementTree as ET - -catalog = i18nCatalog("cura") - -THUMBNAIL_PATH = "Metadata/thumbnail.png" -MODEL_PATH = "3D/3dmodel.model" -PACKAGE_METADATA_PATH = "Cura/packages.json" -USER_SETTINGS_PATH = "Cura/user-settings.json" - -class PCBWriter(MeshWriter): - def __init__(self): - super().__init__() - self._namespaces = { - "3mf": "http://schemas.microsoft.com/3dmanufacturing/core/2015/02", - "content-types": "http://schemas.openxmlformats.org/package/2006/content-types", - "relationships": "http://schemas.openxmlformats.org/package/2006/relationships", - "cura": "http://software.ultimaker.com/xml/cura/3mf/2015/10" - } - - self._config_dialog = None - self._main_thread_lock = Lock() - self._success = False - self._export_model = None - - def write(self, stream, nodes, mode = MeshWriter.OutputMode.BinaryMode) -> bool: - self._success = False - self._export_model = None - - self._main_thread_lock.acquire() - # Start configuration window in main application thread - CuraApplication.getInstance().callLater(self._write, stream, nodes, mode) - self._main_thread_lock.acquire() # Block until lock has been released, meaning the config is over - - self._main_thread_lock.release() - - if self._export_model is not None: - archive = zipfile.ZipFile(stream, "w", compression=zipfile.ZIP_DEFLATED) - try: - model_file = zipfile.ZipInfo(MODEL_PATH) - # Because zipfile is stupid and ignores archive-level compression settings when writing with ZipInfo. - model_file.compress_type = zipfile.ZIP_DEFLATED - - # Create content types file - content_types_file = zipfile.ZipInfo("[Content_Types].xml") - content_types_file.compress_type = zipfile.ZIP_DEFLATED - content_types = ET.Element("Types", xmlns=self._namespaces["content-types"]) - rels_type = ET.SubElement(content_types, "Default", Extension="rels", - ContentType="application/vnd.openxmlformats-package.relationships+xml") - model_type = ET.SubElement(content_types, "Default", Extension="model", - ContentType="application/vnd.ms-package.3dmanufacturing-3dmodel+xml") - - # Create _rels/.rels file - relations_file = zipfile.ZipInfo("_rels/.rels") - relations_file.compress_type = zipfile.ZIP_DEFLATED - relations_element = ET.Element("Relationships", xmlns=self._namespaces["relationships"]) - model_relation_element = ET.SubElement(relations_element, "Relationship", Target="/" + MODEL_PATH, - Id="rel0", - Type="http://schemas.microsoft.com/3dmanufacturing/2013/01/3dmodel") - - # Attempt to add a thumbnail - snapshot = self._createSnapshot() - if snapshot: - thumbnail_buffer = QBuffer() - thumbnail_buffer.open(QBuffer.OpenModeFlag.ReadWrite) - snapshot.save(thumbnail_buffer, "PNG") - - thumbnail_file = zipfile.ZipInfo(THUMBNAIL_PATH) - # Don't try to compress snapshot file, because the PNG is pretty much as compact as it will get - archive.writestr(thumbnail_file, thumbnail_buffer.data()) - - # Add PNG to content types file - thumbnail_type = ET.SubElement(content_types, "Default", Extension="png", ContentType="image/png") - # Add thumbnail relation to _rels/.rels file - thumbnail_relation_element = ET.SubElement(relations_element, "Relationship", - Target="/" + THUMBNAIL_PATH, Id="rel1", - Type="http://schemas.openxmlformats.org/package/2006/relationships/metadata/thumbnail") - - # Write material metadata - packages_metadata = self._getMaterialPackageMetadata() + self._getPluginPackageMetadata() - self._storeMetadataJson({"packages": packages_metadata}, archive, PACKAGE_METADATA_PATH) - - # Write user settings data - user_settings_data = self._getUserSettings(self._export_model) - self._storeMetadataJson(user_settings_data, archive, USER_SETTINGS_PATH) - - savitar_scene = Savitar.Scene() - - scene_metadata = CuraApplication.getInstance().getController().getScene().getMetaData() - - for key, value in scene_metadata.items(): - savitar_scene.setMetaDataEntry(key, value) - - current_time_string = datetime.datetime.now().strftime("%Y-%m-%d %H:%M:%S") - if "Application" not in scene_metadata: - # This might sound a bit strange, but this field should store the original application that created - # the 3mf. So if it was already set, leave it to whatever it was. - savitar_scene.setMetaDataEntry("Application", - CuraApplication.getInstance().getApplicationDisplayName()) - if "CreationDate" not in scene_metadata: - savitar_scene.setMetaDataEntry("CreationDate", current_time_string) - - savitar_scene.setMetaDataEntry("ModificationDate", current_time_string) - - transformation_matrix = Matrix() - transformation_matrix._data[1, 1] = 0 - transformation_matrix._data[1, 2] = -1 - transformation_matrix._data[2, 1] = 1 - transformation_matrix._data[2, 2] = 0 - - global_container_stack = CuraApplication.getInstance().getGlobalContainerStack() - # Second step: 3MF defines the left corner of the machine as center, whereas cura uses the center of the - # build volume. - if global_container_stack: - translation_vector = Vector(x=global_container_stack.getProperty("machine_width", "value") / 2, - y=global_container_stack.getProperty("machine_depth", "value") / 2, - z=0) - translation_matrix = Matrix() - translation_matrix.setByTranslation(translation_vector) - transformation_matrix.preMultiply(translation_matrix) - - root_node = CuraApplication.getInstance().getController().getScene().getRoot() - exported_model_settings = PCBWriter._extractModelExportedSettings(self._export_model) - for node in nodes: - if node == root_node: - for root_child in node.getChildren(): - savitar_node = PCBWriter._convertUMNodeToSavitarNode(root_child, - transformation_matrix, - exported_model_settings) - if savitar_node: - savitar_scene.addSceneNode(savitar_node) - else: - savitar_node = self._convertUMNodeToSavitarNode(node, - transformation_matrix, - exported_model_settings) - if savitar_node: - savitar_scene.addSceneNode(savitar_node) - - parser = Savitar.ThreeMFParser() - scene_string = parser.sceneToString(savitar_scene) - - archive.writestr(model_file, scene_string) - archive.writestr(content_types_file, - b' \n' + ET.tostring(content_types)) - archive.writestr(relations_file, - b' \n' + ET.tostring(relations_element)) - except Exception as error: - Logger.logException("e", "Error writing zip file") - self.setInformation(str(error)) - return False - finally: - archive.close() - - return True - else: - return False - - def _write(self, stream, nodes, mode): - self._config_dialog = PCBDialog() - self._config_dialog.finished.connect(self._onDialogFinished) - self._config_dialog.show() - - def _onDialogFinished(self, accepted: bool): - if accepted: - self._export_model = self._config_dialog.getModel() - - self._main_thread_lock.release() - - @staticmethod - def _extractModelExportedSettings(model: SettingsExportModel) -> Mapping[str, Set[str]]: - extra_settings = {} - - for group in model.settingsGroups: - if group.category == SettingsExportGroup.Category.Model: - exported_model_settings = set() - - for exported_setting in group.settings: - if exported_setting.selected: - exported_model_settings.add(exported_setting.id) - - extra_settings[group.category_details] = exported_model_settings - - return extra_settings - - @staticmethod - def _convertUMNodeToSavitarNode(um_node, - transformation: Matrix = Matrix(), - exported_settings: Mapping[str, Set[str]] = None): - """Convenience function that converts an Uranium SceneNode object to a SavitarSceneNode - - :returns: Uranium Scene node. - """ - if not isinstance(um_node, SceneNode): - return None - - active_build_plate_nr = CuraApplication.getInstance().getMultiBuildPlateModel().activeBuildPlate - if um_node.callDecoration("getBuildPlateNumber") != active_build_plate_nr: - return - - savitar_node = Savitar.SceneNode() - savitar_node.setName(um_node.getName()) - - node_matrix = Matrix() - mesh_data = um_node.getMeshData() - # compensate for original center position, if object(s) is/are not around its zero position - if mesh_data is not None: - extents = mesh_data.getExtents() - if extents is not None: - # We use a different coordinate space while writing, so flip Z and Y - center_vector = Vector(extents.center.x, extents.center.z, extents.center.y) - node_matrix.setByTranslation(center_vector) - node_matrix.multiply(um_node.getLocalTransformation()) - - matrix_string = PCBWriter._convertMatrixToString(node_matrix.preMultiply(transformation)) - - savitar_node.setTransformation(matrix_string) - if mesh_data is not None: - savitar_node.getMeshData().setVerticesFromBytes(mesh_data.getVerticesAsByteArray()) - indices_array = mesh_data.getIndicesAsByteArray() - if indices_array is not None: - savitar_node.getMeshData().setFacesFromBytes(indices_array) - else: - savitar_node.getMeshData().setFacesFromBytes( - numpy.arange(mesh_data.getVertices().size / 3, dtype=numpy.int32).tostring()) - - # Handle per object settings (if any) - stack = um_node.callDecoration("getStack") - if stack is not None: - if um_node.getName() in exported_settings: - model_exported_settings = exported_settings[um_node.getName()] - - # Get values for all exported settings & save them. - for key in model_exported_settings: - savitar_node.setSetting("cura:" + key, str(stack.getProperty(key, "value"))) - - # Store the metadata. - for key, value in um_node.metadata.items(): - savitar_node.setSetting(key, value) - - for child_node in um_node.getChildren(): - # only save the nodes on the active build plate - if child_node.callDecoration("getBuildPlateNumber") != active_build_plate_nr: - continue - savitar_child_node = PCBWriter._convertUMNodeToSavitarNode(child_node, - exported_settings = exported_settings) - if savitar_child_node is not None: - savitar_node.addChild(savitar_child_node) - - return savitar_node - - @call_on_qt_thread # must be called from the main thread because of OpenGL - def _createSnapshot(self): - Logger.log("d", "Creating thumbnail image...") - if not CuraApplication.getInstance().isVisible: - Logger.log("w", "Can't create snapshot when renderer not initialized.") - return None - try: - snapshot = Snapshot.snapshot(width=300, height=300) - except: - Logger.logException("w", "Failed to create snapshot image") - return None - - return snapshot - - @staticmethod - def _storeMetadataJson(metadata: Union[Dict[str, List[Dict[str, str]]], Dict[str, Dict[str, Any]]], - archive: zipfile.ZipFile, path - : str) -> None: - """Stores metadata inside archive path as json file""" - metadata_file = zipfile.ZipInfo(path) - # We have to set the compress type of each file as well (it doesn't keep the type of the entire archive) - metadata_file.compress_type = zipfile.ZIP_DEFLATED - archive.writestr(metadata_file, - json.dumps(metadata, separators=(", ", ": "), indent=4, skipkeys=True, ensure_ascii=False)) - - @staticmethod - def _getUserSettings(model: SettingsExportModel) -> Dict[str, Dict[str, Any]]: - user_settings = {} - - for group in model.settingsGroups: - category = '' - if group.category == SettingsExportGroup.Category.Global: - category = 'global' - elif group.category == SettingsExportGroup.Category.Extruder: - category = f"extruder_{group.extruder_index}" - - if len(category) > 0: - settings_values = {} - stack = group.stack - - for setting in group.settings: - if setting.selected: - settings_values[setting.id] = stack.getProperty(setting.id, "value") - - user_settings[category] = settings_values - - return user_settings - - @staticmethod - def _getPluginPackageMetadata() -> List[Dict[str, str]]: - """Get metadata for all backend plugins that are used in the project. - - :return: List of material metadata dictionaries. - """ - - backend_plugin_enum_value_regex = re.compile( - r"PLUGIN::(?P\w+)@(?P\d+.\d+.\d+)::(?P\w+)") - # This regex parses enum values to find if they contain custom - # backend engine values. These custom enum values are in the format - # PLUGIN::@:: - # where - # - plugin_id is the id of the plugin - # - version is in the semver format - # - value is the value of the enum - - plugin_ids = set() - - def addPluginIdsInStack(stack: CuraContainerStack) -> None: - for key in stack.getAllKeys(): - value = str(stack.getProperty(key, "value")) - for plugin_id, _version, _value in backend_plugin_enum_value_regex.findall(value): - plugin_ids.add(plugin_id) - - # Go through all stacks and find all the plugin id contained in the project - global_stack = CuraApplication.getInstance().getMachineManager().activeMachine - addPluginIdsInStack(global_stack) - - for container in global_stack.getContainers(): - addPluginIdsInStack(container) - - for extruder_stack in global_stack.extruderList: - addPluginIdsInStack(extruder_stack) - - for container in extruder_stack.getContainers(): - addPluginIdsInStack(container) - - metadata = {} - - package_manager = cast(CuraPackageManager, CuraApplication.getInstance().getPackageManager()) - for plugin_id in plugin_ids: - package_data = package_manager.getInstalledPackageInfo(plugin_id) - - metadata[plugin_id] = { - "id": plugin_id, - "display_name": package_data.get("display_name") if package_data.get("display_name") else "", - "package_version": package_data.get("package_version") if package_data.get("package_version") else "", - "sdk_version_semver": package_data.get("sdk_version_semver") if package_data.get( - "sdk_version_semver") else "", - "type": "plugin", - } - - # Storing in a dict and fetching values to avoid duplicates - return list(metadata.values()) - - @staticmethod - def _getMaterialPackageMetadata() -> List[Dict[str, str]]: - """Get metadata for installed materials in active extruder stack, this does not include bundled materials. - - :return: List of material metadata dictionaries. - """ - metadata = {} - - package_manager = cast(CuraPackageManager, CuraApplication.getInstance().getPackageManager()) - - for extruder in CuraApplication.getInstance().getExtruderManager().getActiveExtruderStacks(): - if not extruder.isEnabled: - # Don't export materials not in use - continue - - if isinstance(extruder.material, type(ContainerRegistry.getInstance().getEmptyInstanceContainer())): - # This is an empty material container, no material to export - continue - - if package_manager.isMaterialBundled(extruder.material.getFileName(), - extruder.material.getMetaDataEntry("GUID")): - # Don't export bundled materials - continue - - package_id = package_manager.getMaterialFilePackageId(extruder.material.getFileName(), - extruder.material.getMetaDataEntry("GUID")) - package_data = package_manager.getInstalledPackageInfo(package_id) - - # We failed to find the package for this material - if not package_data: - Logger.info(f"Could not find package for material in extruder {extruder.id}, skipping.") - continue - - material_metadata = { - "id": package_id, - "display_name": package_data.get("display_name") if package_data.get("display_name") else "", - "package_version": package_data.get("package_version") if package_data.get("package_version") else "", - "sdk_version_semver": package_data.get("sdk_version_semver") if package_data.get( - "sdk_version_semver") else "", - "type": "material", - } - - metadata[package_id] = material_metadata - - # Storing in a dict and fetching values to avoid duplicates - return list(metadata.values()) - - @staticmethod - def _convertMatrixToString(matrix): - result = "" - result += str(matrix._data[0, 0]) + " " - result += str(matrix._data[1, 0]) + " " - result += str(matrix._data[2, 0]) + " " - result += str(matrix._data[0, 1]) + " " - result += str(matrix._data[1, 1]) + " " - result += str(matrix._data[2, 1]) + " " - result += str(matrix._data[0, 2]) + " " - result += str(matrix._data[1, 2]) + " " - result += str(matrix._data[2, 2]) + " " - result += str(matrix._data[0, 3]) + " " - result += str(matrix._data[1, 3]) + " " - result += str(matrix._data[2, 3]) - return result \ No newline at end of file diff --git a/plugins/PCBWriter/__init__.py b/plugins/PCBWriter/__init__.py deleted file mode 100644 index da4205a7d7..0000000000 --- a/plugins/PCBWriter/__init__.py +++ /dev/null @@ -1,29 +0,0 @@ -# Copyright (c) 2024 Ultimaker B.V. -# Cura is released under the terms of the LGPLv3 or higher. -import sys - -from PyQt6.QtQml import qmlRegisterType - -from UM.i18n import i18nCatalog - -from . import PCBWriter -from .SettingsExportModel import SettingsExportModel -from .SettingsExportGroup import SettingsExportGroup - -i18n_catalog = i18nCatalog("cura") - -def getMetaData(): - return {"mesh_writer": { - "output": [{ - "extension": "pcb", - "description": i18n_catalog.i18nc("@item:inlistbox", "Pre-Configured Batch file"), - "mime_type": "application/x-pcb", - "mode": PCBWriter.PCBWriter.OutputMode.BinaryMode - }] - }} - -def register(app): - qmlRegisterType(SettingsExportModel, "PCBWriter", 1, 0, "SettingsExportModel") - qmlRegisterType(SettingsExportGroup, "PCBWriter", 1, 0, "SettingsExportGroup") - - return {"mesh_writer": PCBWriter.PCBWriter() } diff --git a/plugins/PCBWriter/plugin.json b/plugins/PCBWriter/plugin.json deleted file mode 100644 index 6571185779..0000000000 --- a/plugins/PCBWriter/plugin.json +++ /dev/null @@ -1,8 +0,0 @@ -{ - "name": "Pre-Configured Batch Writer", - "author": "Ultimaker B.V.", - "version": "1.0.0", - "description": "Provides support for writing Pre-Configured Batch files.", - "api": 8, - "i18n-catalog": "cura" -} diff --git a/resources/qml/Menus/FileMenu.qml b/resources/qml/Menus/FileMenu.qml index 254c0d5468..850c0d7e73 100644 --- a/resources/qml/Menus/FileMenu.qml +++ b/resources/qml/Menus/FileMenu.qml @@ -70,6 +70,18 @@ Cura.Menu enabled: UM.WorkspaceFileHandler.enabled } + Cura.MenuItem + { + id: savePCBMenu + text: catalog.i18nc("@title:menu menubar:file", "&Save PCB Project...") + enabled: UM.WorkspaceFileHandler.enabled + onTriggered: + { + var args = { "filter_by_machine": false, "file_type": "workspace", "preferred_mimetypes": "application/x-pcb" }; + UM.OutputDeviceManager.requestWriteToDevice("local_file", PrintInformation.jobName, args) + } + } + Cura.MenuSeparator { } UM.MeshWritersModel { id: meshWritersModel }