PlotCanvas.py 16 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468
  1. # ##########################################################
  2. # FlatCAM: 2D Post-processing for Manufacturing #
  3. # Author: Dennis Hayrullin (c) #
  4. # Date: 2016 #
  5. # MIT Licence #
  6. # ##########################################################
  7. from PyQt5 import QtCore
  8. import logging
  9. from flatcamGUI.VisPyCanvas import VisPyCanvas, Color
  10. from flatcamGUI.VisPyVisuals import ShapeGroup, ShapeCollection, TextCollection, TextGroup, Cursor
  11. from vispy.scene.visuals import InfiniteLine, Line, Rectangle, Text
  12. import numpy as np
  13. from vispy.geometry import Rect
  14. log = logging.getLogger('base')
  15. class PlotCanvas(QtCore.QObject, VisPyCanvas):
  16. """
  17. Class handling the plotting area in the application.
  18. """
  19. def __init__(self, container, fcapp):
  20. """
  21. The constructor configures the VisPy figure that
  22. will contain all plots, creates the base axes and connects
  23. events to the plotting area.
  24. :param container: The parent container in which to draw plots.
  25. :rtype: PlotCanvas
  26. """
  27. # super(PlotCanvas, self).__init__()
  28. # QtCore.QObject.__init__(self)
  29. # VisPyCanvas.__init__(self)
  30. super().__init__()
  31. # VisPyCanvas does not allow new attributes. Override.
  32. self.unfreeze()
  33. self.fcapp = fcapp
  34. # Parent container
  35. self.container = container
  36. settings = QtCore.QSettings("Open Source", "FlatCAM")
  37. if settings.contains("theme"):
  38. theme = settings.value('theme', type=str)
  39. else:
  40. theme = 'white'
  41. if theme == 'white':
  42. self.line_color = (0.3, 0.0, 0.0, 1.0)
  43. self.rect_hud_color = Color('#0000FF10')
  44. self.text_hud_color = 'black'
  45. else:
  46. self.line_color = (0.4, 0.4, 0.4, 1.0)
  47. self.rect_hud_color = Color('#0000FF10')
  48. self.text_hud_color = 'white'
  49. # workspace lines; I didn't use the rectangle because I didn't want to add another VisPy Node,
  50. # which might decrease performance
  51. # self.b_line, self.r_line, self.t_line, self.l_line = None, None, None, None
  52. self.workspace_line = None
  53. self.pagesize_dict = {}
  54. self.pagesize_dict.update(
  55. {
  56. 'A0': (841, 1189),
  57. 'A1': (594, 841),
  58. 'A2': (420, 594),
  59. 'A3': (297, 420),
  60. 'A4': (210, 297),
  61. 'A5': (148, 210),
  62. 'A6': (105, 148),
  63. 'A7': (74, 105),
  64. 'A8': (52, 74),
  65. 'A9': (37, 52),
  66. 'A10': (26, 37),
  67. 'B0': (1000, 1414),
  68. 'B1': (707, 1000),
  69. 'B2': (500, 707),
  70. 'B3': (353, 500),
  71. 'B4': (250, 353),
  72. 'B5': (176, 250),
  73. 'B6': (125, 176),
  74. 'B7': (88, 125),
  75. 'B8': (62, 88),
  76. 'B9': (44, 62),
  77. 'B10': (31, 44),
  78. 'C0': (917, 1297),
  79. 'C1': (648, 917),
  80. 'C2': (458, 648),
  81. 'C3': (324, 458),
  82. 'C4': (229, 324),
  83. 'C5': (162, 229),
  84. 'C6': (114, 162),
  85. 'C7': (81, 114),
  86. 'C8': (57, 81),
  87. 'C9': (40, 57),
  88. 'C10': (28, 40),
  89. # American paper sizes
  90. 'LETTER': (8.5*25.4, 11*25.4),
  91. 'LEGAL': (8.5*25.4, 14*25.4),
  92. 'ELEVENSEVENTEEN': (11*25.4, 17*25.4),
  93. # From https://en.wikipedia.org/wiki/Paper_size
  94. 'JUNIOR_LEGAL': (5*25.4, 8*25.4),
  95. 'HALF_LETTER': (5.5*25.4, 8*25.4),
  96. 'GOV_LETTER': (8*25.4, 10.5*25.4),
  97. 'GOV_LEGAL': (8.5*25.4, 13*25.4),
  98. 'LEDGER': (17*25.4, 11*25.4),
  99. }
  100. )
  101. # <VisPyCanvas>
  102. self.create_native()
  103. self.native.setParent(self.fcapp.ui)
  104. # <QtCore.QObject>
  105. self.container.addWidget(self.native)
  106. # ## AXIS # ##
  107. self.v_line = InfiniteLine(pos=0, color=(0.70, 0.3, 0.3, 0.8), vertical=True,
  108. parent=self.view.scene)
  109. self.h_line = InfiniteLine(pos=0, color=(0.70, 0.3, 0.3, 0.8), vertical=False,
  110. parent=self.view.scene)
  111. # draw a rectangle made out of 4 lines on the canvas to serve as a hint for the work area
  112. # all CNC have a limited workspace
  113. if self.fcapp.defaults['global_workspace'] is True:
  114. self.draw_workspace(workspace_size=self.fcapp.defaults["global_workspaceT"])
  115. self.line_parent = None
  116. if self.fcapp.defaults["global_cursor_color_enabled"]:
  117. c_color = Color(self.fcapp.defaults["global_cursor_color"]).rgba
  118. else:
  119. c_color = self.line_color
  120. self.cursor_v_line = InfiniteLine(pos=None, color=c_color, vertical=True,
  121. parent=self.line_parent)
  122. self.cursor_h_line = InfiniteLine(pos=None, color=c_color, vertical=False,
  123. parent=self.line_parent)
  124. self.rect_hud = Rectangle(center=(90,45), color=self.rect_hud_color, border_color=self.rect_hud_color,
  125. width=170, height=80, radius=[5, 5, 5, 5], parent=None)
  126. self.rect_hud.set_gl_state(depth_test=False)
  127. # HUD Display
  128. self.hud_enabled = False
  129. self.text_hud = Text('', color=self.text_hud_color, pos=(8, 45), method='gpu', anchor_x='left', parent=None)
  130. self.text_hud.font_size = 8
  131. units = self.fcapp.defaults["units"].lower()
  132. self.text_hud.text = 'Dx:\t%s [%s]\nDy:\t%s [%s]\nX: \t%s [%s]\nY: \t%s [%s]' % \
  133. ('0.0000', units, '0.0000', units, '0.0000', units, '0.0000', units)
  134. if self.fcapp.defaults['global_hud'] is True:
  135. self.on_toggle_hud(state=True)
  136. self.shape_collections = []
  137. self.shape_collection = self.new_shape_collection()
  138. self.fcapp.pool_recreated.connect(self.on_pool_recreated)
  139. self.text_collection = self.new_text_collection()
  140. self.text_collection.enabled = True
  141. self.c = None
  142. self.big_cursor = None
  143. # Keep VisPy canvas happy by letting it be "frozen" again.
  144. self.freeze()
  145. self.fit_view()
  146. self.graph_event_connect('mouse_wheel', self.on_mouse_scroll)
  147. def on_toggle_hud(self, state):
  148. if state:
  149. self.hud_enabled = True
  150. self.rect_hud.parent = self.view
  151. self.text_hud.parent = self.view
  152. else:
  153. self.hud_enabled = False
  154. self.rect_hud.parent = None
  155. self.text_hud.parent = None
  156. def draw_workspace(self, workspace_size):
  157. """
  158. Draw a rectangular shape on canvas to specify our valid workspace.
  159. :param workspace_size: the workspace size; tuple
  160. :return:
  161. """
  162. try:
  163. if self.fcapp.defaults['units'].upper() == 'MM':
  164. dims = self.pagesize_dict[workspace_size]
  165. else:
  166. dims = (self.pagesize_dict[workspace_size][0]/25.4, self.pagesize_dict[workspace_size][1]/25.4)
  167. except Exception as e:
  168. log.debug("PlotCanvas.draw_workspace() --> %s" % str(e))
  169. return
  170. if self.fcapp.defaults['global_workspace_orientation'] == 'l':
  171. dims = (dims[1], dims[0])
  172. a = np.array([(0, 0), (dims[0], 0), (dims[0], dims[1]), (0, dims[1])])
  173. if not self.workspace_line:
  174. self.workspace_line = Line(pos=np.array((a[0], a[1], a[2], a[3], a[0])), color=(0.70, 0.3, 0.3, 0.7),
  175. antialias=True, method='agg', parent=self.view.scene)
  176. else:
  177. self.workspace_line.parent = self.view.scene
  178. def delete_workspace(self):
  179. try:
  180. self.workspace_line.parent = None
  181. except Exception:
  182. pass
  183. # redraw the workspace lines on the plot by re adding them to the parent view.scene
  184. def restore_workspace(self):
  185. try:
  186. self.workspace_line.parent = self.view.scene
  187. except Exception:
  188. pass
  189. def graph_event_connect(self, event_name, callback):
  190. return getattr(self.events, event_name).connect(callback)
  191. def graph_event_disconnect(self, event_name, callback=None):
  192. if callback is None:
  193. getattr(self.events, event_name).disconnect()
  194. else:
  195. getattr(self.events, event_name).disconnect(callback)
  196. def zoom(self, factor, center=None):
  197. """
  198. Zooms the plot by factor around a given
  199. center point. Takes care of re-drawing.
  200. :param factor: Number by which to scale the plot.
  201. :type factor: float
  202. :param center: Coordinates [x, y] of the point around which to scale the plot.
  203. :type center: list
  204. :return: None
  205. """
  206. self.view.camera.zoom(factor, center)
  207. def new_shape_group(self, shape_collection=None):
  208. if shape_collection:
  209. return ShapeGroup(shape_collection)
  210. return ShapeGroup(self.shape_collection)
  211. def new_shape_collection(self, **kwargs):
  212. # sc = ShapeCollection(parent=self.view.scene, pool=self.app.pool, **kwargs)
  213. # self.shape_collections.append(sc)
  214. # return sc
  215. return ShapeCollection(parent=self.view.scene, pool=self.fcapp.pool, **kwargs)
  216. def new_cursor(self, big=None):
  217. """
  218. Will create a mouse cursor pointer on canvas
  219. :param big: if True will create a mouse cursor made out of infinite lines
  220. :return: the mouse cursor object
  221. """
  222. if big is True:
  223. self.big_cursor = True
  224. self.c = CursorBig(app=self.fcapp)
  225. # in case there are multiple new_cursor calls, best to disconnect first the signals
  226. try:
  227. self.c.mouse_state_updated.disconnect(self.on_mouse_state)
  228. except (TypeError, AttributeError):
  229. pass
  230. try:
  231. self.c.mouse_position_updated.disconnect(self.on_mouse_position)
  232. except (TypeError, AttributeError):
  233. pass
  234. self.c.mouse_state_updated.connect(self.on_mouse_state)
  235. self.c.mouse_position_updated.connect(self.on_mouse_position)
  236. else:
  237. self.big_cursor = False
  238. self.c = Cursor(pos=np.empty((0, 2)), parent=self.view.scene)
  239. self.c.antialias = 0
  240. return self.c
  241. def on_mouse_state(self, state):
  242. if state:
  243. self.cursor_h_line.parent = self.view.scene
  244. self.cursor_v_line.parent = self.view.scene
  245. else:
  246. self.cursor_h_line.parent = None
  247. self.cursor_v_line.parent = None
  248. def on_mouse_position(self, pos):
  249. if self.fcapp.defaults['global_cursor_color_enabled']:
  250. color = Color(self.fcapp.defaults['global_cursor_color']).rgba
  251. else:
  252. color = self.line_color
  253. self.cursor_h_line.set_data(pos=pos[1], color=color)
  254. self.cursor_v_line.set_data(pos=pos[0], color=color)
  255. self.view.scene.update()
  256. def on_mouse_scroll(self, event):
  257. # key modifiers
  258. modifiers = event.modifiers
  259. pan_delta_x = self.fcapp.defaults["global_gridx"]
  260. pan_delta_y = self.fcapp.defaults["global_gridy"]
  261. curr_pos = event.pos
  262. # Controlled pan by mouse wheel
  263. if 'Shift' in modifiers:
  264. p1 = np.array(curr_pos)[:2]
  265. if event.delta[1] > 0:
  266. curr_pos[0] -= pan_delta_x
  267. else:
  268. curr_pos[0] += pan_delta_x
  269. p2 = np.array(curr_pos)[:2]
  270. self.view.camera.pan(p2 - p1)
  271. elif 'Control' in modifiers:
  272. p1 = np.array(curr_pos)[:2]
  273. if event.delta[1] > 0:
  274. curr_pos[1] += pan_delta_y
  275. else:
  276. curr_pos[1] -= pan_delta_y
  277. p2 = np.array(curr_pos)[:2]
  278. self.view.camera.pan(p2 - p1)
  279. if self.fcapp.grid_status():
  280. pos_canvas = self.translate_coords(curr_pos)
  281. pos = self.fcapp.geo_editor.snap(pos_canvas[0], pos_canvas[1])
  282. # Update cursor
  283. self.fcapp.app_cursor.set_data(np.asarray([(pos[0], pos[1])]),
  284. symbol='++', edge_color=self.fcapp.cursor_color_3D,
  285. edge_width=self.fcapp.defaults["global_cursor_width"],
  286. size=self.fcapp.defaults["global_cursor_size"])
  287. def new_text_group(self, collection=None):
  288. if collection:
  289. return TextGroup(collection)
  290. else:
  291. return TextGroup(self.text_collection)
  292. def new_text_collection(self, **kwargs):
  293. return TextCollection(parent=self.view.scene, **kwargs)
  294. def fit_view(self, rect=None):
  295. # Lock updates in other threads
  296. self.shape_collection.lock_updates()
  297. if not rect:
  298. rect = Rect(-1, -1, 20, 20)
  299. try:
  300. rect.left, rect.right = self.shape_collection.bounds(axis=0)
  301. rect.bottom, rect.top = self.shape_collection.bounds(axis=1)
  302. except TypeError:
  303. pass
  304. # adjust the view camera to be slightly bigger than the bounds so the shape collection can be seen clearly
  305. # otherwise the shape collection boundary will have no border
  306. dx = rect.right - rect.left
  307. dy = rect.top - rect.bottom
  308. x_factor = dx * 0.02
  309. y_factor = dy * 0.02
  310. rect.left -= x_factor
  311. rect.bottom -= y_factor
  312. rect.right += x_factor
  313. rect.top += y_factor
  314. # rect.left *= 0.96
  315. # rect.bottom *= 0.96
  316. # rect.right *= 1.04
  317. # rect.top *= 1.04
  318. # units = self.fcapp.defaults['units'].upper()
  319. # if units == 'MM':
  320. # compensation = 0.5
  321. # else:
  322. # compensation = 0.5 / 25.4
  323. # rect.left -= compensation
  324. # rect.bottom -= compensation
  325. # rect.right += compensation
  326. # rect.top += compensation
  327. self.view.camera.rect = rect
  328. self.shape_collection.unlock_updates()
  329. def fit_center(self, loc, rect=None):
  330. # Lock updates in other threads
  331. self.shape_collection.lock_updates()
  332. if not rect:
  333. try:
  334. rect = Rect(loc[0]-20, loc[1]-20, 40, 40)
  335. except TypeError:
  336. pass
  337. self.view.camera.rect = rect
  338. self.shape_collection.unlock_updates()
  339. def clear(self):
  340. pass
  341. def redraw(self):
  342. self.shape_collection.redraw([])
  343. self.text_collection.redraw()
  344. def on_pool_recreated(self, pool):
  345. self.shape_collection.pool = pool
  346. class CursorBig(QtCore.QObject):
  347. """
  348. This is a fake cursor to ensure compatibility with the OpenGL engine (VisPy).
  349. This way I don't have to chane (disable) things related to the cursor all over when
  350. using the low performance Matplotlib 2D graphic engine.
  351. """
  352. mouse_state_updated = QtCore.pyqtSignal(bool)
  353. mouse_position_updated = QtCore.pyqtSignal(list)
  354. def __init__(self, app):
  355. super().__init__()
  356. self.app = app
  357. self._enabled = None
  358. @property
  359. def enabled(self):
  360. return True if self._enabled else False
  361. @enabled.setter
  362. def enabled(self, value):
  363. self._enabled = value
  364. self.mouse_state_updated.emit(value)
  365. def set_data(self, pos, **kwargs):
  366. """Internal event handler to draw the cursor when the mouse moves."""
  367. if 'edge_color' in kwargs:
  368. color = kwargs['edge_color']
  369. else:
  370. if self.app.defaults['global_theme'] == 'white':
  371. color = '#000000FF'
  372. else:
  373. color = '#FFFFFFFF'
  374. position = [pos[0][0], pos[0][1]]
  375. self.mouse_position_updated.emit(position)