Browse Source

It is now possible to generate the PCB file

CURA-11561
Erwan MATHIEU 1 year ago
parent
commit
fcf1e63160

+ 32 - 19
plugins/PCBWriter/PCBDialog.py

@@ -1,43 +1,56 @@
 # Copyright (c) 2024 Ultimaker B.V.
 # Cura is released under the terms of the LGPLv3 or higher.
 
-from PyQt6.QtCore import pyqtSignal, QObject, pyqtProperty, QCoreApplication, QUrl, pyqtSlot
-from PyQt6.QtGui import QDesktopServices
-from typing import List, Optional, Dict, cast
-
-from cura.Machines.Models.MachineListModel import MachineListModel
-from cura.Machines.Models.IntentTranslations import intent_translations
-from cura.Settings.GlobalStack import GlobalStack
-from UM.Application import Application
+import os
+
+from PyQt6.QtCore import pyqtSignal, QObject
 from UM.FlameProfiler import pyqtSlot
 from UM.i18n import i18nCatalog
-from UM.Logger import Logger
-from UM.Message import Message
-from UM.PluginRegistry import PluginRegistry
-from UM.Settings.ContainerRegistry import ContainerRegistry
-
-import os
-import threading
-import time
 
 from cura.CuraApplication import CuraApplication
 
+from .SettingsExportModel import SettingsExportModel
+
 i18n_catalog = i18nCatalog("cura")
 
 
 class PCBDialog(QObject):
-    finished = pyqtSignal()
+    finished = pyqtSignal(bool)
 
     def __init__(self, parent = None) -> None:
         super().__init__(parent)
 
         plugin_path = os.path.dirname(__file__)
         dialog_path = os.path.join(plugin_path, 'PCBDialog.qml')
-        self._view = CuraApplication.getInstance().createQmlComponent(dialog_path, {"manager": self})
+        self._model = SettingsExportModel()
+        self._view = CuraApplication.getInstance().createQmlComponent(dialog_path,
+                                                                      {"manager": self,
+                                                                       "settingsExportModel": self._model})
+        self._view.accepted.connect(self._onAccepted)
+        self._view.rejected.connect(self._onRejected)
+        self._finished = False
+        self._accepted = False
 
     def show(self) -> None:
         self._view.show()
 
+    def getModel(self) -> SettingsExportModel:
+        return self._model
+
     @pyqtSlot()
     def notifyClosed(self):
-        self.finished.emit()
+        self._onFinished()
+
+    @pyqtSlot()
+    def _onAccepted(self):
+        self._accepted = True
+        self._onFinished()
+
+    @pyqtSlot()
+    def _onRejected(self):
+        self._onFinished()
+
+    def _onFinished(self):
+        if not self._finished: # Make sure we don't send the finished signal twice, whatever happens
+            self._finished = True
+            self.finished.emit(self._accepted)

+ 13 - 51
plugins/PCBWriter/PCBDialog.qml

