From f07faac4223d3d15d36b486e1fce18a84ea55fb0 Mon Sep 17 00:00:00 2001 From: "c.lamboo" Date: Fri, 4 Aug 2023 13:24:06 +0200 Subject: [PATCH 1/4] Add copy, paste, cut functionality CURA-7913 --- cura/CuraActions.py | 70 +++++++++++++++++++++++++++-- resources/qml/Actions.qml | 33 +++++++++++++- resources/qml/Menus/ContextMenu.qml | 2 + 3 files changed, 101 insertions(+), 4 deletions(-) diff --git a/cura/CuraActions.py b/cura/CuraActions.py index 193803325f..aef26c7082 100644 --- a/cura/CuraActions.py +++ b/cura/CuraActions.py @@ -1,15 +1,20 @@ -# Copyright (c) 2018 Ultimaker B.V. +# Copyright (c) 2023 UltiMaker # Cura is released under the terms of the LGPLv3 or higher. -from PyQt6.QtCore import QObject, QUrl -from PyQt6.QtGui import QDesktopServices from typing import List, cast +from PyQt6.QtCore import QObject, QUrl, QMimeData +from PyQt6.QtGui import QDesktopServices +from PyQt6.QtWidgets import QApplication + +import pySavitar as Savitar + from UM.Event import CallFunctionEvent from UM.FlameProfiler import pyqtSlot from UM.Math.Vector import Vector from UM.Scene.Selection import Selection from UM.Scene.Iterator.BreadthFirstIterator import BreadthFirstIterator +from UM.Scene.Iterator.DepthFirstIterator import DepthFirstIterator from UM.Operations.GroupedOperation import GroupedOperation from UM.Operations.RemoveSceneNodeOperation import RemoveSceneNodeOperation from UM.Operations.TranslateOperation import TranslateOperation @@ -19,6 +24,7 @@ from cura.Operations.SetParentOperation import SetParentOperation from cura.MultiplyObjectsJob import MultiplyObjectsJob from cura.Settings.SetObjectExtruderOperation import SetObjectExtruderOperation from cura.Settings.ExtruderManager import ExtruderManager +from cura.Arranging.Nest2DArrange import createGroupOperationForArrange from cura.Operations.SetBuildPlateNumberOperation import SetBuildPlateNumberOperation @@ -181,5 +187,63 @@ class CuraActions(QObject): Selection.clear() + @pyqtSlot() + def cut(self) -> None: + self.copy() + self.deleteSelection() + + @pyqtSlot() + def copy(self) -> None: + # Convert all selected objects to a Savitar scene + savitar_scene = Savitar.Scene() + mesh_writer = cura.CuraApplication.CuraApplication.getInstance().getMeshFileHandler().getWriter("3MFWriter") + for scene_node in Selection.getAllSelectedObjects(): + savitar_node = mesh_writer._convertUMNodeToSavitarNode(scene_node) + savitar_scene.addSceneNode(savitar_node) + + # Convert the scene to a string + parser = Savitar.ThreeMFParser() + scene_string = parser.sceneToString(savitar_scene) + + # Copy the scene to the clipboard + QApplication.clipboard().setText(scene_string) + + @pyqtSlot() + def paste(self) -> None: + application = cura.CuraApplication.CuraApplication.getInstance() + + # Parse the scene from the clipboard + scene_string = QApplication.clipboard().text() + parser = Savitar.ThreeMFParser() + scene = parser.parse(scene_string) + + # Convert the scene to scene nodes + nodes = [] + mesh_reader = application.getMeshFileHandler().getReaderForFile(".3mf") + for savitar_node in scene.getSceneNodes(): + scene_node = mesh_reader._convertSavitarNodeToUMNode(savitar_node, "file_name") + if scene_node is None: + continue + nodes.append(scene_node) + + # Find all fixed nodes, these are the nodes that should be avoided when arranging + fixed_nodes = [] + root = application.getController().getScene().getRoot() + for node in DepthFirstIterator(root): + # Only count sliceable objects + if node.callDecoration("isSliceable"): + fixed_nodes.append(node) + # Add the new nodes to the scene, and arrange them + group_operation, not_fit_count = createGroupOperationForArrange(nodes, application.getBuildVolume(), + fixed_nodes, factor=10000, + add_new_nodes_in_scene=True) + group_operation.push() + + # deselect currently selected nodes, and select the new nodes + for node in Selection.getAllSelectedObjects(): + Selection.remove(node) + for node in nodes: + Selection.add(node) + def _openUrl(self, url: QUrl) -> None: QDesktopServices.openUrl(url) diff --git a/resources/qml/Actions.qml b/resources/qml/Actions.qml index 6cd75b51ac..9cd72026b2 100644 --- a/resources/qml/Actions.qml +++ b/resources/qml/Actions.qml @@ -1,4 +1,4 @@ -// Copyright (c) 2022 UltiMaker +// Copyright (c) 2023 UltiMaker // Cura is released under the terms of the LGPLv3 or higher. pragma Singleton @@ -71,6 +71,10 @@ Item property alias browsePackages: browsePackagesAction + property alias paste: pasteAction + property alias copy: copyAction + property alias cut: cutAction + UM.I18nCatalog{id: catalog; name: "cura"} @@ -309,6 +313,33 @@ Item onTriggered: CuraActions.centerSelection() } + Action + { + id: copyAction + text: catalog.i18nc("@action:inmenu menubar:edit", "Copy to clipboard") + onTriggered: CuraActions.copy() + enabled: UM.Controller.toolsEnabled && UM.Selection.hasSelection + shortcut: StandardKey.Copy + } + + Action + { + id: pasteAction + text: catalog.i18nc("@action:inmenu menubar:edit", "Paste from clipboard") + onTriggered: CuraActions.paste() + enabled: UM.Controller.toolsEnabled + shortcut: StandardKey.Paste + } + + Action + { + id: cutAction + text: catalog.i18nc("@action:inmenu menubar:edit", "Cut") + onTriggered: CuraActions.cut() + enabled: UM.Controller.toolsEnabled && UM.Selection.hasSelection + shortcut: StandardKey.Cut + } + Action { id: multiplySelectionAction diff --git a/resources/qml/Menus/ContextMenu.qml b/resources/qml/Menus/ContextMenu.qml index f7029939cd..d85703451f 100644 --- a/resources/qml/Menus/ContextMenu.qml +++ b/resources/qml/Menus/ContextMenu.qml @@ -19,6 +19,8 @@ Cura.Menu // Selection-related actions. Cura.MenuItem { action: Cura.Actions.centerSelection; } Cura.MenuItem { action: Cura.Actions.deleteSelection; } + Cura.MenuItem { action: Cura.Actions.copy; } + Cura.MenuItem { action: Cura.Actions.paste; } Cura.MenuItem { action: Cura.Actions.multiplySelection; } // Extruder selection - only visible if there is more than 1 extruder From f8b3fb3d67890b5ee3d2aeac945c9ff136cf62bf Mon Sep 17 00:00:00 2001 From: "c.lamboo" Date: Mon, 7 Aug 2023 13:41:01 +0200 Subject: [PATCH 2/4] Move parse/write responsibility of copy/paste to 3MFWriter/3MFReader CURA-7913 --- cura/CuraActions.py | 27 ++++++--------------------- plugins/3MFReader/ThreeMFReader.py | 29 +++++++++++++++++++++++------ plugins/3MFWriter/ThreeMFWriter.py | 26 +++++++++++++++++++------- 3 files changed, 48 insertions(+), 34 deletions(-) diff --git a/cura/CuraActions.py b/cura/CuraActions.py index aef26c7082..338c18a6b9 100644 --- a/cura/CuraActions.py +++ b/cura/CuraActions.py @@ -7,8 +7,6 @@ from PyQt6.QtCore import QObject, QUrl, QMimeData from PyQt6.QtGui import QDesktopServices from PyQt6.QtWidgets import QApplication -import pySavitar as Savitar - from UM.Event import CallFunctionEvent from UM.FlameProfiler import pyqtSlot from UM.Math.Vector import Vector @@ -194,18 +192,13 @@ class CuraActions(QObject): @pyqtSlot() def copy(self) -> None: - # Convert all selected objects to a Savitar scene - savitar_scene = Savitar.Scene() mesh_writer = cura.CuraApplication.CuraApplication.getInstance().getMeshFileHandler().getWriter("3MFWriter") - for scene_node in Selection.getAllSelectedObjects(): - savitar_node = mesh_writer._convertUMNodeToSavitarNode(scene_node) - savitar_scene.addSceneNode(savitar_node) - # Convert the scene to a string - parser = Savitar.ThreeMFParser() - scene_string = parser.sceneToString(savitar_scene) - - # Copy the scene to the clipboard + # Get the selected nodes + selected_objects = Selection.getAllSelectedObjects() + # Serialize the nodes to a string + scene_string = mesh_writer.sceneNodesToString(selected_objects) + # Put the string on the clipboard QApplication.clipboard().setText(scene_string) @pyqtSlot() @@ -214,17 +207,9 @@ class CuraActions(QObject): # Parse the scene from the clipboard scene_string = QApplication.clipboard().text() - parser = Savitar.ThreeMFParser() - scene = parser.parse(scene_string) - # Convert the scene to scene nodes - nodes = [] mesh_reader = application.getMeshFileHandler().getReaderForFile(".3mf") - for savitar_node in scene.getSceneNodes(): - scene_node = mesh_reader._convertSavitarNodeToUMNode(savitar_node, "file_name") - if scene_node is None: - continue - nodes.append(scene_node) + nodes = mesh_reader.stringToSceneNodes(scene_string) # Find all fixed nodes, these are the nodes that should be avoided when arranging fixed_nodes = [] diff --git a/plugins/3MFReader/ThreeMFReader.py b/plugins/3MFReader/ThreeMFReader.py index e8b6a54e46..e06e9dcf4e 100755 --- a/plugins/3MFReader/ThreeMFReader.py +++ b/plugins/3MFReader/ThreeMFReader.py @@ -56,7 +56,8 @@ class ThreeMFReader(MeshReader): def emptyFileHintSet(self) -> bool: return self._empty_project - def _createMatrixFromTransformationString(self, transformation: str) -> Matrix: + @staticmethod + def _createMatrixFromTransformationString(transformation: str) -> Matrix: if transformation == "": return Matrix() @@ -90,7 +91,8 @@ class ThreeMFReader(MeshReader): return temp_mat - def _convertSavitarNodeToUMNode(self, savitar_node: Savitar.SceneNode, file_name: str = "") -> Optional[SceneNode]: + @staticmethod + def _convertSavitarNodeToUMNode(savitar_node: Savitar.SceneNode, file_name: str = "") -> Optional[SceneNode]: """Convenience function that converts a SceneNode object (as obtained from libSavitar) to a scene node. :returns: Scene node. @@ -119,7 +121,7 @@ class ThreeMFReader(MeshReader): pass um_node.setName(node_name) um_node.setId(node_id) - transformation = self._createMatrixFromTransformationString(savitar_node.getTransformation()) + transformation = ThreeMFReader._createMatrixFromTransformationString(savitar_node.getTransformation()) um_node.setTransformation(transformation) mesh_builder = MeshBuilder() @@ -138,7 +140,7 @@ class ThreeMFReader(MeshReader): um_node.setMeshData(mesh_data) for child in savitar_node.getChildren(): - child_node = self._convertSavitarNodeToUMNode(child) + child_node = ThreeMFReader._convertSavitarNodeToUMNode(child) if child_node: um_node.addChild(child_node) @@ -214,7 +216,7 @@ class ThreeMFReader(MeshReader): CuraApplication.getInstance().getController().getScene().setMetaDataEntry(key, value) for node in scene_3mf.getSceneNodes(): - um_node = self._convertSavitarNodeToUMNode(node, file_name) + um_node = ThreeMFReader._convertSavitarNodeToUMNode(node, file_name) if um_node is None: continue @@ -300,8 +302,23 @@ class ThreeMFReader(MeshReader): if unit is None: unit = "millimeter" elif unit not in conversion_to_mm: - Logger.log("w", "Unrecognised unit {unit} used. Assuming mm instead.".format(unit = unit)) + Logger.log("w", "Unrecognised unit {unit} used. Assuming mm instead.".format(unit=unit)) unit = "millimeter" scale = conversion_to_mm[unit] return Vector(scale, scale, scale) + + @staticmethod + def stringToSceneNodes(scene_string: str) -> List[SceneNode]: + parser = Savitar.ThreeMFParser() + scene = parser.parse(scene_string) + + # Convert the scene to scene nodes + nodes = [] + for savitar_node in scene.getSceneNodes(): + scene_node = ThreeMFReader._convertSavitarNodeToUMNode(savitar_node, "file_name") + if scene_node is None: + continue + nodes.append(scene_node) + + return nodes diff --git a/plugins/3MFWriter/ThreeMFWriter.py b/plugins/3MFWriter/ThreeMFWriter.py index 57c667145e..3f6fef7201 100644 --- a/plugins/3MFWriter/ThreeMFWriter.py +++ b/plugins/3MFWriter/ThreeMFWriter.py @@ -55,11 +55,12 @@ class ThreeMFWriter(MeshWriter): "cura": "http://software.ultimaker.com/xml/cura/3mf/2015/10" } - self._unit_matrix_string = self._convertMatrixToString(Matrix()) + self._unit_matrix_string = ThreeMFWriter._convertMatrixToString(Matrix()) self._archive: Optional[zipfile.ZipFile] = None self._store_archive = False - def _convertMatrixToString(self, matrix): + @staticmethod + def _convertMatrixToString(matrix): result = "" result += str(matrix._data[0, 0]) + " " result += str(matrix._data[1, 0]) + " " @@ -83,7 +84,8 @@ class ThreeMFWriter(MeshWriter): """ self._store_archive = store_archive - def _convertUMNodeToSavitarNode(self, um_node, transformation = Matrix()): + @staticmethod + def _convertUMNodeToSavitarNode(um_node, transformation=Matrix()): """Convenience function that converts an Uranium SceneNode object to a SavitarSceneNode :returns: Uranium Scene node. @@ -100,7 +102,7 @@ class ThreeMFWriter(MeshWriter): node_matrix = um_node.getLocalTransformation() - matrix_string = self._convertMatrixToString(node_matrix.preMultiply(transformation)) + matrix_string = ThreeMFWriter._convertMatrixToString(node_matrix.preMultiply(transformation)) savitar_node.setTransformation(matrix_string) mesh_data = um_node.getMeshData() @@ -133,7 +135,7 @@ 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 = self._convertUMNodeToSavitarNode(child_node) + savitar_child_node = ThreeMFWriter._convertUMNodeToSavitarNode(child_node) if savitar_child_node is not None: savitar_node.addChild(savitar_child_node) @@ -221,7 +223,7 @@ class ThreeMFWriter(MeshWriter): for node in nodes: if node == root_node: for root_child in node.getChildren(): - savitar_node = self._convertUMNodeToSavitarNode(root_child, transformation_matrix) + savitar_node = ThreeMFWriter._convertUMNodeToSavitarNode(root_child, transformation_matrix) if savitar_node: savitar_scene.addSceneNode(savitar_node) else: @@ -303,9 +305,19 @@ class ThreeMFWriter(MeshWriter): Logger.log("w", "Can't create snapshot when renderer not initialized.") return None try: - snapshot = Snapshot.snapshot(width = 300, height = 300) + snapshot = Snapshot.snapshot(width=300, height=300) except: Logger.logException("w", "Failed to create snapshot image") return None return snapshot + + @staticmethod + def sceneNodesToString(scene_nodes: [SceneNode]) -> str: + savitar_scene = Savitar.Scene() + for scene_node in scene_nodes: + savitar_node = ThreeMFWriter._convertUMNodeToSavitarNode(scene_node) + savitar_scene.addSceneNode(savitar_node) + parser = Savitar.ThreeMFParser() + scene_string = parser.sceneToString(savitar_scene) + return scene_string From c393d915d7e85e7437846475d416ead9c3455c0d Mon Sep 17 00:00:00 2001 From: "c.lamboo" Date: Tue, 8 Aug 2023 12:17:08 +0200 Subject: [PATCH 3/4] Disable copy paste when either 3mf reader or writer is disabled CURA-7913 --- cura/CuraActions.py | 8 +++++++- resources/qml/Actions.qml | 13 +++++++++---- 2 files changed, 16 insertions(+), 5 deletions(-) diff --git a/cura/CuraActions.py b/cura/CuraActions.py index 338c18a6b9..2f62944caa 100644 --- a/cura/CuraActions.py +++ b/cura/CuraActions.py @@ -193,6 +193,9 @@ class CuraActions(QObject): @pyqtSlot() def copy(self) -> None: mesh_writer = cura.CuraApplication.CuraApplication.getInstance().getMeshFileHandler().getWriter("3MFWriter") + if not mesh_writer: + Logger.log("e", "No 3MF writer found, unable to copy.") + return # Get the selected nodes selected_objects = Selection.getAllSelectedObjects() @@ -204,11 +207,14 @@ class CuraActions(QObject): @pyqtSlot() def paste(self) -> None: application = cura.CuraApplication.CuraApplication.getInstance() + mesh_reader = application.getMeshFileHandler().getReaderForFile(".3mf") + if not mesh_reader: + Logger.log("e", "No 3MF reader found, unable to paste.") + return # Parse the scene from the clipboard scene_string = QApplication.clipboard().text() - mesh_reader = application.getMeshFileHandler().getReaderForFile(".3mf") nodes = mesh_reader.stringToSceneNodes(scene_string) # Find all fixed nodes, these are the nodes that should be avoided when arranging diff --git a/resources/qml/Actions.qml b/resources/qml/Actions.qml index 9cd72026b2..3b75c7699e 100644 --- a/resources/qml/Actions.qml +++ b/resources/qml/Actions.qml @@ -6,7 +6,7 @@ pragma Singleton import QtQuick 2.10 import QtQuick.Controls 2.4 import UM 1.1 as UM -import Cura 1.0 as Cura +import Cura 1.5 as Cura Item { @@ -75,6 +75,11 @@ Item property alias copy: copyAction property alias cut: cutAction + readonly property bool copy_paste_enabled: { + const all_enabled_packages = CuraApplication.getPackageManager().allEnabledPackages; + return all_enabled_packages.includes("3MFReader") && all_enabled_packages.includes("3MFWriter"); + } + UM.I18nCatalog{id: catalog; name: "cura"} @@ -318,7 +323,7 @@ Item id: copyAction text: catalog.i18nc("@action:inmenu menubar:edit", "Copy to clipboard") onTriggered: CuraActions.copy() - enabled: UM.Controller.toolsEnabled && UM.Selection.hasSelection + enabled: UM.Controller.toolsEnabled && UM.Selection.hasSelection && copy_paste_enabled shortcut: StandardKey.Copy } @@ -327,7 +332,7 @@ Item id: pasteAction text: catalog.i18nc("@action:inmenu menubar:edit", "Paste from clipboard") onTriggered: CuraActions.paste() - enabled: UM.Controller.toolsEnabled + enabled: UM.Controller.toolsEnabled && copy_paste_enabled shortcut: StandardKey.Paste } @@ -336,7 +341,7 @@ Item id: cutAction text: catalog.i18nc("@action:inmenu menubar:edit", "Cut") onTriggered: CuraActions.cut() - enabled: UM.Controller.toolsEnabled && UM.Selection.hasSelection + enabled: UM.Controller.toolsEnabled && UM.Selection.hasSelection && copy_paste_enabled shortcut: StandardKey.Cut } From 511f05c392648c93c1840fac3b47b5dd70706558 Mon Sep 17 00:00:00 2001 From: "c.lamboo" Date: Tue, 8 Aug 2023 12:26:44 +0200 Subject: [PATCH 4/4] Don't arrange in paste when no objects were present in the clipboard CURA-7913 --- cura/CuraActions.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/cura/CuraActions.py b/cura/CuraActions.py index 2f62944caa..6c2d3f4cb8 100644 --- a/cura/CuraActions.py +++ b/cura/CuraActions.py @@ -217,6 +217,10 @@ class CuraActions(QObject): nodes = mesh_reader.stringToSceneNodes(scene_string) + if not nodes: + # Nothing to paste + return + # Find all fixed nodes, these are the nodes that should be avoided when arranging fixed_nodes = [] root = application.getController().getScene().getRoot()