PlotCanvas.py 16 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472
  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('#80808040')
  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=(95,50), color=self.rect_hud_color, border_color=self.rect_hud_color,
  125. width=180, height=90, 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=(10, 50), 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]\n\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. self.fcapp.defaults['global_hud'] = True
  153. else:
  154. self.hud_enabled = False
  155. self.rect_hud.parent = None
  156. self.text_hud.parent = None
  157. self.fcapp.defaults['global_hud'] = False
  158. def draw_workspace(self, workspace_size):
  159. """
  160. Draw a rectangular shape on canvas to specify our valid workspace.
  161. :param workspace_size: the workspace size; tuple
  162. :return:
  163. """
  164. try:
  165. if self.fcapp.defaults['units'].upper() == 'MM':
  166. dims = self.pagesize_dict[workspace_size]
  167. else:
  168. dims = (self.pagesize_dict[workspace_size][0]/25.4, self.pagesize_dict[workspace_size][1]/25.4)
  169. except Exception as e:
  170. log.debug("PlotCanvas.draw_workspace() --> %s" % str(e))
  171. return
  172. if self.fcapp.defaults['global_workspace_orientation'] == 'l':
  173. dims = (dims[1], dims[0])
  174. a = np.array([(0, 0), (dims[0], 0), (dims[0], dims[1]), (0, dims[1])])
  175. if not self.workspace_line:
  176. 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),
  177. antialias=True, method='agg', parent=self.view.scene)
  178. else:
  179. self.workspace_line.parent = self.view.scene
  180. def delete_workspace(self):
  181. try:
  182. self.workspace_line.parent = None
  183. except Exception:
  184. pass
  185. # redraw the workspace lines on the plot by re adding them to the parent view.scene
  186. def restore_workspace(self):
  187. try:
  188. self.workspace_line.parent = self.view.scene
  189. except Exception:
  190. pass
  191. def graph_event_connect(self, event_name, callback):
  192. return getattr(self.events, event_name).connect(callback)
  193. def graph_event_disconnect(self, event_name, callback=None):
  194. if callback is None:
  195. getattr(self.events, event_name).disconnect()
  196. else:
  197. getattr(self.events, event_name).disconnect(callback)
  198. def zoom(self, factor, center=None):
  199. """
  200. Zooms the plot by factor around a given
  201. center point. Takes care of re-drawing.
  202. :param factor: Number by which to scale the plot.
  203. :type factor: float
  204. :param center: Coordinates [x, y] of the point around which to scale the plot.
  205. :type center: list
  206. :return: None
  207. """
  208. self.view.camera.zoom(factor, center)
  209. def new_shape_group(self, shape_collection=None):
  210. if shape_collection:
  211. return ShapeGroup(shape_collection)
  212. return ShapeGroup(self.shape_collection)
  213. def new_shape_collection(self, **kwargs):
  214. # sc = ShapeCollection(parent=self.view.scene, pool=self.app.pool, **kwargs)
  215. # self.shape_collections.append(sc)
  216. # return sc
  217. return ShapeCollection(parent=self.view.scene, pool=self.fcapp.pool, **kwargs)
  218. def new_cursor(self, big=None):
  219. """
  220. Will create a mouse cursor pointer on canvas
  221. :param big: if True will create a mouse cursor made out of infinite lines
  222. :return: the mouse cursor object
  223. """
  224. if big is True:
  225. self.big_cursor = True
  226. self.c = CursorBig(app=self.fcapp)
  227. # in case there are multiple new_cursor calls, best to disconnect first the signals
  228. try:
  229. self.c.mouse_state_updated.disconnect(self.on_mouse_state)
  230. except (TypeError, AttributeError):
  231. pass
  232. try:
  233. self.c.mouse_position_updated.disconnect(self.on_mouse_position)
  234. except (TypeError, AttributeError):
  235. pass
  236. self.c.mouse_state_updated.connect(self.on_mouse_state)
  237. self.c.mouse_position_updated.connect(self.on_mouse_position)
  238. else:
  239. self.big_cursor = False
  240. self.c = Cursor(pos=np.empty((0, 2)), parent=self.view.scene)
  241. self.c.antialias = 0
  242. return self.c
  243. def on_mouse_state(self, state):
  244. if state:
  245. self.cursor_h_line.parent = self.view.scene
  246. self.cursor_v_line.parent = self.view.scene
  247. else:
  248. self.cursor_h_line.parent = None
  249. self.cursor_v_line.parent = None
  250. def on_mouse_position(self, pos):
  251. if self.fcapp.defaults['global_cursor_color_enabled']:
  252. color = Color(self.fcapp.defaults['global_cursor_color']).rgba
  253. else:
  254. color = self.line_color
  255. self.cursor_h_line.set_data(pos=pos[1], color=color)
  256. self.cursor_v_line.set_data(pos=pos[0], color=color)
  257. self.view.scene.update()
  258. def on_mouse_scroll(self, event):
  259. # key modifiers
  260. modifiers = event.modifiers
  261. pan_delta_x = self.fcapp.defaults["global_gridx"]
  262. pan_delta_y = self.fcapp.defaults["global_gridy"]
  263. curr_pos = event.pos
  264. # Controlled pan by mouse wheel
  265. if 'Shift' in modifiers:
  266. p1 = np.array(curr_pos)[:2]
  267. if event.delta[1] > 0:
  268. curr_pos[0] -= pan_delta_x
  269. else:
  270. curr_pos[0] += pan_delta_x
  271. p2 = np.array(curr_pos)[:2]
  272. self.view.camera.pan(p2 - p1)
  273. elif 'Control' in modifiers:
  274. p1 = np.array(curr_pos)[:2]
  275. if event.delta[1] > 0:
  276. curr_pos[1] += pan_delta_y
  277. else:
  278. curr_pos[1] -= pan_delta_y
  279. p2 = np.array(curr_pos)[:2]
  280. self.view.camera.pan(p2 - p1)
  281. if self.fcapp.grid_status():
  282. pos_canvas = self.translate_coords(curr_pos)
  283. pos = self.fcapp.geo_editor.snap(pos_canvas[0], pos_canvas[1])
  284. # Update cursor
  285. self.fcapp.app_cursor.set_data(np.asarray([(pos[0], pos[1])]),
  286. symbol='++', edge_color=self.fcapp.cursor_color_3D,
  287. edge_width=self.fcapp.defaults["global_cursor_width"],
  288. size=self.fcapp.defaults["global_cursor_size"])
  289. def new_text_group(self, collection=None):
  290. if collection:
  291. return TextGroup(collection)
  292. else:
  293. return TextGroup(self.text_collection)
  294. def new_text_collection(self, **kwargs):
  295. return TextCollection(parent=self.view.scene, **kwargs)
  296. def fit_view(self, rect=None):
  297. # Lock updates in other threads
  298. self.shape_collection.lock_updates()
  299. if not rect:
  300. rect = Rect(-1, -1, 20, 20)
  301. try:
  302. rect.left, rect.right = self.shape_collection.bounds(axis=0)
  303. rect.bottom, rect.top = self.shape_collection.bounds(axis=1)
  304. except TypeError:
  305. pass
  306. # adjust the view camera to be slightly bigger than the bounds so the shape collection can be seen clearly
  307. # otherwise the shape collection boundary will have no border
  308. dx = rect.right - rect.left
  309. dy = rect.top - rect.bottom
  310. x_factor = dx * 0.02
  311. y_factor = dy * 0.02
  312. rect.left -= x_factor
  313. rect.bottom -= y_factor
  314. rect.right += x_factor
  315. rect.top += y_factor
  316. # rect.left *= 0.96
  317. # rect.bottom *= 0.96
  318. # rect.right *= 1.04
  319. # rect.top *= 1.04
  320. # units = self.fcapp.defaults['units'].upper()
  321. # if units == 'MM':
  322. # compensation = 0.5
  323. # else:
  324. # compensation = 0.5 / 25.4
  325. # rect.left -= compensation
  326. # rect.bottom -= compensation
  327. # rect.right += compensation
  328. # rect.top += compensation
  329. self.view.camera.rect = rect
  330. self.shape_collection.unlock_updates()
  331. def fit_center(self, loc, rect=None):
  332. # Lock updates in other threads
  333. self.shape_collection.lock_updates()
  334. if not rect:
  335. try:
  336. rect = Rect(loc[0]-20, loc[1]-20, 40, 40)
  337. except TypeError:
  338. pass
  339. self.view.camera.rect = rect
  340. self.shape_collection.unlock_updates()
  341. def clear(self):
  342. pass
  343. def redraw(self):
  344. self.shape_collection.redraw([])
  345. self.text_collection.redraw()
  346. def on_pool_recreated(self, pool):
  347. self.shape_collection.pool = pool
  348. class CursorBig(QtCore.QObject):
  349. """
  350. This is a fake cursor to ensure compatibility with the OpenGL engine (VisPy).
  351. This way I don't have to chane (disable) things related to the cursor all over when
  352. using the low performance Matplotlib 2D graphic engine.
  353. """
  354. mouse_state_updated = QtCore.pyqtSignal(bool)
  355. mouse_position_updated = QtCore.pyqtSignal(list)
  356. def __init__(self, app):
  357. super().__init__()
  358. self.app = app
  359. self._enabled = None
  360. @property
  361. def enabled(self):
  362. return True if self._enabled else False
  363. @enabled.setter
  364. def enabled(self, value):
  365. self._enabled = value
  366. self.mouse_state_updated.emit(value)
  367. def set_data(self, pos, **kwargs):
  368. """Internal event handler to draw the cursor when the mouse moves."""
  369. if 'edge_color' in kwargs:
  370. color = kwargs['edge_color']
  371. else:
  372. if self.app.defaults['global_theme'] == 'white':
  373. color = '#000000FF'
  374. else:
  375. color = '#FFFFFFFF'
  376. position = [pos[0][0], pos[0][1]]
  377. self.mouse_position_updated.emit(position)