@@ -12,7 +12,7 @@ import PCBWriter 1.0 as PCBWriter
 
 UM.Dialog
 {
-    id: workspaceDialog
+    id: exportDialog
     title: catalog.i18nc("@title:window", "Export pre-configured build batch")
 
     margin: UM.Theme.getSize("default_margin").width
@@ -23,8 +23,6 @@ UM.Dialog
 
     headerComponent: Rectangle
     {
-        UM.I18nCatalog { id: catalog; name: "cura" }
-
         height: childrenRect.height + 2 * UM.Theme.getSize("default_margin").height
         color: UM.Theme.getColor("main_background")
 
@@ -62,7 +60,7 @@ UM.Dialog
         anchors.fill: parent
         color: UM.Theme.getColor("main_background")
 
-        PCBWriter.SettingsExportModel{ id: settingsExportModel }
+        UM.I18nCatalog { id: catalog; name: "cura" }
 
         ListView
         {
@@ -79,55 +77,19 @@ UM.Dialog
         }
     }
 
-    footerComponent: Rectangle
-    {
-        color: warning ? UM.Theme.getColor("warning") : "transparent"
-        anchors.bottom: parent.bottom
-        width: parent.width
-        height: childrenRect.height + (warning ? 2 * workspaceDialog.margin : workspaceDialog.margin)
-
-        Column
+    rightButtons:
+    [
+        Cura.TertiaryButton
         {
-            height: childrenRect.height
-            spacing: workspaceDialog.margin
-
-            anchors.leftMargin: workspaceDialog.margin
-            anchors.rightMargin: workspaceDialog.margin
-            anchors.bottomMargin: workspaceDialog.margin
-            anchors.topMargin: warning ? workspaceDialog.margin : 0
-
-            anchors.left: parent.left
-            anchors.right: parent.right
-            anchors.top: parent.top
-
-            RowLayout
-            {
-                id: warningRow
-                height: childrenRect.height
-                visible: warning
-                spacing: workspaceDialog.margin
-                UM.ColorImage
-                {
-                    width: UM.Theme.getSize("extruder_icon").width
-                    height: UM.Theme.getSize("extruder_icon").height
-                    source: UM.Theme.getIcon("Warning")
-                }
-
-                UM.Label
-                {
-                    id: warningText
-                    text: catalog.i18nc("@label", "This project contains materials or plugins that are currently not installed in Cura.<br/>Install the missing packages and reopen the project.")
-                }
-            }
-
-            Loader
-            {
-                width: parent.width
-                height: childrenRect.height
-                sourceComponent: buttonRow
-            }
+            text: catalog.i18nc("@action:button", "Cancel")
+            onClicked: reject()
+        },
+        Cura.PrimaryButton
+        {
+            text: catalog.i18nc("@action:button", "Save project")
+            onClicked: accept()
         }
-    }
+    ]
 
     buttonSpacing: UM.Theme.getSize("wide_margin").width
 

+ 416 - 27
plugins/PCBWriter/PCBWriter.py

@@ -1,71 +1,460 @@
 #  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.
 
-from typing import Optional, cast, List, Dict, Pattern, Set
+import pySavitar as Savitar
+
+from PyQt6.QtCore import QBuffer
 
 from UM.Mesh.MeshWriter import MeshWriter
-from UM.Math.Vector import Vector
 from UM.Logger import Logger
-from UM.Math.Matrix import Matrix
-from UM.Application import Application
-from UM.Message import Message
-from UM.Resources import Resources
 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.Settings.EmptyInstanceContainer import EmptyInstanceContainer
-from PyQt6.QtQml import qmlRegisterType
+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 PyQt6.QtCore import QBuffer
-
-import pySavitar as Savitar
-
-import numpy
-import datetime
-
-import zipfile
-import UM.Application
-
 from .PCBDialog import PCBDialog
 from .SettingsExportModel import SettingsExportModel
 from .SettingsExportGroup import SettingsExportGroup
 
-from UM.i18n import i18nCatalog
+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__()
-
-        qmlRegisterType(SettingsExportModel, "PCBWriter", 1, 0, "SettingsExportModel")
-        qmlRegisterType(SettingsExportGroup, "PCBWriter", 1, 0, "SettingsExportGroup")
-        #qmlRegisterUncreatableType(SettingsExportGroup.Category, "PCBWriter", 1, 0, "SettingsExportGroup.Category")
+        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()
-        return True
+
+        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'<?xml version="1.0" encoding="UTF-8"?> \n' + ET.tostring(content_types))
+                archive.writestr(relations_file,
+                                 b'<?xml version="1.0" encoding="UTF-8"?> \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._onDialogClosed)
+        self._config_dialog.finished.connect(self._onDialogFinished)
         self._config_dialog.show()
 
-    def _onDialogClosed(self):
+    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<plugin_id>\w+)@(?P<version>\d+.\d+.\d+)::(?P<value>\w+)")
+        # This regex parses enum values to find if they contain custom
+        # backend engine values. These custom enum values are in the format
+        #      PLUGIN::<plugin_id>@<version>::<value>
+        # 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

+ 16 - 3
plugins/PCBWriter/SettingExport.py

@@ -1,15 +1,17 @@
 #  Copyright (c) 2024 Ultimaker B.V.
 #  Cura is released under the terms of the LGPLv3 or higher.
 
-from PyQt6.QtCore import QObject, pyqtProperty
+from PyQt6.QtCore import QObject, pyqtProperty, pyqtSignal
 
 
-class SettingsExport(QObject):
+class SettingExport(QObject):
 
-    def __init__(self, name, value):
+    def __init__(self, id, name, value):
         super().__init__()
+        self.id = id
         self._name = name
         self._value = value
+        self._selected = True
 
     @pyqtProperty(str, constant=True)
     def name(self):
@@ -18,3 +20,14 @@ class SettingsExport(QObject):
     @pyqtProperty(str, constant=True)
     def value(self):
         return self._value
+
+    selectedChanged = pyqtSignal(bool)
+
+    def setSelected(self, selected):
+        if selected != self._selected:
+            self._selected = selected
+            self.selectedChanged.emit(self._selected)
+
+    @pyqtProperty(bool, fset = setSelected, notify = selectedChanged)
+    def selected(self):
+        return self._selected

