CuraActions.py 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282
  1. # Copyright (c) 2023 UltiMaker
  2. # Cura is released under the terms of the LGPLv3 or higher.
  3. from typing import List, cast
  4. from PyQt6.QtCore import QObject, QUrl, pyqtSignal, pyqtProperty
  5. from PyQt6.QtGui import QDesktopServices
  6. from PyQt6.QtWidgets import QApplication
  7. from UM.Application import Application
  8. from UM.Event import CallFunctionEvent
  9. from UM.FlameProfiler import pyqtSlot
  10. from UM.Math.Vector import Vector
  11. from UM.Scene.Selection import Selection
  12. from UM.Scene.Iterator.BreadthFirstIterator import BreadthFirstIterator
  13. from UM.Scene.Iterator.DepthFirstIterator import DepthFirstIterator
  14. from UM.Operations.GroupedOperation import GroupedOperation
  15. from UM.Operations.RemoveSceneNodeOperation import RemoveSceneNodeOperation
  16. from UM.Operations.TranslateOperation import TranslateOperation
  17. import cura.CuraApplication
  18. from cura.Operations.SetParentOperation import SetParentOperation
  19. from cura.MultiplyObjectsJob import MultiplyObjectsJob
  20. from cura.Settings.SetObjectExtruderOperation import SetObjectExtruderOperation
  21. from cura.Settings.ExtruderManager import ExtruderManager
  22. from cura.Arranging.GridArrange import GridArrange
  23. from cura.Arranging.Nest2DArrange import Nest2DArrange
  24. from cura.Operations.SetBuildPlateNumberOperation import SetBuildPlateNumberOperation
  25. from UM.Logger import Logger
  26. from UM.Scene.SceneNode import SceneNode
  27. class CuraActions(QObject):
  28. def __init__(self, parent: QObject = None) -> None:
  29. super().__init__(parent)
  30. self._operation_stack = Application.getInstance().getOperationStack()
  31. self._operation_stack.changed.connect(self._onUndoStackChanged)
  32. undoStackChanged = pyqtSignal()
  33. @pyqtSlot()
  34. def openDocumentation(self) -> None:
  35. # Starting a web browser from a signal handler connected to a menu will crash on windows.
  36. # So instead, defer the call to the next run of the event loop, since that does work.
  37. # Note that weirdly enough, only signal handlers that open a web browser fail like that.
  38. event = CallFunctionEvent(self._openUrl, [QUrl("https://ultimaker.com/en/resources/manuals/software?utm_source=cura&utm_medium=software&utm_campaign=dropdown-documentation")], {})
  39. cura.CuraApplication.CuraApplication.getInstance().functionEvent(event)
  40. @pyqtProperty(bool, notify=undoStackChanged)
  41. def canUndo(self):
  42. return self._operation_stack.canUndo()
  43. @pyqtProperty(bool, notify=undoStackChanged)
  44. def canRedo(self):
  45. return self._operation_stack.canRedo()
  46. @pyqtSlot()
  47. def undo(self):
  48. self._operation_stack.undo()
  49. @pyqtSlot()
  50. def redo(self):
  51. self._operation_stack.redo()
  52. def _onUndoStackChanged(self):
  53. self.undoStackChanged.emit()
  54. @pyqtSlot()
  55. def openBugReportPage(self) -> None:
  56. event = CallFunctionEvent(self._openUrl, [QUrl("https://github.com/Ultimaker/Cura/issues/new/choose")], {})
  57. cura.CuraApplication.CuraApplication.getInstance().functionEvent(event)
  58. @pyqtSlot()
  59. def homeCamera(self) -> None:
  60. """Reset camera position and direction to default"""
  61. scene = cura.CuraApplication.CuraApplication.getInstance().getController().getScene()
  62. camera = scene.getActiveCamera()
  63. if camera:
  64. diagonal_size = cura.CuraApplication.CuraApplication.getInstance().getBuildVolume().getDiagonalSize()
  65. camera.setPosition(Vector(-80, 250, 700) * diagonal_size / 375)
  66. camera.setPerspective(True)
  67. camera.lookAt(Vector(0, 0, 0))
  68. @pyqtSlot()
  69. def centerSelection(self) -> None:
  70. """Center all objects in the selection"""
  71. operation = GroupedOperation()
  72. for node in Selection.getAllSelectedObjects():
  73. current_node = node
  74. parent_node = current_node.getParent()
  75. while parent_node and parent_node.callDecoration("isGroup"):
  76. current_node = parent_node
  77. parent_node = current_node.getParent()
  78. # Find out where the bottom of the object is
  79. bbox = current_node.getBoundingBox()
  80. if bbox:
  81. center_y = current_node.getWorldPosition().y - bbox.bottom
  82. else:
  83. center_y = 0
  84. # Move the object so that it's bottom is on to of the buildplate
  85. center_operation = TranslateOperation(current_node, Vector(0, center_y, 0), set_position = True)
  86. operation.addOperation(center_operation)
  87. operation.push()
  88. @pyqtSlot(int)
  89. def multiplySelection(self, count: int) -> None:
  90. """Multiply all objects in the selection
  91. :param count: The number of times to multiply the selection.
  92. """
  93. min_offset = cura.CuraApplication.CuraApplication.getInstance().getBuildVolume().getEdgeDisallowedSize() + 2 # Allow for some rounding errors
  94. job = MultiplyObjectsJob(Selection.getAllSelectedObjects(), count, min_offset = max(min_offset, 8))
  95. job.start()
  96. @pyqtSlot(int)
  97. def multiplySelectionToGrid(self, count: int) -> None:
  98. """Multiply all objects in the selection
  99. :param count: The number of times to multiply the selection.
  100. """
  101. min_offset = cura.CuraApplication.CuraApplication.getInstance().getBuildVolume().getEdgeDisallowedSize() + 2 # Allow for some rounding errors
  102. job = MultiplyObjectsJob(Selection.getAllSelectedObjects(), count, min_offset=max(min_offset, 8),
  103. grid_arrange=True)
  104. job.start()
  105. @pyqtSlot()
  106. def deleteSelection(self) -> None:
  107. """Delete all selected objects."""
  108. if not cura.CuraApplication.CuraApplication.getInstance().getController().getToolsEnabled():
  109. return
  110. removed_group_nodes = [] #type: List[SceneNode]
  111. op = GroupedOperation()
  112. nodes = Selection.getAllSelectedObjects()
  113. for node in nodes:
  114. op.addOperation(RemoveSceneNodeOperation(node))
  115. group_node = node.getParent()
  116. if group_node and group_node.callDecoration("isGroup") and group_node not in removed_group_nodes:
  117. remaining_nodes_in_group = list(set(group_node.getChildren()) - set(nodes))
  118. if len(remaining_nodes_in_group) == 1:
  119. removed_group_nodes.append(group_node)
  120. op.addOperation(SetParentOperation(remaining_nodes_in_group[0], group_node.getParent()))
  121. op.addOperation(RemoveSceneNodeOperation(group_node))
  122. # Reset the print information
  123. cura.CuraApplication.CuraApplication.getInstance().getController().getScene().sceneChanged.emit(node)
  124. op.push()
  125. @pyqtSlot(str)
  126. def setExtruderForSelection(self, extruder_id: str) -> None:
  127. """Set the extruder that should be used to print the selection.
  128. :param extruder_id: The ID of the extruder stack to use for the selected objects.
  129. """
  130. operation = GroupedOperation()
  131. nodes_to_change = []
  132. for node in Selection.getAllSelectedObjects():
  133. # If the node is a group, apply the active extruder to all children of the group.
  134. if node.callDecoration("isGroup"):
  135. for grouped_node in BreadthFirstIterator(node): #type: ignore #Ignore type error because iter() should get called automatically by Python syntax.
  136. if grouped_node.callDecoration("getActiveExtruder") == extruder_id:
  137. continue
  138. if grouped_node.callDecoration("isGroup"):
  139. continue
  140. nodes_to_change.append(grouped_node)
  141. continue
  142. # Do not change any nodes that already have the right extruder set.
  143. if node.callDecoration("getActiveExtruder") == extruder_id:
  144. continue
  145. nodes_to_change.append(node)
  146. if not nodes_to_change:
  147. # If there are no changes to make, we still need to reset the selected extruders.
  148. # This is a workaround for checked menu items being deselected while still being
  149. # selected.
  150. ExtruderManager.getInstance().resetSelectedObjectExtruders()
  151. return
  152. for node in nodes_to_change:
  153. operation.addOperation(SetObjectExtruderOperation(node, extruder_id))
  154. operation.push()
  155. @pyqtSlot(int)
  156. def setBuildPlateForSelection(self, build_plate_nr: int) -> None:
  157. Logger.log("d", "Setting build plate number... %d" % build_plate_nr)
  158. operation = GroupedOperation()
  159. root = cura.CuraApplication.CuraApplication.getInstance().getController().getScene().getRoot()
  160. nodes_to_change = [] # type: List[SceneNode]
  161. for node in Selection.getAllSelectedObjects():
  162. parent_node = node # Find the parent node to change instead
  163. while parent_node.getParent() != root:
  164. parent_node = cast(SceneNode, parent_node.getParent())
  165. for single_node in BreadthFirstIterator(parent_node): # type: ignore #Ignore type error because iter() should get called automatically by Python syntax.
  166. nodes_to_change.append(single_node)
  167. if not nodes_to_change:
  168. Logger.log("d", "Nothing to change.")
  169. return
  170. for node in nodes_to_change:
  171. operation.addOperation(SetBuildPlateNumberOperation(node, build_plate_nr))
  172. operation.push()
  173. Selection.clear()
  174. @pyqtSlot()
  175. def cut(self) -> None:
  176. self.copy()
  177. self.deleteSelection()
  178. @pyqtSlot()
  179. def copy(self) -> None:
  180. mesh_writer = cura.CuraApplication.CuraApplication.getInstance().getMeshFileHandler().getWriter("3MFWriter")
  181. if not mesh_writer:
  182. Logger.log("e", "No 3MF writer found, unable to copy.")
  183. return
  184. # Get the selected nodes
  185. selected_objects = Selection.getAllSelectedObjects()
  186. # Serialize the nodes to a string
  187. scene_string = mesh_writer.sceneNodesToString(selected_objects)
  188. # Put the string on the clipboard
  189. QApplication.clipboard().setText(scene_string)
  190. @pyqtSlot()
  191. def paste(self) -> None:
  192. application = cura.CuraApplication.CuraApplication.getInstance()
  193. mesh_reader = application.getMeshFileHandler().getReaderForFile(".3mf")
  194. if not mesh_reader:
  195. Logger.log("e", "No 3MF reader found, unable to paste.")
  196. return
  197. # Parse the scene from the clipboard
  198. scene_string = QApplication.clipboard().text()
  199. nodes = mesh_reader.stringToSceneNodes(scene_string)
  200. if not nodes:
  201. # Nothing to paste
  202. return
  203. # Find all fixed nodes, these are the nodes that should be avoided when arranging
  204. fixed_nodes = []
  205. root = application.getController().getScene().getRoot()
  206. for node in DepthFirstIterator(root):
  207. # Only count sliceable objects
  208. if node.callDecoration("isSliceable"):
  209. fixed_nodes.append(node)
  210. # Add the new nodes to the scene, and arrange them
  211. arranger = GridArrange(nodes, application.getBuildVolume(), fixed_nodes)
  212. group_operation, not_fit_count = arranger.createGroupOperationForArrange(add_new_nodes_in_scene = True)
  213. group_operation.push()
  214. # deselect currently selected nodes, and select the new nodes
  215. for node in Selection.getAllSelectedObjects():
  216. Selection.remove(node)
  217. numberOfFixedNodes = len(fixed_nodes)
  218. for node in nodes:
  219. numberOfFixedNodes += 1
  220. node.printOrder = numberOfFixedNodes
  221. Selection.add(node)
  222. def _openUrl(self, url: QUrl) -> None:
  223. QDesktopServices.openUrl(url)