############################################################ # FlatCAM: 2D Post-processing for Manufacturing # # http://caram.cl/software/flatcam # # Author: Juan Pablo Caram (c) # # Date: 2/5/2014 # # MIT Licence # ############################################################ from PyQt5 import QtGui, QtCore, QtWidgets # Prevent conflict with Qt5 and above. from matplotlib import use as mpl_use from matplotlib.figure import Figure from matplotlib.backends.backend_qt5agg import FigureCanvasQTAgg as FigureCanvas from matplotlib.backends.backend_agg import FigureCanvasAgg from matplotlib.widgets import Cursor # needed for legacy mode # Used for solid polygons in Matplotlib from descartes.patch import PolygonPatch from shapely.geometry import Polygon, LineString, LinearRing, Point, MultiPolygon, MultiLineString import FlatCAMApp from copy import deepcopy import logging mpl_use("Qt5Agg") log = logging.getLogger('base') class CanvasCache(QtCore.QObject): """ Case story #1: 1) No objects in the project. 2) Object is created (new_object() emits object_created(obj)). on_object_created() adds (i) object to collection and emits (ii) new_object_available() then calls (iii) object.plot() 3) object.plot() creates axes if necessary on app.collection.figure. Then plots on it. 4) Plots on a cache-size canvas (in background). 5) Plot completes. Bitmap is generated. 6) Visible canvas is painted. """ # Signals: # A bitmap is ready to be displayed. new_screen = QtCore.pyqtSignal() def __init__(self, plotcanvas, app, dpi=50): super(CanvasCache, self).__init__() self.app = app self.plotcanvas = plotcanvas self.dpi = dpi self.figure = Figure(dpi=dpi) self.axes = self.figure.add_axes([0.0, 0.0, 1.0, 1.0], alpha=1.0) self.axes.set_frame_on(False) self.axes.set_xticks([]) self.axes.set_yticks([]) self.canvas = FigureCanvasAgg(self.figure) self.cache = None def run(self): log.debug("CanvasCache Thread Started!") self.plotcanvas.update_screen_request.connect(self.on_update_req) def on_update_req(self, extents): """ Event handler for an updated display request. :param extents: [xmin, xmax, ymin, ymax, zoom(optional)] """ # log.debug("Canvas update requested: %s" % str(extents)) # Note: This information below might be out of date. Establish # a protocol regarding when to change the canvas in the main # thread and when to check these values here in the background, # or pass this data in the signal (safer). # log.debug("Size: %s [px]" % str(self.plotcanvas.get_axes_pixelsize())) # log.debug("Density: %s [units/px]" % str(self.plotcanvas.get_density())) # Move the requested screen portion to the main thread # and inform about the update: self.new_screen.emit() # Continue to update the cache. # def on_new_object_available(self): # # log.debug("A new object is available. Should plot it!") class PlotCanvasLegacy(QtCore.QObject): """ Class handling the plotting area in the application. """ # Signals: # Request for new bitmap to display. The parameter # is a list with [xmin, xmax, ymin, ymax, zoom(optional)] update_screen_request = QtCore.pyqtSignal(list) double_click = QtCore.pyqtSignal(object) def __init__(self, container, app): """ The constructor configures the Matplotlib figure that will contain all plots, creates the base axes and connects events to the plotting area. :param container: The parent container in which to draw plots. :rtype: PlotCanvas """ super(PlotCanvasLegacy, self).__init__() self.app = app # Options self.x_margin = 15 # pixels self.y_margin = 25 # Pixels # Parent container self.container = container # Plots go onto a single matplotlib.figure self.figure = Figure(dpi=50) # TODO: dpi needed? self.figure.patch.set_visible(False) # These axes show the ticks and grid. No plotting done here. # New axes must have a label, otherwise mpl returns an existing one. self.axes = self.figure.add_axes([0.05, 0.05, 0.9, 0.9], label="base", alpha=0.0) self.axes.set_aspect(1) self.axes.grid(True) self.axes.axhline(color=(0.70, 0.3, 0.3), linewidth=2) self.axes.axvline(color=(0.70, 0.3, 0.3), linewidth=2) # The canvas is the top level container (FigureCanvasQTAgg) self.canvas = FigureCanvas(self.figure) self.canvas.setFocusPolicy(QtCore.Qt.ClickFocus) self.canvas.setFocus() self.native = self.canvas # self.canvas.set_can_focus(True) # For key press # Attach to parent # self.container.attach(self.canvas, 0, 0, 600, 400) # TODO: Height and width are num. columns?? self.container.addWidget(self.canvas) # Qt # Copy a bitmap of the canvas for quick animation. # Update every time the canvas is re-drawn. self.background = self.canvas.copy_from_bbox(self.axes.bbox) # ## Bitmap Cache self.cache = CanvasCache(self, self.app) self.cache_thread = QtCore.QThread() self.cache.moveToThread(self.cache_thread) # super(PlotCanvas, self).connect(self.cache_thread, QtCore.SIGNAL("started()"), self.cache.run) self.cache_thread.started.connect(self.cache.run) self.cache_thread.start() self.cache.new_screen.connect(self.on_new_screen) # Events self.mp = self.graph_event_connect('button_press_event', self.on_mouse_press) self.mr = self.graph_event_connect('button_release_event', self.on_mouse_release) self.mm = self.graph_event_connect('motion_notify_event', self.on_mouse_move) # self.canvas.connect('configure-event', self.auto_adjust_axes) self.aaa = self.graph_event_connect('resize_event', self.auto_adjust_axes) # self.canvas.add_events(Gdk.EventMask.SMOOTH_SCROLL_MASK) # self.canvas.connect("scroll-event", self.on_scroll) self.osc = self.graph_event_connect('scroll_event', self.on_scroll) # self.graph_event_connect('key_press_event', self.on_key_down) # self.graph_event_connect('key_release_event', self.on_key_up) self.odr = self.graph_event_connect('draw_event', self.on_draw) self.mouse = [0, 0] self.key = None self.pan_axes = [] self.panning = False # signal is the mouse is dragging self.is_dragging = False # signal if there is a doubleclick self.is_dblclk = False def graph_event_connect(self, event_name, callback): """ Attach an event handler to the canvas through the Matplotlib interface. :param event_name: Name of the event :type event_name: str :param callback: Function to call :type callback: func :return: Connection id :rtype: int """ if event_name == 'mouse_move': event_name = 'motion_notify_event' if event_name == 'mouse_press': event_name = 'button_press_event' if event_name == 'mouse_release': event_name = 'button_release_event' if event_name == 'mouse_double_click': return self.double_click.connect(callback) if event_name == 'key_press': event_name = 'key_press_event' return self.canvas.mpl_connect(event_name, callback) def graph_event_disconnect(self, cid): """ Disconnect callback with the give id. :param cid: Callback id. :return: None """ # self.double_click.disconnect(cid) self.canvas.mpl_disconnect(cid) def on_new_screen(self): pass # log.debug("Cache updated the screen!") def new_cursor(self, axes=None): # if axes is None: # c = MplCursor(axes=self.axes, color='black', linewidth=1) # else: # c = MplCursor(axes=axes, color='black', linewidth=1) c = FakeCursor() return c def on_key_down(self, event): """ :param event: :return: """ FlatCAMApp.App.log.debug('on_key_down(): ' + str(event.key)) self.key = event.key def on_key_up(self, event): """ :param event: :return: """ self.key = None def connect(self, event_name, callback): """ Attach an event handler to the canvas through the native Qt interface. :param event_name: Name of the event :type event_name: str :param callback: Function to call :type callback: function :return: Nothing """ self.canvas.connect(event_name, callback) def clear(self): """ Clears axes and figure. :return: None """ # Clear self.axes.cla() try: self.figure.clf() except KeyError: FlatCAMApp.App.log.warning("KeyError in MPL figure.clf()") # Re-build self.figure.add_axes(self.axes) self.axes.set_aspect(1) self.axes.grid(True) # Re-draw self.canvas.draw_idle() def adjust_axes(self, xmin, ymin, xmax, ymax): """ Adjusts all axes while maintaining the use of the whole canvas and an aspect ratio to 1:1 between x and y axes. The parameters are an original request that will be modified to fit these restrictions. :param xmin: Requested minimum value for the X axis. :type xmin: float :param ymin: Requested minimum value for the Y axis. :type ymin: float :param xmax: Requested maximum value for the X axis. :type xmax: float :param ymax: Requested maximum value for the Y axis. :type ymax: float :return: None """ # FlatCAMApp.App.log.debug("PC.adjust_axes()") width = xmax - xmin height = ymax - ymin try: r = width / height except ZeroDivisionError: FlatCAMApp.App.log.error("Height is %f" % height) return canvas_w, canvas_h = self.canvas.get_width_height() canvas_r = float(canvas_w) / canvas_h x_ratio = float(self.x_margin) / canvas_w y_ratio = float(self.y_margin) / canvas_h if r > canvas_r: ycenter = (ymin + ymax) / 2.0 newheight = height * r / canvas_r ymin = ycenter - newheight / 2.0 ymax = ycenter + newheight / 2.0 else: xcenter = (xmax + xmin) / 2.0 newwidth = width * canvas_r / r xmin = xcenter - newwidth / 2.0 xmax = xcenter + newwidth / 2.0 # Adjust axes for ax in self.figure.get_axes(): if ax._label != 'base': ax.set_frame_on(False) # No frame ax.set_xticks([]) # No tick ax.set_yticks([]) # No ticks ax.patch.set_visible(False) # No background ax.set_aspect(1) ax.set_xlim((xmin, xmax)) ax.set_ylim((ymin, ymax)) ax.set_position([x_ratio, y_ratio, 1 - 2 * x_ratio, 1 - 2 * y_ratio]) # Sync re-draw to proper paint on form resize self.canvas.draw() # #### Temporary place-holder for cached update ##### self.update_screen_request.emit([0, 0, 0, 0, 0]) def auto_adjust_axes(self, *args): """ Calls ``adjust_axes()`` using the extents of the base axes. :rtype : None :return: None """ xmin, xmax = self.axes.get_xlim() ymin, ymax = self.axes.get_ylim() self.adjust_axes(xmin, ymin, xmax, ymax) def fit_view(self): self.auto_adjust_axes() def zoom(self, factor, center=None): """ Zooms the plot by factor around a given center point. Takes care of re-drawing. :param factor: Number by which to scale the plot. :type factor: float :param center: Coordinates [x, y] of the point around which to scale the plot. :type center: list :return: None """ xmin, xmax = self.axes.get_xlim() ymin, ymax = self.axes.get_ylim() width = xmax - xmin height = ymax - ymin if center is None or center == [None, None]: center = [(xmin + xmax) / 2.0, (ymin + ymax) / 2.0] # For keeping the point at the pointer location relx = (xmax - center[0]) / width rely = (ymax - center[1]) / height new_width = width / factor new_height = height / factor xmin = center[0] - new_width * (1 - relx) xmax = center[0] + new_width * relx ymin = center[1] - new_height * (1 - rely) ymax = center[1] + new_height * rely # Adjust axes for ax in self.figure.get_axes(): ax.set_xlim((xmin, xmax)) ax.set_ylim((ymin, ymax)) # Async re-draw self.canvas.draw_idle() # #### Temporary place-holder for cached update ##### self.update_screen_request.emit([0, 0, 0, 0, 0]) def pan(self, x, y): xmin, xmax = self.axes.get_xlim() ymin, ymax = self.axes.get_ylim() width = xmax - xmin height = ymax - ymin # Adjust axes for ax in self.figure.get_axes(): ax.set_xlim((xmin + x * width, xmax + x * width)) ax.set_ylim((ymin + y * height, ymax + y * height)) # Re-draw self.canvas.draw_idle() # #### Temporary place-holder for cached update ##### self.update_screen_request.emit([0, 0, 0, 0, 0]) def new_axes(self, name): """ Creates and returns an Axes object attached to this object's Figure. :param name: Unique label for the axes. :return: Axes attached to the figure. :rtype: Axes """ return self.figure.add_axes([0.05, 0.05, 0.9, 0.9], label=name) def on_scroll(self, event): """ Scroll event handler. :param event: Event object containing the event information. :return: None """ # So it can receive key presses # self.canvas.grab_focus() self.canvas.setFocus() # Event info # z, direction = event.get_scroll_direction() if self.key is None: if event.button == 'up': self.zoom(1.5, self.mouse) else: self.zoom(1 / 1.5, self.mouse) return if self.key == 'shift': if event.button == 'up': self.pan(0.3, 0) else: self.pan(-0.3, 0) return if self.key == 'control': if event.button == 'up': self.pan(0, 0.3) else: self.pan(0, -0.3) return def on_mouse_press(self, event): self.is_dragging = True # Check for middle mouse button press if self.app.defaults["global_pan_button"] == '2': pan_button = 3 # right button for Matplotlib else: pan_button = 2 # middle button for Matplotlib if event.button == pan_button: # Prepare axes for pan (using 'matplotlib' pan function) self.pan_axes = [] for a in self.figure.get_axes(): if (event.x is not None and event.y is not None and a.in_axes(event) and a.get_navigate() and a.can_pan()): a.start_pan(event.x, event.y, 1) self.pan_axes.append(a) # Set pan view flag if len(self.pan_axes) > 0: self.panning = True if event.dblclick: self.double_click.emit(event) def on_mouse_release(self, event): self.is_dragging = False # Check for middle mouse button release to complete pan procedure # Check for middle mouse button press if self.app.defaults["global_pan_button"] == '2': pan_button = 3 # right button for Matplotlib else: pan_button = 2 # middle button for Matplotlib if event.button == pan_button: for a in self.pan_axes: a.end_pan() # Clear pan flag self.panning = False def on_mouse_move(self, event): """ Mouse movement event hadler. Stores the coordinates. Updates view on pan. :param event: Contains information about the event. :return: None """ try: x = float(event.xdata) y = float(event.ydata) except TypeError: return self.mouse = [event.xdata, event.ydata] self.canvas.restore_region(self.background) # Update pan view on mouse move if self.panning is True: # x_pan, y_pan = self.app.geo_editor.snap(event.xdata, event.ydata) # self.app.app_cursor.set_data(event, (x_pan, y_pan)) for a in self.pan_axes: a.drag_pan(1, event.key, event.x, event.y) # Async re-draw (redraws only on thread idle state, uses timer on backend) self.canvas.draw_idle() # #### Temporary place-holder for cached update ##### self.update_screen_request.emit([0, 0, 0, 0, 0]) x, y = self.app.geo_editor.snap(x, y) if self.app.app_cursor.enabled is True: # Pointer (snapped) elements = self.axes.plot(x, y, 'k+', ms=40, mew=2, animated=True) for el in elements: self.axes.draw_artist(el) self.canvas.blit(self.axes.bbox) def translate_coords(self, position): """ This does not do much. It's just for code compatibility :param position: Mouse event position :return: Tuple with mouse position """ return (position[0], position[1]) def on_draw(self, renderer): # Store background on canvas redraw self.background = self.canvas.copy_from_bbox(self.axes.bbox) def get_axes_pixelsize(self): """ Axes size in pixels. :return: Pixel width and height :rtype: tuple """ bbox = self.axes.get_window_extent().transformed(self.figure.dpi_scale_trans.inverted()) width, height = bbox.width, bbox.height width *= self.figure.dpi height *= self.figure.dpi return width, height def get_density(self): """ Returns unit length per pixel on horizontal and vertical axes. :return: X and Y density :rtype: tuple """ xpx, ypx = self.get_axes_pixelsize() xmin, xmax = self.axes.get_xlim() ymin, ymax = self.axes.get_ylim() width = xmax - xmin height = ymax - ymin return width / xpx, height / ypx class FakeCursor(): def __init__(self): self._enabled = True @property def enabled(self): return True if self._enabled else False @enabled.setter def enabled(self, value): self._enabled = value class MplCursor(Cursor): def __init__(self, axes, color='red', linewidth=1): super().__init__(ax=axes, useblit=True, color=color, linewidth=linewidth) self._enabled = True self.axes = axes self.color = color self.linewidth = linewidth self.x = None self.y = None @property def enabled(self): return True if self._enabled else False @enabled.setter def enabled(self, value): self._enabled = value self.visible = self._enabled self.canvas.draw() def onmove(self, event): pass def set_data(self, event, pos): """Internal event handler to draw the cursor when the mouse moves.""" self.x = pos[0] self.y = pos[1] if self.ignore(event): return if not self.canvas.widgetlock.available(self): return if event.inaxes != self.ax: self.linev.set_visible(False) self.lineh.set_visible(False) if self.needclear: self.canvas.draw() self.needclear = False return self.needclear = True if not self.visible: return self.linev.set_xdata((self.x, self.x)) self.lineh.set_ydata((self.y, self.y)) self.linev.set_visible(self.visible and self.vertOn) self.lineh.set_visible(self.visible and self.horizOn) self._update() class ShapeCollectionLegacy(): def __init__(self, obj, app, name=None): self.obj = obj self.app = app self._shapes = dict() self.shape_dict = dict() self.shape_id = 0 self._color = None self._face_color = None self._visible = True self._update = False self._obj = None self._gcode_parsed = None if name is None: axes_name = self.obj.options['name'] else: axes_name = name # Axes must exist and be attached to canvas. if axes_name not in self.app.plotcanvas.figure.axes: self.axes = self.app.plotcanvas.new_axes(axes_name) def add(self, shape=None, color=None, face_color=None, alpha=None, visible=True, update=False, layer=1, tolerance=0.01, obj=None, gcode_parsed=None, tool_tolerance=None, tooldia=None): self._color = color[:-2] if color is not None else None self._face_color = face_color[:-2] if face_color is not None else None self._visible = visible self._update = update # CNCJob oject related arguments self._obj = obj self._gcode_parsed = gcode_parsed self._tool_tolerance = tool_tolerance self._tooldia = tooldia try: for sh in shape: self.shape_id += 1 self.shape_dict.update({ 'color': self._color, 'face_color': self._face_color, 'shape': sh }) self._shapes.update({ self.shape_id: deepcopy(self.shape_dict) }) except TypeError: self.shape_id += 1 self.shape_dict.update({ 'color': self._color, 'face_color': self._face_color, 'shape': shape }) self._shapes.update({ self.shape_id: deepcopy(self.shape_dict) }) return self.shape_id def clear(self, update=None): self._shapes.clear() self.shape_id = 0 self.axes.cla() self.app.plotcanvas.auto_adjust_axes() if update is True: self.redraw() def redraw(self): path_num = 0 if self._visible: for element in self._shapes: if self.obj.kind == 'excellon': # Plot excellon (All polygons?) if self.obj.options["solid"]: patch = PolygonPatch(self._shapes[element]['shape'], facecolor="#C40000", edgecolor="#750000", alpha=0.75, zorder=3) self.axes.add_patch(patch) else: x, y = self._shapes[element]['shape'].exterior.coords.xy self.axes.plot(x, y, 'r-') for ints in self._shapes[element]['shape'].interiors: x, y = ints.coords.xy self.axes.plot(x, y, 'o-') elif self.obj.kind == 'geometry': if type(self._shapes[element]['shape']) == Polygon: x, y = self._shapes[element]['shape'].exterior.coords.xy self.axes.plot(x, y, self._shapes[element]['color'], linestyle='-') for ints in self._shapes[element]['shape'].interiors: x, y = ints.coords.xy self.axes.plot(x, y, self._shapes[element]['color'], linestyle='-') elif type(element) == LineString or type(element) == LinearRing: x, y = element.coords.xy self.axes.plot(x, y, self._shapes[element]['color'], marker='-') return elif self.obj.kind == 'gerber': if self.obj.options["multicolored"]: linespec = '-' else: linespec = 'k-' if self.obj.options["solid"]: try: patch = PolygonPatch(self._shapes[element]['shape'], facecolor=self._shapes[element]['face_color'], edgecolor=self._shapes[element]['color'], alpha=0.75, zorder=2) self.axes.add_patch(patch) except AssertionError: FlatCAMApp.App.log.warning("A geometry component was not a polygon:") FlatCAMApp.App.log.warning(str(element)) else: x, y = self._shapes[element]['shape'].exterior.xy self.axes.plot(x, y, linespec) for ints in self._shapes[element]['shape'].interiors: x, y = ints.coords.xy self.axes.plot(x, y, linespec) elif self.obj.kind == 'cncjob': if self._shapes[element]['face_color'] is None: linespec = '--' linecolor = self._shapes[element]['color'] # if geo['kind'][0] == 'C': # linespec = 'k-' x, y = self._shapes[element]['shape'].coords.xy self.axes.plot(x, y, linespec, color=linecolor) else: path_num += 1 if isinstance(self._shapes[element]['shape'], Polygon): self.axes.annotate(str(path_num), xy=self._shapes[element]['shape'].exterior.coords[0], xycoords='data') else: self.axes.annotate(str(path_num), xy=self._shapes[element]['shape'].coords[0], xycoords='data') patch = PolygonPatch(self._shapes[element]['shape'], facecolor=self._shapes[element]['face_color'], edgecolor=self._shapes[element]['color'], alpha=0.75, zorder=2) self.axes.add_patch(patch) self.app.plotcanvas.auto_adjust_axes() @property def visible(self): return self._visible @visible.setter def visible(self, value): if value is False: self.axes.cla() self.app.plotcanvas.auto_adjust_axes() else: if self._visible is False: self.redraw() self._visible = value