+ 2 - 1
plugins/PCBWriter/SettingSelection.qml

@@ -17,7 +17,8 @@ RowLayout
     {
         text: modelData.name
         Layout.preferredWidth: UM.Theme.getSize("setting").width
-        checked: true
+        checked: modelData.selected
+        onClicked: modelData.selected = checked
     }
 
     UM.Label

+ 2 - 1
plugins/PCBWriter/SettingsExportGroup.py

@@ -14,8 +14,9 @@ class SettingsExportGroup(QObject):
         Extruder = 1
         Model = 2
 
-    def __init__(self, name, category, settings, category_details = '', extruder_index = 0, extruder_color = ''):
+    def __init__(self, stack, name, category, settings, category_details = '', extruder_index = 0, extruder_color = ''):
         super().__init__()
+        self.stack = stack
         self._name = name
         self._settings = settings
         self._category = category

+ 20 - 9
plugins/PCBWriter/SettingsExportModel.py

@@ -1,13 +1,21 @@
 #  Copyright (c) 2024 Ultimaker B.V.
 #  Cura is released under the terms of the LGPLv3 or higher.
 
+from dataclasses import asdict
+from typing import Optional, cast, List, Dict, Pattern, Set
+
 from PyQt6.QtCore import QObject, pyqtProperty
 
-from .SettingsExportGroup import SettingsExportGroup
-from .SettingExport import SettingsExport
-from cura.CuraApplication import CuraApplication
 from UM.Settings.SettingDefinition import SettingDefinition
+from UM.Settings.InstanceContainer import InstanceContainer
+from UM.Settings.SettingFunction import SettingFunction
+
+from cura.CuraApplication import CuraApplication
 from cura.Settings.ExtruderManager import ExtruderManager
+from cura.Settings.GlobalStack import GlobalStack
+
+from .SettingsExportGroup import SettingsExportGroup
+from .SettingExport import SettingExport
 
 
 class SettingsExportModel(QObject):
@@ -61,7 +69,8 @@ class SettingsExportModel(QObject):
 
         # Display global settings
         global_stack = application.getGlobalContainerStack()
-        self._settings_groups.append(SettingsExportGroup("Global settings",
+        self._settings_groups.append(SettingsExportGroup(global_stack,
+                                                         "Global settings",
                                                          SettingsExportGroup.Category.Global,
                                                          self._exportSettings(global_stack)))
 
@@ -72,7 +81,8 @@ class SettingsExportModel(QObject):
             if extruder_stack.material:
                 color = extruder_stack.material.getMetaDataEntry("color_code")
 
-            self._settings_groups.append(SettingsExportGroup("Extruder settings",
+            self._settings_groups.append(SettingsExportGroup(extruder_stack,
+                                                             "Extruder settings",
                                                              SettingsExportGroup.Category.Extruder,
                                                              self._exportSettings(extruder_stack),
                                                              extruder_index=extruder_stack.position,
@@ -83,13 +93,14 @@ class SettingsExportModel(QObject):
         for scene_node in scene_root.getChildren():
             per_model_stack = scene_node.callDecoration("getStack")
             if per_model_stack is not None:
-                self._settings_groups.append(SettingsExportGroup("Model settings",
+                self._settings_groups.append(SettingsExportGroup(per_model_stack,
+                                                                 "Model settings",
                                                                  SettingsExportGroup.Category.Model,
                                                                  self._exportSettings(per_model_stack),
                                                                  scene_node.getName()))
 
     @pyqtProperty(list, constant=True)
-    def settingsGroups(self):
+    def settingsGroups(self) -> List[SettingsExportGroup]:
         return self._settings_groups
 
     @staticmethod
@@ -110,6 +121,6 @@ class SettingsExportModel(QObject):
             else:
                 value = str(value)
 
-            settings_export.append(SettingsExport(label, value))
+            settings_export.append(SettingExport(setting_to_export, label, value))
 
-        return settings_export
+        return settings_export

+ 10 - 2
plugins/PCBWriter/__init__.py

@@ -2,9 +2,14 @@
 # Cura is released under the terms of the LGPLv3 or higher.
 import sys
 
-from . import PCBWriter
+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():
@@ -12,10 +17,13 @@ def getMetaData():
         "output": [{
             "extension": "pcb",
             "description": i18n_catalog.i18nc("@item:inlistbox", "Pre-Configured Batch file"),
-            "mime_type": "application/vnd.um.preconfigured-batch+3mf",
+            "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() }