PlotCanvas.py 16 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526
  1. ############################################################
  2. # FlatCAM: 2D Post-processing for Manufacturing #
  3. # http://caram.cl/software/flatcam #
  4. # Author: Juan Pablo Caram (c) #
  5. # Date: 2/5/2014 #
  6. # MIT Licence #
  7. ############################################################
  8. from PyQt4 import QtGui, QtCore
  9. # Prevent conflict with Qt5 and above.
  10. from matplotlib import use as mpl_use
  11. mpl_use("Qt4Agg")
  12. from matplotlib.figure import Figure
  13. from matplotlib.backends.backend_qt4agg import FigureCanvasQTAgg as FigureCanvas
  14. from matplotlib.backends.backend_agg import FigureCanvasAgg
  15. import FlatCAMApp
  16. import logging
  17. log = logging.getLogger('base')
  18. class CanvasCache(QtCore.QObject):
  19. """
  20. Case story #1:
  21. 1) No objects in the project.
  22. 2) Object is created (new_object() emits object_created(obj)).
  23. on_object_created() adds (i) object to collection and emits
  24. (ii) new_object_available() then calls (iii) object.plot()
  25. 3) object.plot() creates axes if necessary on
  26. app.collection.figure. Then plots on it.
  27. 4) Plots on a cache-size canvas (in background).
  28. 5) Plot completes. Bitmap is generated.
  29. 6) Visible canvas is painted.
  30. """
  31. # Signals:
  32. # A bitmap is ready to be displayed.
  33. new_screen = QtCore.pyqtSignal()
  34. def __init__(self, plotcanvas, app, dpi=50):
  35. super(CanvasCache, self).__init__()
  36. self.app = app
  37. self.plotcanvas = plotcanvas
  38. self.dpi = dpi
  39. self.figure = Figure(dpi=dpi)
  40. self.axes = self.figure.add_axes([0.0, 0.0, 1.0, 1.0], alpha=1.0)
  41. self.axes.set_frame_on(False)
  42. self.axes.set_xticks([])
  43. self.axes.set_yticks([])
  44. self.canvas = FigureCanvasAgg(self.figure)
  45. self.cache = None
  46. def run(self):
  47. log.debug("CanvasCache Thread Started!")
  48. self.plotcanvas.update_screen_request.connect(self.on_update_req)
  49. self.app.new_object_available.connect(self.on_new_object_available)
  50. def on_update_req(self, extents):
  51. """
  52. Event handler for an updated display request.
  53. :param extents: [xmin, xmax, ymin, ymax, zoom(optional)]
  54. """
  55. log.debug("Canvas update requested: %s" % str(extents))
  56. # Note: This information below might be out of date. Establish
  57. # a protocol regarding when to change the canvas in the main
  58. # thread and when to check these values here in the background,
  59. # or pass this data in the signal (safer).
  60. log.debug("Size: %s [px]" % str(self.plotcanvas.get_axes_pixelsize()))
  61. log.debug("Density: %s [units/px]" % str(self.plotcanvas.get_density()))
  62. # Move the requested screen portion to the main thread
  63. # and inform about the update:
  64. self.new_screen.emit()
  65. # Continue to update the cache.
  66. def on_new_object_available(self):
  67. log.debug("A new object is available. Should plot it!")
  68. class PlotCanvas(QtCore.QObject):
  69. """
  70. Class handling the plotting area in the application.
  71. """
  72. # Signals:
  73. # Request for new bitmap to display. The parameter
  74. # is a list with [xmin, xmax, ymin, ymax, zoom(optional)]
  75. update_screen_request = QtCore.pyqtSignal(list)
  76. def __init__(self, container, app):
  77. """
  78. The constructor configures the Matplotlib figure that
  79. will contain all plots, creates the base axes and connects
  80. events to the plotting area.
  81. :param container: The parent container in which to draw plots.
  82. :rtype: PlotCanvas
  83. """
  84. super(PlotCanvas, self).__init__()
  85. self.app = app
  86. # Options
  87. self.x_margin = 15 # pixels
  88. self.y_margin = 25 # Pixels
  89. # Parent container
  90. self.container = container
  91. # Plots go onto a single matplotlib.figure
  92. self.figure = Figure(dpi=50) # TODO: dpi needed?
  93. self.figure.patch.set_visible(False)
  94. # These axes show the ticks and grid. No plotting done here.
  95. # New axes must have a label, otherwise mpl returns an existing one.
  96. self.axes = self.figure.add_axes([0.05, 0.05, 0.9, 0.9], label="base", alpha=0.0)
  97. self.axes.set_aspect(1)
  98. self.axes.grid(True)
  99. self.axes.axhline(color='Black')
  100. self.axes.axvline(color='Black')
  101. # The canvas is the top level container (FigureCanvasQTAgg)
  102. self.canvas = FigureCanvas(self.figure)
  103. # self.canvas.setFocusPolicy(QtCore.Qt.ClickFocus)
  104. # self.canvas.setFocus()
  105. #self.canvas.set_hexpand(1)
  106. #self.canvas.set_vexpand(1)
  107. #self.canvas.set_can_focus(True) # For key press
  108. # Attach to parent
  109. #self.container.attach(self.canvas, 0, 0, 600, 400) # TODO: Height and width are num. columns??
  110. self.container.addWidget(self.canvas) # Qt
  111. # Copy a bitmap of the canvas for quick animation.
  112. # Update every time the canvas is re-drawn.
  113. self.background = self.canvas.copy_from_bbox(self.axes.bbox)
  114. ### Bitmap Cache
  115. self.cache = CanvasCache(self, self.app)
  116. self.cache_thread = QtCore.QThread()
  117. self.cache.moveToThread(self.cache_thread)
  118. super(PlotCanvas, self).connect(self.cache_thread, QtCore.SIGNAL("started()"), self.cache.run)
  119. # self.connect()
  120. self.cache_thread.start()
  121. self.cache.new_screen.connect(self.on_new_screen)
  122. # Events
  123. self.canvas.mpl_connect('button_press_event', self.on_mouse_press)
  124. self.canvas.mpl_connect('button_release_event', self.on_mouse_release)
  125. self.canvas.mpl_connect('motion_notify_event', self.on_mouse_move)
  126. #self.canvas.connect('configure-event', self.auto_adjust_axes)
  127. self.canvas.mpl_connect('resize_event', self.auto_adjust_axes)
  128. #self.canvas.add_events(Gdk.EventMask.SMOOTH_SCROLL_MASK)
  129. #self.canvas.connect("scroll-event", self.on_scroll)
  130. self.canvas.mpl_connect('scroll_event', self.on_scroll)
  131. self.canvas.mpl_connect('key_press_event', self.on_key_down)
  132. self.canvas.mpl_connect('key_release_event', self.on_key_up)
  133. self.canvas.mpl_connect('draw_event', self.on_draw)
  134. self.mouse = [0, 0]
  135. self.key = None
  136. self.pan_axes = []
  137. self.panning = False
  138. def on_new_screen(self):
  139. log.debug("Cache updated the screen!")
  140. def on_key_down(self, event):
  141. """
  142. :param event:
  143. :return:
  144. """
  145. FlatCAMApp.App.log.debug('on_key_down(): ' + str(event.key))
  146. self.key = event.key
  147. def on_key_up(self, event):
  148. """
  149. :param event:
  150. :return:
  151. """
  152. self.key = None
  153. def mpl_connect(self, event_name, callback):
  154. """
  155. Attach an event handler to the canvas through the Matplotlib interface.
  156. :param event_name: Name of the event
  157. :type event_name: str
  158. :param callback: Function to call
  159. :type callback: func
  160. :return: Connection id
  161. :rtype: int
  162. """
  163. return self.canvas.mpl_connect(event_name, callback)
  164. def mpl_disconnect(self, cid):
  165. """
  166. Disconnect callback with the give id.
  167. :param cid: Callback id.
  168. :return: None
  169. """
  170. self.canvas.mpl_disconnect(cid)
  171. def connect(self, event_name, callback):
  172. """
  173. Attach an event handler to the canvas through the native Qt interface.
  174. :param event_name: Name of the event
  175. :type event_name: str
  176. :param callback: Function to call
  177. :type callback: function
  178. :return: Nothing
  179. """
  180. self.canvas.connect(event_name, callback)
  181. def clear(self):
  182. """
  183. Clears axes and figure.
  184. :return: None
  185. """
  186. # Clear
  187. self.axes.cla()
  188. try:
  189. self.figure.clf()
  190. except KeyError:
  191. FlatCAMApp.App.log.warning("KeyError in MPL figure.clf()")
  192. # Re-build
  193. self.figure.add_axes(self.axes)
  194. self.axes.set_aspect(1)
  195. self.axes.grid(True)
  196. # Re-draw
  197. self.canvas.draw_idle()
  198. def adjust_axes(self, xmin, ymin, xmax, ymax):
  199. """
  200. Adjusts all axes while maintaining the use of the whole canvas
  201. and an aspect ratio to 1:1 between x and y axes. The parameters are an original
  202. request that will be modified to fit these restrictions.
  203. :param xmin: Requested minimum value for the X axis.
  204. :type xmin: float
  205. :param ymin: Requested minimum value for the Y axis.
  206. :type ymin: float
  207. :param xmax: Requested maximum value for the X axis.
  208. :type xmax: float
  209. :param ymax: Requested maximum value for the Y axis.
  210. :type ymax: float
  211. :return: None
  212. """
  213. # FlatCAMApp.App.log.debug("PC.adjust_axes()")
  214. width = xmax - xmin
  215. height = ymax - ymin
  216. try:
  217. r = width / height
  218. except ZeroDivisionError:
  219. FlatCAMApp.App.log.error("Height is %f" % height)
  220. return
  221. canvas_w, canvas_h = self.canvas.get_width_height()
  222. canvas_r = float(canvas_w) / canvas_h
  223. x_ratio = float(self.x_margin) / canvas_w
  224. y_ratio = float(self.y_margin) / canvas_h
  225. if r > canvas_r:
  226. ycenter = (ymin + ymax) / 2.0
  227. newheight = height * r / canvas_r
  228. ymin = ycenter - newheight / 2.0
  229. ymax = ycenter + newheight / 2.0
  230. else:
  231. xcenter = (xmax + xmin) / 2.0
  232. newwidth = width * canvas_r / r
  233. xmin = xcenter - newwidth / 2.0
  234. xmax = xcenter + newwidth / 2.0
  235. # Adjust axes
  236. for ax in self.figure.get_axes():
  237. if ax._label != 'base':
  238. ax.set_frame_on(False) # No frame
  239. ax.set_xticks([]) # No tick
  240. ax.set_yticks([]) # No ticks
  241. ax.patch.set_visible(False) # No background
  242. ax.set_aspect(1)
  243. ax.set_xlim((xmin, xmax))
  244. ax.set_ylim((ymin, ymax))
  245. ax.set_position([x_ratio, y_ratio, 1 - 2 * x_ratio, 1 - 2 * y_ratio])
  246. # Sync re-draw to proper paint on form resize
  247. self.canvas.draw()
  248. ##### Temporary place-holder for cached update #####
  249. self.update_screen_request.emit([0, 0, 0, 0, 0])
  250. def auto_adjust_axes(self, *args):
  251. """
  252. Calls ``adjust_axes()`` using the extents of the base axes.
  253. :rtype : None
  254. :return: None
  255. """
  256. xmin, xmax = self.axes.get_xlim()
  257. ymin, ymax = self.axes.get_ylim()
  258. self.adjust_axes(xmin, ymin, xmax, ymax)
  259. def zoom(self, factor, center=None):
  260. """
  261. Zooms the plot by factor around a given
  262. center point. Takes care of re-drawing.
  263. :param factor: Number by which to scale the plot.
  264. :type factor: float
  265. :param center: Coordinates [x, y] of the point around which to scale the plot.
  266. :type center: list
  267. :return: None
  268. """
  269. xmin, xmax = self.axes.get_xlim()
  270. ymin, ymax = self.axes.get_ylim()
  271. width = xmax - xmin
  272. height = ymax - ymin
  273. if center is None or center == [None, None]:
  274. center = [(xmin + xmax) / 2.0, (ymin + ymax) / 2.0]
  275. # For keeping the point at the pointer location
  276. relx = (xmax - center[0]) / width
  277. rely = (ymax - center[1]) / height
  278. new_width = width / factor
  279. new_height = height / factor
  280. xmin = center[0] - new_width * (1 - relx)
  281. xmax = center[0] + new_width * relx
  282. ymin = center[1] - new_height * (1 - rely)
  283. ymax = center[1] + new_height * rely
  284. # Adjust axes
  285. for ax in self.figure.get_axes():
  286. ax.set_xlim((xmin, xmax))
  287. ax.set_ylim((ymin, ymax))
  288. # Async re-draw
  289. self.canvas.draw_idle()
  290. ##### Temporary place-holder for cached update #####
  291. self.update_screen_request.emit([0, 0, 0, 0, 0])
  292. def pan(self, x, y):
  293. xmin, xmax = self.axes.get_xlim()
  294. ymin, ymax = self.axes.get_ylim()
  295. width = xmax - xmin
  296. height = ymax - ymin
  297. # Adjust axes
  298. for ax in self.figure.get_axes():
  299. ax.set_xlim((xmin + x * width, xmax + x * width))
  300. ax.set_ylim((ymin + y * height, ymax + y * height))
  301. # Re-draw
  302. self.canvas.draw_idle()
  303. ##### Temporary place-holder for cached update #####
  304. self.update_screen_request.emit([0, 0, 0, 0, 0])
  305. def new_axes(self, name):
  306. """
  307. Creates and returns an Axes object attached to this object's Figure.
  308. :param name: Unique label for the axes.
  309. :return: Axes attached to the figure.
  310. :rtype: Axes
  311. """
  312. return self.figure.add_axes([0.05, 0.05, 0.9, 0.9], label=name)
  313. def on_scroll(self, event):
  314. """
  315. Scroll event handler.
  316. :param event: Event object containing the event information.
  317. :return: None
  318. """
  319. # So it can receive key presses
  320. # self.canvas.grab_focus()
  321. self.canvas.setFocus()
  322. # Event info
  323. # z, direction = event.get_scroll_direction()
  324. if self.key is None:
  325. if event.button == 'up':
  326. self.zoom(1.5, self.mouse)
  327. else:
  328. self.zoom(1 / 1.5, self.mouse)
  329. return
  330. if self.key == 'shift':
  331. if event.button == 'up':
  332. self.pan(0.3, 0)
  333. else:
  334. self.pan(-0.3, 0)
  335. return
  336. if self.key == 'control':
  337. if event.button == 'up':
  338. self.pan(0, 0.3)
  339. else:
  340. self.pan(0, -0.3)
  341. return
  342. def on_mouse_press(self, event):
  343. # Check for middle mouse button press
  344. if event.button == self.app.mouse_pan_button:
  345. # Prepare axes for pan (using 'matplotlib' pan function)
  346. self.pan_axes = []
  347. for a in self.figure.get_axes():
  348. if (event.x is not None and event.y is not None and a.in_axes(event) and
  349. a.get_navigate() and a.can_pan()):
  350. a.start_pan(event.x, event.y, 1)
  351. self.pan_axes.append(a)
  352. # Set pan view flag
  353. if len(self.pan_axes) > 0: self.panning = True;
  354. def on_mouse_release(self, event):
  355. # Check for middle mouse button release to complete pan procedure
  356. if event.button == self.app.mouse_pan_button:
  357. for a in self.pan_axes:
  358. a.end_pan()
  359. # Clear pan flag
  360. self.panning = False
  361. def on_mouse_move(self, event):
  362. """
  363. Mouse movement event hadler. Stores the coordinates. Updates view on pan.
  364. :param event: Contains information about the event.
  365. :return: None
  366. """
  367. self.mouse = [event.xdata, event.ydata]
  368. # Update pan view on mouse move
  369. if self.panning is True:
  370. for a in self.pan_axes:
  371. a.drag_pan(1, event.key, event.x, event.y)
  372. # Async re-draw (redraws only on thread idle state, uses timer on backend)
  373. self.canvas.draw_idle()
  374. ##### Temporary place-holder for cached update #####
  375. self.update_screen_request.emit([0, 0, 0, 0, 0])
  376. def on_draw(self, renderer):
  377. # Store background on canvas redraw
  378. self.background = self.canvas.copy_from_bbox(self.axes.bbox)
  379. def get_axes_pixelsize(self):
  380. """
  381. Axes size in pixels.
  382. :return: Pixel width and height
  383. :rtype: tuple
  384. """
  385. bbox = self.axes.get_window_extent().transformed(self.figure.dpi_scale_trans.inverted())
  386. width, height = bbox.width, bbox.height
  387. width *= self.figure.dpi
  388. height *= self.figure.dpi
  389. return width, height
  390. def get_density(self):
  391. """
  392. Returns unit length per pixel on horizontal
  393. and vertical axes.
  394. :return: X and Y density
  395. :rtype: tuple
  396. """
  397. xpx, ypx = self.get_axes_pixelsize()
  398. xmin, xmax = self.axes.get_xlim()
  399. ymin, ymax = self.axes.get_ylim()
  400. width = xmax - xmin
  401. height = ymax - ymin
  402. return width / xpx, height / ypx