PlotCanvas.py 21 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587
  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 gettext
  13. import appTranslation as fcTranslate
  14. import builtins
  15. import numpy as np
  16. from vispy.geometry import Rect
  17. fcTranslate.apply_language('strings')
  18. if '_' not in builtins.__dict__:
  19. _ = gettext.gettext
  20. log = logging.getLogger('base')
  21. class PlotCanvas(QtCore.QObject, VisPyCanvas):
  22. """
  23. Class handling the plotting area in the application.
  24. """
  25. def __init__(self, container, fcapp):
  26. """
  27. The constructor configures the VisPy figure that
  28. will contain all plots, creates the base axes and connects
  29. events to the plotting area.
  30. :param container: The parent container in which to draw plots.
  31. :rtype: PlotCanvas
  32. """
  33. # super(PlotCanvas, self).__init__()
  34. # QtCore.QObject.__init__(self)
  35. # VisPyCanvas.__init__(self)
  36. super().__init__()
  37. # VisPyCanvas does not allow new attributes. Override.
  38. self.unfreeze()
  39. self.fcapp = fcapp
  40. # Parent container
  41. self.container = container
  42. settings = QtCore.QSettings("Open Source", "FlatCAM")
  43. if settings.contains("theme"):
  44. theme = settings.value('theme', type=str)
  45. else:
  46. theme = 'white'
  47. if theme == 'white':
  48. self.line_color = (0.3, 0.0, 0.0, 1.0)
  49. self.rect_hud_color = Color('#0000FF10')
  50. self.text_hud_color = 'black'
  51. else:
  52. self.line_color = (0.4, 0.4, 0.4, 1.0)
  53. self.rect_hud_color = Color('#80808040')
  54. self.text_hud_color = 'white'
  55. # workspace lines; I didn't use the rectangle because I didn't want to add another VisPy Node,
  56. # which might decrease performance
  57. # self.b_line, self.r_line, self.t_line, self.l_line = None, None, None, None
  58. self.workspace_line = None
  59. self.pagesize_dict = {}
  60. self.pagesize_dict.update(
  61. {
  62. 'A0': (841, 1189),
  63. 'A1': (594, 841),
  64. 'A2': (420, 594),
  65. 'A3': (297, 420),
  66. 'A4': (210, 297),
  67. 'A5': (148, 210),
  68. 'A6': (105, 148),
  69. 'A7': (74, 105),
  70. 'A8': (52, 74),
  71. 'A9': (37, 52),
  72. 'A10': (26, 37),
  73. 'B0': (1000, 1414),
  74. 'B1': (707, 1000),
  75. 'B2': (500, 707),
  76. 'B3': (353, 500),
  77. 'B4': (250, 353),
  78. 'B5': (176, 250),
  79. 'B6': (125, 176),
  80. 'B7': (88, 125),
  81. 'B8': (62, 88),
  82. 'B9': (44, 62),
  83. 'B10': (31, 44),
  84. 'C0': (917, 1297),
  85. 'C1': (648, 917),
  86. 'C2': (458, 648),
  87. 'C3': (324, 458),
  88. 'C4': (229, 324),
  89. 'C5': (162, 229),
  90. 'C6': (114, 162),
  91. 'C7': (81, 114),
  92. 'C8': (57, 81),
  93. 'C9': (40, 57),
  94. 'C10': (28, 40),
  95. # American paper sizes
  96. 'LETTER': (8.5*25.4, 11*25.4),
  97. 'LEGAL': (8.5*25.4, 14*25.4),
  98. 'ELEVENSEVENTEEN': (11*25.4, 17*25.4),
  99. # From https://en.wikipedia.org/wiki/Paper_size
  100. 'JUNIOR_LEGAL': (5*25.4, 8*25.4),
  101. 'HALF_LETTER': (5.5*25.4, 8*25.4),
  102. 'GOV_LETTER': (8*25.4, 10.5*25.4),
  103. 'GOV_LEGAL': (8.5*25.4, 13*25.4),
  104. 'LEDGER': (17*25.4, 11*25.4),
  105. }
  106. )
  107. # <VisPyCanvas>
  108. self.create_native()
  109. self.native.setParent(self.fcapp.ui)
  110. # <QtCore.QObject>
  111. self.container.addWidget(self.native)
  112. # ## AXIS # ##
  113. self.v_line = InfiniteLine(pos=0, color=(0.70, 0.3, 0.3, 0.8), vertical=True,
  114. parent=self.view.scene)
  115. self.h_line = InfiniteLine(pos=0, color=(0.70, 0.3, 0.3, 0.8), vertical=False,
  116. parent=self.view.scene)
  117. self.line_parent = None
  118. if self.fcapp.defaults["global_cursor_color_enabled"]:
  119. c_color = Color(self.fcapp.defaults["global_cursor_color"]).rgba
  120. else:
  121. c_color = self.line_color
  122. self.cursor_v_line = InfiniteLine(pos=None, color=c_color, vertical=True,
  123. parent=self.line_parent)
  124. self.cursor_h_line = InfiniteLine(pos=None, color=c_color, vertical=False,
  125. parent=self.line_parent)
  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. # draw a rectangle made out of 4 lines on the canvas to serve as a hint for the work area
  150. # all CNC have a limited workspace
  151. if self.fcapp.defaults['global_workspace'] is True:
  152. self.draw_workspace(workspace_size=self.fcapp.defaults["global_workspaceT"])
  153. # HUD Display
  154. self.hud_enabled = False
  155. # enable the HUD if it is activated in FlatCAM Preferences
  156. if self.fcapp.defaults['global_hud'] is True:
  157. self.on_toggle_hud(state=True, silent=True)
  158. # Axis Display
  159. self.axis_enabled = True
  160. # enable Axis
  161. self.on_toggle_axis(state=True, silent=True)
  162. # enable Grid lines
  163. self.grid_lines_enabled = True
  164. self.shape_collections = []
  165. self.shape_collection = self.new_shape_collection()
  166. self.fcapp.pool_recreated.connect(self.on_pool_recreated)
  167. self.text_collection = self.new_text_collection()
  168. self.text_collection.enabled = True
  169. self.c = None
  170. self.big_cursor = None
  171. # Keep VisPy canvas happy by letting it be "frozen" again.
  172. self.freeze()
  173. self.fit_view()
  174. self.graph_event_connect('mouse_wheel', self.on_mouse_scroll)
  175. def on_toggle_axis(self, signal=None, state=None, silent=None):
  176. if not state:
  177. state = not self.axis_enabled
  178. if state:
  179. self.axis_enabled = True
  180. self.fcapp.defaults['global_axis'] = True
  181. self.v_line.parent = self.view.scene
  182. self.h_line.parent = self.view.scene
  183. self.fcapp.ui.axis_status_label.setStyleSheet("""
  184. QLabel
  185. {
  186. color: black;
  187. background-color: orange;
  188. }
  189. """)
  190. if silent is None:
  191. self.fcapp.inform[str, bool].emit(_("Axis enabled."), False)
  192. else:
  193. self.axis_enabled = False
  194. self.fcapp.defaults['global_axis'] = False
  195. self.v_line.parent = None
  196. self.h_line.parent = None
  197. self.fcapp.ui.axis_status_label.setStyleSheet("")
  198. if silent is None:
  199. self.fcapp.inform[str, bool].emit(_("Axis disabled."), False)
  200. def on_toggle_hud(self, signal=None, state=None, silent=None):
  201. if state is None:
  202. state = not self.hud_enabled
  203. if state:
  204. self.hud_enabled = True
  205. self.rect_hud.parent = self.view
  206. self.text_hud.parent = self.view
  207. self.fcapp.defaults['global_hud'] = True
  208. self.fcapp.ui.hud_label.setStyleSheet("""
  209. QLabel
  210. {
  211. color: black;
  212. background-color: mediumpurple;
  213. }
  214. """)
  215. if silent is None:
  216. self.fcapp.inform[str, bool].emit(_("HUD enabled."), False)
  217. else:
  218. self.hud_enabled = False
  219. self.rect_hud.parent = None
  220. self.text_hud.parent = None
  221. self.fcapp.defaults['global_hud'] = False
  222. self.fcapp.ui.hud_label.setStyleSheet("")
  223. if silent is None:
  224. self.fcapp.inform[str, bool].emit(_("HUD disabled."), False)
  225. def on_toggle_grid_lines(self, signal=None, silent=None):
  226. state = not self.grid_lines_enabled
  227. if state:
  228. self.fcapp.defaults['global_grid_lines'] = True
  229. self.grid_lines_enabled = True
  230. self.grid.parent = self.view.scene
  231. if silent is None:
  232. self.fcapp.inform[str, bool].emit(_("Grid enabled."), False)
  233. else:
  234. self.fcapp.defaults['global_grid_lines'] = False
  235. self.grid_lines_enabled = False
  236. self.grid.parent = None
  237. if silent is None:
  238. self.fcapp.inform[str, bool].emit(_("Grid disabled."), False)
  239. # HACK: enabling/disabling the cursor seams to somehow update the shapes on screen
  240. # - perhaps is a bug in VisPy implementation
  241. if self.fcapp.grid_status():
  242. self.fcapp.app_cursor.enabled = False
  243. self.fcapp.app_cursor.enabled = True
  244. else:
  245. self.fcapp.app_cursor.enabled = True
  246. self.fcapp.app_cursor.enabled = False
  247. def draw_workspace(self, workspace_size):
  248. """
  249. Draw a rectangular shape on canvas to specify our valid workspace.
  250. :param workspace_size: the workspace size; tuple
  251. :return:
  252. """
  253. try:
  254. if self.fcapp.defaults['units'].upper() == 'MM':
  255. dims = self.pagesize_dict[workspace_size]
  256. else:
  257. dims = (self.pagesize_dict[workspace_size][0]/25.4, self.pagesize_dict[workspace_size][1]/25.4)
  258. except Exception as e:
  259. log.debug("PlotCanvas.draw_workspace() --> %s" % str(e))
  260. return
  261. if self.fcapp.defaults['global_workspace_orientation'] == 'l':
  262. dims = (dims[1], dims[0])
  263. a = np.array([(0, 0), (dims[0], 0), (dims[0], dims[1]), (0, dims[1])])
  264. # if not self.workspace_line:
  265. # 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),
  266. # antialias=True, method='agg', parent=self.view.scene)
  267. # else:
  268. # self.workspace_line.parent = self.view.scene
  269. 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),
  270. antialias=True, method='agg', parent=self.view.scene)
  271. self.fcapp.ui.wplace_label.set_value(workspace_size[:3])
  272. self.fcapp.ui.wplace_label.setToolTip(workspace_size)
  273. self.fcapp.ui.wplace_label.setStyleSheet("""
  274. QLabel
  275. {
  276. color: black;
  277. background-color: olivedrab;
  278. }
  279. """)
  280. def delete_workspace(self):
  281. try:
  282. self.workspace_line.parent = None
  283. except Exception:
  284. pass
  285. self.fcapp.ui.wplace_label.setStyleSheet("")
  286. # redraw the workspace lines on the plot by re adding them to the parent view.scene
  287. def restore_workspace(self):
  288. try:
  289. self.workspace_line.parent = self.view.scene
  290. except Exception:
  291. pass
  292. def graph_event_connect(self, event_name, callback):
  293. return getattr(self.events, event_name).connect(callback)
  294. def graph_event_disconnect(self, event_name, callback=None):
  295. if callback is None:
  296. getattr(self.events, event_name).disconnect()
  297. else:
  298. getattr(self.events, event_name).disconnect(callback)
  299. def zoom(self, factor, center=None):
  300. """
  301. Zooms the plot by factor around a given
  302. center point. Takes care of re-drawing.
  303. :param factor: Number by which to scale the plot.
  304. :type factor: float
  305. :param center: Coordinates [x, y] of the point around which to scale the plot.
  306. :type center: list
  307. :return: None
  308. """
  309. self.view.camera.zoom(factor, center)
  310. def new_shape_group(self, shape_collection=None):
  311. if shape_collection:
  312. return ShapeGroup(shape_collection)
  313. return ShapeGroup(self.shape_collection)
  314. def new_shape_collection(self, **kwargs):
  315. # sc = ShapeCollection(parent=self.view.scene, pool=self.app.pool, **kwargs)
  316. # self.shape_collections.append(sc)
  317. # return sc
  318. return ShapeCollection(parent=self.view.scene, pool=self.fcapp.pool, **kwargs)
  319. def new_cursor(self, big=None):
  320. """
  321. Will create a mouse cursor pointer on canvas
  322. :param big: if True will create a mouse cursor made out of infinite lines
  323. :return: the mouse cursor object
  324. """
  325. if big is True:
  326. self.big_cursor = True
  327. self.c = CursorBig(app=self.fcapp)
  328. # in case there are multiple new_cursor calls, best to disconnect first the signals
  329. try:
  330. self.c.mouse_state_updated.disconnect(self.on_mouse_state)
  331. except (TypeError, AttributeError):
  332. pass
  333. try:
  334. self.c.mouse_position_updated.disconnect(self.on_mouse_position)
  335. except (TypeError, AttributeError):
  336. pass
  337. self.c.mouse_state_updated.connect(self.on_mouse_state)
  338. self.c.mouse_position_updated.connect(self.on_mouse_position)
  339. else:
  340. self.big_cursor = False
  341. self.c = Cursor(pos=np.empty((0, 2)), parent=self.view.scene)
  342. self.c.antialias = 0
  343. return self.c
  344. def on_mouse_state(self, state):
  345. if state:
  346. self.cursor_h_line.parent = self.view.scene
  347. self.cursor_v_line.parent = self.view.scene
  348. else:
  349. self.cursor_h_line.parent = None
  350. self.cursor_v_line.parent = None
  351. def on_mouse_position(self, pos):
  352. if self.fcapp.defaults['global_cursor_color_enabled']:
  353. color = Color(self.fcapp.defaults['global_cursor_color']).rgba
  354. else:
  355. color = self.line_color
  356. self.cursor_h_line.set_data(pos=pos[1], color=color)
  357. self.cursor_v_line.set_data(pos=pos[0], color=color)
  358. self.view.scene.update()
  359. def on_mouse_scroll(self, event):
  360. # key modifiers
  361. modifiers = event.modifiers
  362. pan_delta_x = self.fcapp.defaults["global_gridx"]
  363. pan_delta_y = self.fcapp.defaults["global_gridy"]
  364. curr_pos = event.pos
  365. # Controlled pan by mouse wheel
  366. if 'Shift' in modifiers:
  367. p1 = np.array(curr_pos)[:2]
  368. if event.delta[1] > 0:
  369. curr_pos[0] -= pan_delta_x
  370. else:
  371. curr_pos[0] += pan_delta_x
  372. p2 = np.array(curr_pos)[:2]
  373. self.view.camera.pan(p2 - p1)
  374. elif 'Control' in modifiers:
  375. p1 = np.array(curr_pos)[:2]
  376. if event.delta[1] > 0:
  377. curr_pos[1] += pan_delta_y
  378. else:
  379. curr_pos[1] -= pan_delta_y
  380. p2 = np.array(curr_pos)[:2]
  381. self.view.camera.pan(p2 - p1)
  382. if self.fcapp.grid_status():
  383. pos_canvas = self.translate_coords(curr_pos)
  384. pos = self.fcapp.geo_editor.snap(pos_canvas[0], pos_canvas[1])
  385. # Update cursor
  386. self.fcapp.app_cursor.set_data(np.asarray([(pos[0], pos[1])]),
  387. symbol='++', edge_color=self.fcapp.cursor_color_3D,
  388. edge_width=self.fcapp.defaults["global_cursor_width"],
  389. size=self.fcapp.defaults["global_cursor_size"])
  390. def new_text_group(self, collection=None):
  391. if collection:
  392. return TextGroup(collection)
  393. else:
  394. return TextGroup(self.text_collection)
  395. def new_text_collection(self, **kwargs):
  396. return TextCollection(parent=self.view.scene, **kwargs)
  397. def fit_view(self, rect=None):
  398. # Lock updates in other threads
  399. self.shape_collection.lock_updates()
  400. if not rect:
  401. rect = Rect(-1, -1, 20, 20)
  402. try:
  403. rect.left, rect.right = self.shape_collection.bounds(axis=0)
  404. rect.bottom, rect.top = self.shape_collection.bounds(axis=1)
  405. except TypeError:
  406. pass
  407. # adjust the view camera to be slightly bigger than the bounds so the shape collection can be seen clearly
  408. # otherwise the shape collection boundary will have no border
  409. dx = rect.right - rect.left
  410. dy = rect.top - rect.bottom
  411. x_factor = dx * 0.02
  412. y_factor = dy * 0.02
  413. rect.left -= x_factor
  414. rect.bottom -= y_factor
  415. rect.right += x_factor
  416. rect.top += y_factor
  417. # rect.left *= 0.96
  418. # rect.bottom *= 0.96
  419. # rect.right *= 1.04
  420. # rect.top *= 1.04
  421. # units = self.fcapp.defaults['units'].upper()
  422. # if units == 'MM':
  423. # compensation = 0.5
  424. # else:
  425. # compensation = 0.5 / 25.4
  426. # rect.left -= compensation
  427. # rect.bottom -= compensation
  428. # rect.right += compensation
  429. # rect.top += compensation
  430. self.view.camera.rect = rect
  431. self.shape_collection.unlock_updates()
  432. def fit_center(self, loc, rect=None):
  433. # Lock updates in other threads
  434. self.shape_collection.lock_updates()
  435. if not rect:
  436. try:
  437. rect = Rect(loc[0]-20, loc[1]-20, 40, 40)
  438. except TypeError:
  439. pass
  440. self.view.camera.rect = rect
  441. self.shape_collection.unlock_updates()
  442. def clear(self):
  443. pass
  444. def redraw(self):
  445. self.shape_collection.redraw([])
  446. self.text_collection.redraw()
  447. def on_pool_recreated(self, pool):
  448. self.shape_collection.pool = pool
  449. class CursorBig(QtCore.QObject):
  450. """
  451. This is a fake cursor to ensure compatibility with the OpenGL engine (VisPy).
  452. This way I don't have to chane (disable) things related to the cursor all over when
  453. using the low performance Matplotlib 2D graphic engine.
  454. """
  455. mouse_state_updated = QtCore.pyqtSignal(bool)
  456. mouse_position_updated = QtCore.pyqtSignal(list)
  457. def __init__(self, app):
  458. super().__init__()
  459. self.app = app
  460. self._enabled = None
  461. @property
  462. def enabled(self):
  463. return True if self._enabled else False
  464. @enabled.setter
  465. def enabled(self, value):
  466. self._enabled = value
  467. self.mouse_state_updated.emit(value)
  468. def set_data(self, pos, **kwargs):
  469. """Internal event handler to draw the cursor when the mouse moves."""
  470. # if 'edge_color' in kwargs:
  471. # color = kwargs['edge_color']
  472. # else:
  473. # if self.app.defaults['global_theme'] == 'white':
  474. # color = '#000000FF'
  475. # else:
  476. # color = '#FFFFFFFF'
  477. position = [pos[0][0], pos[0][1]]
  478. self.mouse_position_updated.emit(position)