CustomSupport.py 17 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268
  1. # Copyright (c) 2018 Ultimaker B.V.
  2. # Cura is released under the terms of the LGPLv3 or higher.
  3. import math #To create the circular cursor.
  4. import numpy #To process coordinates in bulk.
  5. import numpy.linalg #To project window coordinates onto the scene.
  6. from PyQt5.QtCore import Qt #For shortcut keys and colours.
  7. from PyQt5.QtGui import QBrush, QColor, QCursor, QImage, QPainter, QPen, QPixmap #Drawing on a temporary buffer until we're ready to process the area of custom support, and changing the cursor.
  8. import qimage2ndarray #To convert QImage to Numpy arrays.
  9. from typing import Optional, Tuple
  10. from cura.CuraApplication import CuraApplication #To get the camera and settings.
  11. from cura.Operations.SetParentOperation import SetParentOperation #To make the support move along with whatever it is drawn on.
  12. from cura.PickingPass import PickingPass
  13. from cura.Scene.BuildPlateDecorator import BuildPlateDecorator #To put the scene node on the correct build plate.
  14. from cura.Scene.CuraSceneNode import CuraSceneNode #To create a scene node that causes the support to be drawn/erased.
  15. from cura.Scene.SliceableObjectDecorator import SliceableObjectDecorator #To create a scene node that can be sliced.
  16. from UM.Event import Event, MouseEvent #To register mouse movements.
  17. from UM.Logger import Logger
  18. from UM.Mesh.MeshBuilder import MeshBuilder #To create the support structure in 3D.
  19. from UM.Operations.AddSceneNodeOperation import AddSceneNodeOperation #To create the scene node.
  20. from UM.Operations.GroupedOperation import GroupedOperation #To create the scene node.
  21. from UM.Qt.QtApplication import QtApplication #To change the active view.
  22. from UM.Scene.Iterator.BreadthFirstIterator import BreadthFirstIterator #To find the parent node to link custom support to.
  23. from UM.Settings.SettingInstance import SettingInstance #To set the correct support overhang angle for the support mesh.
  24. from UM.Tool import Tool #The interface we're implementing.
  25. class CustomSupport(Tool):
  26. #Diameter of the brush.
  27. brush_size = 20
  28. #Size of each piece of the support mesh in pixels.
  29. #If you make this too small you can't create support since it'll be less
  30. #than a layer high or less than a line wide. If you make this too large the
  31. #drawing will be inaccurate.
  32. globule_size = 1.5
  33. def __init__(self):
  34. super().__init__()
  35. self._shortcut_key = Qt.Key_S
  36. self._previous_view = None #type: Optional[str] #This tool forces SolidView. When the tool is disabled, it goes back to the original view.
  37. self._draw_buffer = None #type: Optional[QImage] #An image to temporarily draw support on until we've processed the draw command completely.
  38. self._painter = None #type: Optional[QPainter] #A pen tool that paints on the draw buffer.
  39. self._last_x = 0 #type: int #The last position that was drawn in the previous frame, if we are currently drawing.
  40. self._last_y = 0 #type: int
  41. self._endcap_pen = QPen(Qt.white) #type: QPen #Pen to use for the end caps of the drawn line. This draws a circle when pressing and releasing the mouse.
  42. self._line_pen = QPen(Qt.white) #type: QPen #Pen to use for drawing connecting lines while dragging the mouse.
  43. self._line_pen.setWidth(self.brush_size)
  44. self._line_pen.setCapStyle(Qt.RoundCap)
  45. #Create the cursor.
  46. cursor_image = QImage(self.brush_size, self.brush_size, QImage.Format_ARGB32)
  47. cursor_image.fill(QColor(0, 0, 0, 0))
  48. for angle in (i / 2 / math.pi for i in range(4 * self.brush_size)):
  49. x = int(math.cos(angle) * self.brush_size / 2 + self.brush_size / 2)
  50. y = int(math.sin(angle) * self.brush_size / 2 + self.brush_size / 2)
  51. cursor_image.setPixelColor(x, y, QColor(128, 128, 128, 255))
  52. cursor_bitmap = QPixmap.fromImage(cursor_image)
  53. self._cursor = QCursor(cursor_bitmap)
  54. def event(self, event: Event):
  55. if event.type == Event.ToolActivateEvent:
  56. active_view = QtApplication.getInstance().getController().getActiveView()
  57. if active_view is not None:
  58. self._previous_view = active_view.getPluginId()
  59. QtApplication.getInstance().getController().setActiveView("SolidView")
  60. QtApplication.getInstance().getController().disableSelection()
  61. QtApplication.getInstance().setOverrideCursor(self._cursor)
  62. elif event.type == Event.ToolDeactivateEvent:
  63. if self._previous_view is not None:
  64. QtApplication.getInstance().getController().setActiveView(self._previous_view)
  65. self._previous_view = None
  66. QtApplication.getInstance().getController().enableSelection()
  67. QtApplication.getInstance().restoreOverrideCursor()
  68. elif event.type == Event.MousePressEvent and MouseEvent.LeftButton in event.buttons:
  69. #Reset the draw buffer and start painting.
  70. self._draw_buffer = QImage(QtApplication.getInstance().getMainWindow().width(), QtApplication.getInstance().getMainWindow().height(), QImage.Format_Grayscale8)
  71. self._draw_buffer.fill(Qt.black)
  72. self._painter = QPainter(self._draw_buffer)
  73. self._painter.setBrush(QBrush(Qt.white))
  74. self._painter.setPen(self._endcap_pen)
  75. self._last_x, self._last_y = self._cursorCoordinates()
  76. self._painter.drawEllipse(self._last_x - self.brush_size / 2, self._last_y - self.brush_size / 2, self.brush_size, self.brush_size) #Paint an initial ellipse at the spot you're clicking.
  77. QtApplication.getInstance().getController().getView("SolidView").setExtraOverhang(self._draw_buffer)
  78. elif event.type == Event.MouseReleaseEvent and MouseEvent.LeftButton in event.buttons:
  79. #Complete drawing.
  80. self._last_x, self._last_y = self._cursorCoordinates()
  81. if self._painter:
  82. self._painter.setPen(self._endcap_pen)
  83. self._painter.drawEllipse(self._last_x - self.brush_size / 2, self._last_y - self.brush_size / 2, self.brush_size, self.brush_size) #Paint another ellipse when you're releasing as endcap.
  84. self._painter = None
  85. QtApplication.getInstance().getController().getView("SolidView").setExtraOverhang(self._draw_buffer)
  86. self._constructSupport(self._draw_buffer) #Actually place the support.
  87. self._resetDrawBuffer()
  88. elif event.type == Event.MouseMoveEvent and self._painter is not None: #While dragging.
  89. self._painter.setPen(self._line_pen)
  90. new_x, new_y = self._cursorCoordinates()
  91. self._painter.drawLine(self._last_x, self._last_y, new_x, new_y)
  92. self._last_x = new_x
  93. self._last_y = new_y
  94. QtApplication.getInstance().getController().getView("SolidView").setExtraOverhang(self._draw_buffer)
  95. ## Construct the actual support intersection structure from an image.
  96. # \param buffer The temporary buffer indicating where support should be
  97. # added and where it should be removed.
  98. def _constructSupport(self, buffer: QImage) -> None:
  99. depth_pass = PickingPass(buffer.width(), buffer.height()) #Instead of using the picking pass to pick for us, we need to bulk-pick digits so do this in Numpy.
  100. depth_pass.render()
  101. depth_image = depth_pass.getOutput()
  102. camera = CuraApplication.getInstance().getController().getScene().getActiveCamera()
  103. to_support = qimage2ndarray.raw_view(buffer)
  104. depth = qimage2ndarray.recarray_view(depth_image)
  105. depth.a = 0 #Discard alpha channel.
  106. depth = depth.view(dtype = numpy.int32).astype(numpy.float32) / 1000 #Conflate the R, G and B channels to one 24-bit (cast to 32) float. Divide by 1000 to get mm.
  107. support_positions_2d = numpy.array(numpy.where(numpy.bitwise_and(to_support == 255, depth < 16777))) #All the 2D coordinates on the screen where we want support. The 16777 is for points that don't land on a model.
  108. support_depths = numpy.take(depth, support_positions_2d[0, :] * depth.shape[1] + support_positions_2d[1, :]) #The depth at those pixels.
  109. support_positions_2d = support_positions_2d.transpose() #We want rows with pixels, not columns with pixels.
  110. if len(support_positions_2d) == 0:
  111. Logger.log("i", "Support was not drawn on the surface of any objects. Not creating support.")
  112. return
  113. support_positions_2d[:, [0, 1]] = support_positions_2d[:, [1, 0]] #Swap columns to get OpenGL's coordinate system.
  114. camera_viewport = numpy.array([camera.getViewportWidth(), camera.getViewportHeight()])
  115. support_positions_2d = support_positions_2d * 2.0 / camera_viewport - 1.0 #Scale to view coordinates (range -1 to 1).
  116. inverted_projection = numpy.linalg.inv(camera.getProjectionMatrix().getData())
  117. transformation = camera.getWorldTransformation().getData()
  118. transformation[:, 1] = -transformation[:, 1] #Invert Z to get OpenGL's coordinate system.
  119. #For each pixel, get the near and far plane.
  120. near = numpy.ndarray((support_positions_2d.shape[0], 4))
  121. near.fill(1)
  122. near[0: support_positions_2d.shape[0], 0: support_positions_2d.shape[1]] = support_positions_2d
  123. near[:, 2].fill(-1)
  124. near = numpy.dot(inverted_projection, near.transpose())
  125. near = numpy.dot(transformation, near)
  126. near = near[0:3] / near[3]
  127. far = numpy.ndarray((support_positions_2d.shape[0], 4))
  128. far.fill(1)
  129. far[0: support_positions_2d.shape[0], 0: support_positions_2d.shape[1]] = support_positions_2d
  130. far = numpy.dot(inverted_projection, far.transpose())
  131. far = numpy.dot(transformation, far)
  132. far = far[0:3] / far[3]
  133. #Direction is from near plane pixel to far plane pixel, normalised.
  134. direction = near - far
  135. direction /= numpy.linalg.norm(direction, axis = 0)
  136. #Final position is in the direction of the pixel, moving with <depth> mm away from the camera position.
  137. support_positions_3d = (support_depths - 1) * direction #We want the support to appear just before the surface, not behind the surface, so - 1.
  138. support_positions_3d = support_positions_3d.transpose()
  139. camera_position_data = camera.getPosition().getData()
  140. support_positions_3d = support_positions_3d + camera_position_data
  141. #Create the vertices for the 3D mesh.
  142. #This mesh consists of a diamond-shape for each position that we traced.
  143. n = support_positions_3d.shape[0]
  144. Logger.log("i", "Adding support in {num_pixels} locations.".format(num_pixels = n))
  145. vertices = support_positions_3d.copy().astype(numpy.float32)
  146. vertices = numpy.resize(vertices, (n * 6, support_positions_3d.shape[1])) #Resize will repeat all coordinates 6 times.
  147. #For each position, create a diamond shape around the position with 6 vertices.
  148. vertices[n * 0: n * 1, 0] -= support_depths * 0.001 * self.globule_size #First corner (-x, +y).
  149. vertices[n * 0: n * 1, 2] += support_depths * 0.001 * self.globule_size
  150. vertices[n * 1: n * 2, 0] += support_depths * 0.001 * self.globule_size #Second corner (+x, +y).
  151. vertices[n * 1: n * 2, 2] += support_depths * 0.001 * self.globule_size
  152. vertices[n * 2: n * 3, 0] -= support_depths * 0.001 * self.globule_size #Third corner (-x, -y).
  153. vertices[n * 2: n * 3, 2] -= support_depths * 0.001 * self.globule_size
  154. vertices[n * 3: n * 4, 0] += support_depths * 0.001 * self.globule_size #Fourth corner (+x, -y)
  155. vertices[n * 3: n * 4, 2] -= support_depths * 0.001 * self.globule_size
  156. vertices[n * 4: n * 5, 1] += support_depths * 0.001 * self.globule_size #Top side.
  157. vertices[n * 5: n * 6, 1] -= support_depths * 0.001 * self.globule_size #Bottom side.
  158. #Create the faces of the diamond.
  159. indices = numpy.arange(n, dtype = numpy.int32)
  160. indices = numpy.kron(indices, numpy.ones((3, 1))).astype(numpy.int32).transpose()
  161. indices = numpy.resize(indices, (n * 8, 3)) #Creates 8 triangles using 3 times the same vertex, for each position: [[0, 0, 0], [1, 1, 1], ... , [0, 0, 0], [1, 1, 1], ... ]
  162. #indices[n * 0: n * 1, 0] += n * 0 #First corner.
  163. indices[n * 0: n * 1, 1] += n * 1 #Second corner.
  164. indices[n * 0: n * 1, 2] += n * 4 #Top side.
  165. indices[n * 1: n * 2, 0] += n * 1 #Second corner.
  166. indices[n * 1: n * 2, 1] += n * 3 #Fourth corner.
  167. indices[n * 1: n * 2, 2] += n * 4 #Top side.
  168. indices[n * 2: n * 3, 0] += n * 3 #Fourth corner.
  169. indices[n * 2: n * 3, 1] += n * 2 #Third corner.
  170. indices[n * 2: n * 3, 2] += n * 4 #Top side.
  171. indices[n * 3: n * 4, 0] += n * 2 #Third corner.
  172. #indices[n * 3: n * 4, 1] += n * 0 #First corner.
  173. indices[n * 3: n * 4, 2] += n * 4 #Top side.
  174. indices[n * 4: n * 5, 0] += n * 1 #Second corner.
  175. #indices[n * 4: n * 5, 1] += n * 0 #First corner.
  176. indices[n * 4: n * 5, 2] += n * 5 #Bottom side.
  177. indices[n * 5: n * 6, 0] += n * 3 #Fourth corner.
  178. indices[n * 5: n * 6, 1] += n * 1 #Second corner.
  179. indices[n * 5: n * 6, 2] += n * 5 #Bottom side.
  180. indices[n * 6: n * 7, 0] += n * 2 #Third corner.
  181. indices[n * 6: n * 7, 1] += n * 3 #Fourth corner.
  182. indices[n * 6: n * 7, 2] += n * 5 #Bottom side.
  183. #indices[n * 7: n * 8, 0] += n * 0 #First corner.
  184. indices[n * 7: n * 8, 1] += n * 2 #Third corner.
  185. indices[n * 7: n * 8, 2] += n * 5 #Bottom side.
  186. builder = MeshBuilder()
  187. builder.addVertices(vertices)
  188. builder.addIndices(indices)
  189. #Create the scene node.
  190. scene = CuraApplication.getInstance().getController().getScene()
  191. new_node = CuraSceneNode(parent = scene.getRoot(), name = "CustomSupport")
  192. new_node.setSelectable(False)
  193. new_node.setMeshData(builder.build())
  194. new_node.addDecorator(BuildPlateDecorator(CuraApplication.getInstance().getMultiBuildPlateModel().activeBuildPlate))
  195. new_node.addDecorator(SliceableObjectDecorator())
  196. operation = GroupedOperation()
  197. #Figure out which mesh this piece of support belongs to.
  198. #TODO: You can draw support in one stroke over multiple meshes. The support would belong to an arbitrary one of these.
  199. selection_pass = CuraApplication.getInstance().getRenderer().getRenderPass("selection")
  200. parent_id = selection_pass.getIdAtPosition(support_positions_2d[0][0], support_positions_2d[0][1]) #Find the selection under the first support pixel.
  201. parent_node = scene.getRoot()
  202. if not parent_id:
  203. Logger.log("d", "Can't link custom support to any scene node.")
  204. else:
  205. for node in BreadthFirstIterator(scene.getRoot()):
  206. if id(node) == parent_id:
  207. parent_node = node
  208. break
  209. #Add the appropriate per-object settings.
  210. stack = new_node.callDecoration("getStack") #Created by SettingOverrideDecorator that is automatically added to CuraSceneNode.
  211. settings = stack.getTop()
  212. support_mesh_instance = SettingInstance(stack.getSettingDefinition("support_mesh"), settings)
  213. support_mesh_instance.setProperty("value", True)
  214. support_mesh_instance.resetState()
  215. settings.addInstance(support_mesh_instance)
  216. drop_down_instance = SettingInstance(stack.getSettingDefinition("support_mesh_drop_down"), settings)
  217. drop_down_instance.setProperty("value", True)
  218. drop_down_instance.resetState()
  219. settings.addInstance(drop_down_instance)
  220. #Add the scene node to the scene (and allow for undo).
  221. operation.addOperation(AddSceneNodeOperation(new_node, scene.getRoot())) #Set the parent to root initially, then change the parent, so that we don't have to alter the transformation.
  222. operation.addOperation(SetParentOperation(new_node, parent_node))
  223. operation.push()
  224. scene.sceneChanged.emit(new_node)
  225. ## Resets the draw buffer so that no pixels are marked as needing support.
  226. def _resetDrawBuffer(self) -> None:
  227. #Create a new buffer so that we don't change the data of a job that's still processing.
  228. self._draw_buffer = QImage(QtApplication.getInstance().getMainWindow().width(), QtApplication.getInstance().getMainWindow().height(), QImage.Format_Grayscale8)
  229. self._draw_buffer.fill(Qt.black)
  230. QtApplication.getInstance().getController().getView("SolidView").setExtraOverhang(self._draw_buffer)
  231. QtApplication.getInstance().getMainWindow().update() #Force a redraw.
  232. ## Get the current mouse coordinates.
  233. # \return A tuple containing first the X coordinate and then the Y
  234. # coordinate.
  235. def _cursorCoordinates(self) -> Tuple[int, int]:
  236. return QtApplication.getInstance().getMainWindow().mouseX, QtApplication.getInstance().getMainWindow().mouseY