PlotCanvas.py 18 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512
  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 AppGUI.VisPyCanvas import VisPyCanvas, Color
  10. from AppGUI.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. # HUD Display
  125. self.hud_enabled = False
  126. # font size
  127. qsettings = QtCore.QSettings("Open Source", "FlatCAM")
  128. if qsettings.contains("hud_font_size"):
  129. fsize = qsettings.value('hud_font_size', type=int)
  130. else:
  131. fsize = 8
  132. # units
  133. units = self.fcapp.defaults["units"].lower()
  134. # coordinates and anchors
  135. height = fsize * 11 # 90. Constant 11 is something that works
  136. width = height * 2 # width is double the height = it is something that works
  137. center_x = (width / 2) + 5
  138. center_y = (height / 2) + 5
  139. # text
  140. self.text_hud = Text('', color=self.text_hud_color, pos=(10, center_y), method='gpu', anchor_x='left',
  141. parent=None)
  142. self.text_hud.font_size = fsize
  143. self.text_hud.text = 'Dx:\t%s [%s]\nDy:\t%s [%s]\n\nX: \t%s [%s]\nY: \t%s [%s]' % \
  144. ('0.0000', units, '0.0000', units, '0.0000', units, '0.0000', units)
  145. # rectangle
  146. self.rect_hud = Rectangle(center=(center_x, center_y), width=width, height=height, radius=[5, 5, 5, 5],
  147. border_color=self.rect_hud_color, color=self.rect_hud_color, parent=None)
  148. self.rect_hud.set_gl_state(depth_test=False)
  149. # enable the HUD if it is activated in FlatCAM Preferences
  150. if self.fcapp.defaults['global_hud'] is True:
  151. self.on_toggle_hud(state=True)
  152. self.shape_collections = []
  153. self.shape_collection = self.new_shape_collection()
  154. self.fcapp.pool_recreated.connect(self.on_pool_recreated)
  155. self.text_collection = self.new_text_collection()
  156. self.text_collection.enabled = True
  157. self.c = None
  158. self.big_cursor = None
  159. # Keep VisPy canvas happy by letting it be "frozen" again.
  160. self.freeze()
  161. self.fit_view()
  162. self.graph_event_connect('mouse_wheel', self.on_mouse_scroll)
  163. def on_toggle_hud(self, state):
  164. if state:
  165. self.hud_enabled = True
  166. self.rect_hud.parent = self.view
  167. self.text_hud.parent = self.view
  168. self.fcapp.defaults['global_hud'] = True
  169. self.fcapp.ui.hud_label.setStyleSheet("""
  170. QLabel
  171. {
  172. color: black;
  173. background-color: lightblue;
  174. }
  175. """)
  176. else:
  177. self.hud_enabled = False
  178. self.rect_hud.parent = None
  179. self.text_hud.parent = None
  180. self.fcapp.defaults['global_hud'] = False
  181. self.fcapp.ui.hud_label.setStyleSheet("")
  182. def draw_workspace(self, workspace_size):
  183. """
  184. Draw a rectangular shape on canvas to specify our valid workspace.
  185. :param workspace_size: the workspace size; tuple
  186. :return:
  187. """
  188. try:
  189. if self.fcapp.defaults['units'].upper() == 'MM':
  190. dims = self.pagesize_dict[workspace_size]
  191. else:
  192. dims = (self.pagesize_dict[workspace_size][0]/25.4, self.pagesize_dict[workspace_size][1]/25.4)
  193. except Exception as e:
  194. log.debug("PlotCanvas.draw_workspace() --> %s" % str(e))
  195. return
  196. if self.fcapp.defaults['global_workspace_orientation'] == 'l':
  197. dims = (dims[1], dims[0])
  198. a = np.array([(0, 0), (dims[0], 0), (dims[0], dims[1]), (0, dims[1])])
  199. # if not self.workspace_line:
  200. # 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),
  201. # antialias=True, method='agg', parent=self.view.scene)
  202. # else:
  203. # self.workspace_line.parent = self.view.scene
  204. 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),
  205. antialias=True, method='agg', parent=self.view.scene)
  206. self.fcapp.ui.wplace_label.set_value(workspace_size[:3])
  207. self.fcapp.ui.wplace_label.setToolTip(workspace_size)
  208. self.fcapp.ui.wplace_label.setStyleSheet("""
  209. QLabel
  210. {
  211. color: black;
  212. background-color: lightgreen;
  213. }
  214. """)
  215. def delete_workspace(self):
  216. try:
  217. self.workspace_line.parent = None
  218. except Exception:
  219. pass
  220. self.fcapp.ui.wplace_label.setStyleSheet("")
  221. # redraw the workspace lines on the plot by re adding them to the parent view.scene
  222. def restore_workspace(self):
  223. try:
  224. self.workspace_line.parent = self.view.scene
  225. except Exception:
  226. pass
  227. def graph_event_connect(self, event_name, callback):
  228. return getattr(self.events, event_name).connect(callback)
  229. def graph_event_disconnect(self, event_name, callback=None):
  230. if callback is None:
  231. getattr(self.events, event_name).disconnect()
  232. else:
  233. getattr(self.events, event_name).disconnect(callback)
  234. def zoom(self, factor, center=None):
  235. """
  236. Zooms the plot by factor around a given
  237. center point. Takes care of re-drawing.
  238. :param factor: Number by which to scale the plot.
  239. :type factor: float
  240. :param center: Coordinates [x, y] of the point around which to scale the plot.
  241. :type center: list
  242. :return: None
  243. """
  244. self.view.camera.zoom(factor, center)
  245. def new_shape_group(self, shape_collection=None):
  246. if shape_collection:
  247. return ShapeGroup(shape_collection)
  248. return ShapeGroup(self.shape_collection)
  249. def new_shape_collection(self, **kwargs):
  250. # sc = ShapeCollection(parent=self.view.scene, pool=self.app.pool, **kwargs)
  251. # self.shape_collections.append(sc)
  252. # return sc
  253. return ShapeCollection(parent=self.view.scene, pool=self.fcapp.pool, **kwargs)
  254. def new_cursor(self, big=None):
  255. """
  256. Will create a mouse cursor pointer on canvas
  257. :param big: if True will create a mouse cursor made out of infinite lines
  258. :return: the mouse cursor object
  259. """
  260. if big is True:
  261. self.big_cursor = True
  262. self.c = CursorBig(app=self.fcapp)
  263. # in case there are multiple new_cursor calls, best to disconnect first the signals
  264. try:
  265. self.c.mouse_state_updated.disconnect(self.on_mouse_state)
  266. except (TypeError, AttributeError):
  267. pass
  268. try:
  269. self.c.mouse_position_updated.disconnect(self.on_mouse_position)
  270. except (TypeError, AttributeError):
  271. pass
  272. self.c.mouse_state_updated.connect(self.on_mouse_state)
  273. self.c.mouse_position_updated.connect(self.on_mouse_position)
  274. else:
  275. self.big_cursor = False
  276. self.c = Cursor(pos=np.empty((0, 2)), parent=self.view.scene)
  277. self.c.antialias = 0
  278. return self.c
  279. def on_mouse_state(self, state):
  280. if state:
  281. self.cursor_h_line.parent = self.view.scene
  282. self.cursor_v_line.parent = self.view.scene
  283. else:
  284. self.cursor_h_line.parent = None
  285. self.cursor_v_line.parent = None
  286. def on_mouse_position(self, pos):
  287. if self.fcapp.defaults['global_cursor_color_enabled']:
  288. color = Color(self.fcapp.defaults['global_cursor_color']).rgba
  289. else:
  290. color = self.line_color
  291. self.cursor_h_line.set_data(pos=pos[1], color=color)
  292. self.cursor_v_line.set_data(pos=pos[0], color=color)
  293. self.view.scene.update()
  294. def on_mouse_scroll(self, event):
  295. # key modifiers
  296. modifiers = event.modifiers
  297. pan_delta_x = self.fcapp.defaults["global_gridx"]
  298. pan_delta_y = self.fcapp.defaults["global_gridy"]
  299. curr_pos = event.pos
  300. # Controlled pan by mouse wheel
  301. if 'Shift' in modifiers:
  302. p1 = np.array(curr_pos)[:2]
  303. if event.delta[1] > 0:
  304. curr_pos[0] -= pan_delta_x
  305. else:
  306. curr_pos[0] += pan_delta_x
  307. p2 = np.array(curr_pos)[:2]
  308. self.view.camera.pan(p2 - p1)
  309. elif 'Control' in modifiers:
  310. p1 = np.array(curr_pos)[:2]
  311. if event.delta[1] > 0:
  312. curr_pos[1] += pan_delta_y
  313. else:
  314. curr_pos[1] -= pan_delta_y
  315. p2 = np.array(curr_pos)[:2]
  316. self.view.camera.pan(p2 - p1)
  317. if self.fcapp.grid_status():
  318. pos_canvas = self.translate_coords(curr_pos)
  319. pos = self.fcapp.geo_editor.snap(pos_canvas[0], pos_canvas[1])
  320. # Update cursor
  321. self.fcapp.app_cursor.set_data(np.asarray([(pos[0], pos[1])]),
  322. symbol='++', edge_color=self.fcapp.cursor_color_3D,
  323. edge_width=self.fcapp.defaults["global_cursor_width"],
  324. size=self.fcapp.defaults["global_cursor_size"])
  325. def new_text_group(self, collection=None):
  326. if collection:
  327. return TextGroup(collection)
  328. else:
  329. return TextGroup(self.text_collection)
  330. def new_text_collection(self, **kwargs):
  331. return TextCollection(parent=self.view.scene, **kwargs)
  332. def fit_view(self, rect=None):
  333. # Lock updates in other threads
  334. self.shape_collection.lock_updates()
  335. if not rect:
  336. rect = Rect(-1, -1, 20, 20)
  337. try:
  338. rect.left, rect.right = self.shape_collection.bounds(axis=0)
  339. rect.bottom, rect.top = self.shape_collection.bounds(axis=1)
  340. except TypeError:
  341. pass
  342. # adjust the view camera to be slightly bigger than the bounds so the shape collection can be seen clearly
  343. # otherwise the shape collection boundary will have no border
  344. dx = rect.right - rect.left
  345. dy = rect.top - rect.bottom
  346. x_factor = dx * 0.02
  347. y_factor = dy * 0.02
  348. rect.left -= x_factor
  349. rect.bottom -= y_factor
  350. rect.right += x_factor
  351. rect.top += y_factor
  352. # rect.left *= 0.96
  353. # rect.bottom *= 0.96
  354. # rect.right *= 1.04
  355. # rect.top *= 1.04
  356. # units = self.fcapp.defaults['units'].upper()
  357. # if units == 'MM':
  358. # compensation = 0.5
  359. # else:
  360. # compensation = 0.5 / 25.4
  361. # rect.left -= compensation
  362. # rect.bottom -= compensation
  363. # rect.right += compensation
  364. # rect.top += compensation
  365. self.view.camera.rect = rect
  366. self.shape_collection.unlock_updates()
  367. def fit_center(self, loc, rect=None):
  368. # Lock updates in other threads
  369. self.shape_collection.lock_updates()
  370. if not rect:
  371. try:
  372. rect = Rect(loc[0]-20, loc[1]-20, 40, 40)
  373. except TypeError:
  374. pass
  375. self.view.camera.rect = rect
  376. self.shape_collection.unlock_updates()
  377. def clear(self):
  378. pass
  379. def redraw(self):
  380. self.shape_collection.redraw([])
  381. self.text_collection.redraw()
  382. def on_pool_recreated(self, pool):
  383. self.shape_collection.pool = pool
  384. class CursorBig(QtCore.QObject):
  385. """
  386. This is a fake cursor to ensure compatibility with the OpenGL engine (VisPy).
  387. This way I don't have to chane (disable) things related to the cursor all over when
  388. using the low performance Matplotlib 2D graphic engine.
  389. """
  390. mouse_state_updated = QtCore.pyqtSignal(bool)
  391. mouse_position_updated = QtCore.pyqtSignal(list)
  392. def __init__(self, app):
  393. super().__init__()
  394. self.app = app
  395. self._enabled = None
  396. @property
  397. def enabled(self):
  398. return True if self._enabled else False
  399. @enabled.setter
  400. def enabled(self, value):
  401. self._enabled = value
  402. self.mouse_state_updated.emit(value)
  403. def set_data(self, pos, **kwargs):
  404. """Internal event handler to draw the cursor when the mouse moves."""
  405. if 'edge_color' in kwargs:
  406. color = kwargs['edge_color']
  407. else:
  408. if self.app.defaults['global_theme'] == 'white':
  409. color = '#000000FF'
  410. else:
  411. color = '#FFFFFFFF'
  412. position = [pos[0][0], pos[0][1]]
  413. self.mouse_position_updated.emit(position)