|
|
@@ -0,0 +1,553 @@
|
|
|
+from PyQt4 import QtGui, QtCore, Qt
|
|
|
+import FlatCAMApp
|
|
|
+
|
|
|
+from shapely.geometry import Polygon, LineString, Point, LinearRing
|
|
|
+from shapely.geometry import MultiPoint, MultiPolygon
|
|
|
+from shapely.geometry import box as shply_box
|
|
|
+from shapely.ops import cascaded_union, unary_union
|
|
|
+import shapely.affinity as affinity
|
|
|
+from shapely.wkt import loads as sloads
|
|
|
+from shapely.wkt import dumps as sdumps
|
|
|
+from shapely.geometry.base import BaseGeometry
|
|
|
+
|
|
|
+from numpy import arctan2, Inf, array, sqrt, pi, ceil, sin, cos
|
|
|
+
|
|
|
+
|
|
|
+class DrawTool(object):
|
|
|
+ def __init__(self, draw_app):
|
|
|
+ self.draw_app = draw_app
|
|
|
+ self.complete = False
|
|
|
+ self.start_msg = "Click on 1st point..."
|
|
|
+ self.points = []
|
|
|
+ self.geometry = None
|
|
|
+
|
|
|
+ def click(self, point):
|
|
|
+ return ""
|
|
|
+
|
|
|
+ def utility_geometry(self, data=None):
|
|
|
+ return None
|
|
|
+
|
|
|
+
|
|
|
+class FCShapeTool(DrawTool):
|
|
|
+ def __init__(self, draw_app):
|
|
|
+ DrawTool.__init__(self, draw_app)
|
|
|
+
|
|
|
+ def make(self):
|
|
|
+ pass
|
|
|
+
|
|
|
+
|
|
|
+class FCCircle(FCShapeTool):
|
|
|
+ def __init__(self, draw_app):
|
|
|
+ DrawTool.__init__(self, draw_app)
|
|
|
+ self.start_msg = "Click on CENTER ..."
|
|
|
+
|
|
|
+ def click(self, point):
|
|
|
+ self.points.append(point)
|
|
|
+
|
|
|
+ if len(self.points) == 1:
|
|
|
+ return "Click on perimeter to complete ..."
|
|
|
+
|
|
|
+ if len(self.points) == 2:
|
|
|
+ self.make()
|
|
|
+ return "Done."
|
|
|
+
|
|
|
+ return ""
|
|
|
+
|
|
|
+ def utility_geometry(self, data=None):
|
|
|
+ if len(self.points) == 1:
|
|
|
+ p1 = self.points[0]
|
|
|
+ p2 = data
|
|
|
+ radius = sqrt((p1[0]-p2[0])**2 + (p1[1]-p2[1])**2)
|
|
|
+ return Point(p1).buffer(radius)
|
|
|
+
|
|
|
+ return None
|
|
|
+
|
|
|
+ def make(self):
|
|
|
+ p1 = self.points[0]
|
|
|
+ p2 = self.points[1]
|
|
|
+ radius = sqrt((p1[0]-p2[0])**2 + (p1[1]-p2[1])**2)
|
|
|
+ self.geometry = Point(p1).buffer(radius)
|
|
|
+ self.complete = True
|
|
|
+
|
|
|
+
|
|
|
+class FCRectangle(FCShapeTool):
|
|
|
+ def __init__(self, draw_app):
|
|
|
+ DrawTool.__init__(self, draw_app)
|
|
|
+ self.start_msg = "Click on 1st corner ..."
|
|
|
+
|
|
|
+ def click(self, point):
|
|
|
+ self.points.append(point)
|
|
|
+
|
|
|
+ if len(self.points) == 1:
|
|
|
+ return "Click on opposite corner to complete ..."
|
|
|
+
|
|
|
+ if len(self.points) == 2:
|
|
|
+ self.make()
|
|
|
+ return "Done."
|
|
|
+
|
|
|
+ return ""
|
|
|
+
|
|
|
+ def utility_geometry(self, data=None):
|
|
|
+ if len(self.points) == 1:
|
|
|
+ p1 = self.points[0]
|
|
|
+ p2 = data
|
|
|
+ return LinearRing([p1, (p2[0], p1[1]), p2, (p1[0], p2[1])])
|
|
|
+
|
|
|
+ return None
|
|
|
+
|
|
|
+ def make(self):
|
|
|
+ p1 = self.points[0]
|
|
|
+ p2 = self.points[1]
|
|
|
+ #self.geometry = LinearRing([p1, (p2[0], p1[1]), p2, (p1[0], p2[1])])
|
|
|
+ self.geometry = Polygon([p1, (p2[0], p1[1]), p2, (p1[0], p2[1])])
|
|
|
+ self.complete = True
|
|
|
+
|
|
|
+
|
|
|
+class FCPolygon(FCShapeTool):
|
|
|
+ def __init__(self, draw_app):
|
|
|
+ DrawTool.__init__(self, draw_app)
|
|
|
+ self.start_msg = "Click on 1st point ..."
|
|
|
+
|
|
|
+ def click(self, point):
|
|
|
+ self.points.append(point)
|
|
|
+
|
|
|
+ if len(self.points) > 0:
|
|
|
+ return "Click on next point or hit SPACE to complete ..."
|
|
|
+
|
|
|
+ return ""
|
|
|
+
|
|
|
+ def utility_geometry(self, data=None):
|
|
|
+ if len(self.points) == 1:
|
|
|
+ temp_points = [x for x in self.points]
|
|
|
+ temp_points.append(data)
|
|
|
+ return LineString(temp_points)
|
|
|
+
|
|
|
+ if len(self.points) > 1:
|
|
|
+ temp_points = [x for x in self.points]
|
|
|
+ temp_points.append(data)
|
|
|
+ return LinearRing(temp_points)
|
|
|
+
|
|
|
+ return None
|
|
|
+
|
|
|
+ def make(self):
|
|
|
+ # self.geometry = LinearRing(self.points)
|
|
|
+ self.geometry = Polygon(self.points)
|
|
|
+ self.complete = True
|
|
|
+
|
|
|
+
|
|
|
+class FCPath(FCPolygon):
|
|
|
+ def make(self):
|
|
|
+ self.geometry = LineString(self.points)
|
|
|
+ self.complete = True
|
|
|
+
|
|
|
+ def utility_geometry(self, data=None):
|
|
|
+ if len(self.points) > 1:
|
|
|
+ temp_points = [x for x in self.points]
|
|
|
+ temp_points.append(data)
|
|
|
+ return LineString(temp_points)
|
|
|
+
|
|
|
+ return None
|
|
|
+
|
|
|
+
|
|
|
+class FCSelect(DrawTool):
|
|
|
+ def __init__(self, draw_app):
|
|
|
+ DrawTool.__init__(self, draw_app)
|
|
|
+ self.shape_buffer = self.draw_app.shape_buffer
|
|
|
+ self.start_msg = "Click on geometry to select"
|
|
|
+
|
|
|
+ def click(self, point):
|
|
|
+ min_distance = Inf
|
|
|
+ closest_shape = None
|
|
|
+
|
|
|
+ for shape in self.shape_buffer:
|
|
|
+ if self.draw_app.key != 'control':
|
|
|
+ shape["selected"] = False
|
|
|
+
|
|
|
+ distance = Point(point).distance(shape["geometry"])
|
|
|
+ if distance < min_distance:
|
|
|
+ closest_shape = shape
|
|
|
+ min_distance = distance
|
|
|
+
|
|
|
+ if closest_shape is not None:
|
|
|
+ closest_shape["selected"] = True
|
|
|
+ return "Shape selected."
|
|
|
+
|
|
|
+ return "Nothing selected."
|
|
|
+
|
|
|
+
|
|
|
+class FlatCAMDraw:
|
|
|
+ def __init__(self, app, disabled=False):
|
|
|
+ assert isinstance(app, FlatCAMApp.App)
|
|
|
+ self.app = app
|
|
|
+ self.canvas = app.plotcanvas
|
|
|
+ self.axes = self.canvas.new_axes("draw")
|
|
|
+
|
|
|
+ ### Drawing Toolbar ###
|
|
|
+ self.drawing_toolbar = QtGui.QToolBar()
|
|
|
+ self.drawing_toolbar.setDisabled(disabled)
|
|
|
+ self.app.ui.addToolBar(self.drawing_toolbar)
|
|
|
+ self.select_btn = self.drawing_toolbar.addAction(QtGui.QIcon('share/pointer32.png'), 'Select')
|
|
|
+ self.add_circle_btn = self.drawing_toolbar.addAction(QtGui.QIcon('share/circle32.png'), 'Add Circle')
|
|
|
+ self.add_rectangle_btn = self.drawing_toolbar.addAction(QtGui.QIcon('share/rectangle32.png'), 'Add Rectangle')
|
|
|
+ self.add_polygon_btn = self.drawing_toolbar.addAction(QtGui.QIcon('share/polygon32.png'), 'Add Polygon')
|
|
|
+ self.add_path_btn = self.drawing_toolbar.addAction(QtGui.QIcon('share/path32.png'), 'Add Path')
|
|
|
+ self.union_btn = self.drawing_toolbar.addAction(QtGui.QIcon('share/union32.png'), 'Polygon Union')
|
|
|
+
|
|
|
+ ### Event handlers ###
|
|
|
+ ## Canvas events
|
|
|
+ self.canvas.mpl_connect('button_press_event', self.on_canvas_click)
|
|
|
+ self.canvas.mpl_connect('motion_notify_event', self.on_canvas_move)
|
|
|
+ self.canvas.mpl_connect('key_press_event', self.on_canvas_key)
|
|
|
+ self.canvas.mpl_connect('key_release_event', self.on_canvas_key_release)
|
|
|
+
|
|
|
+ self.union_btn.triggered.connect(self.union)
|
|
|
+
|
|
|
+ ## Toolbar events and properties
|
|
|
+ self.tools = {
|
|
|
+ "select": {"button": self.select_btn,
|
|
|
+ "constructor": FCSelect},
|
|
|
+ "circle": {"button": self.add_circle_btn,
|
|
|
+ "constructor": FCCircle},
|
|
|
+ "rectangle": {"button": self.add_rectangle_btn,
|
|
|
+ "constructor": FCRectangle},
|
|
|
+ "polygon": {"button": self.add_polygon_btn,
|
|
|
+ "constructor": FCPolygon},
|
|
|
+ "path": {"button": self.add_path_btn,
|
|
|
+ "constructor": FCPath}
|
|
|
+ }
|
|
|
+
|
|
|
+ # Data
|
|
|
+ self.active_tool = None
|
|
|
+ self.shape_buffer = []
|
|
|
+
|
|
|
+ self.move_timer = QtCore.QTimer()
|
|
|
+ self.move_timer.setSingleShot(True)
|
|
|
+
|
|
|
+ self.key = None # Currently pressed key
|
|
|
+
|
|
|
+ def make_callback(tool):
|
|
|
+ def f():
|
|
|
+ self.on_tool_select(tool)
|
|
|
+ return f
|
|
|
+
|
|
|
+ for tool in self.tools:
|
|
|
+ self.tools[tool]["button"].triggered.connect(make_callback(tool)) # Events
|
|
|
+ self.tools[tool]["button"].setCheckable(True) # Checkable
|
|
|
+
|
|
|
+ def clear(self):
|
|
|
+ self.active_tool = None
|
|
|
+ self.shape_buffer = []
|
|
|
+ self.replot()
|
|
|
+
|
|
|
+ def on_tool_select(self, tool):
|
|
|
+ """
|
|
|
+
|
|
|
+ :rtype : None
|
|
|
+ """
|
|
|
+ self.app.log.debug("on_tool_select('%s')" % tool)
|
|
|
+
|
|
|
+ # This is to make the group behave as radio group
|
|
|
+ if tool in self.tools:
|
|
|
+ if self.tools[tool]["button"].isChecked():
|
|
|
+ self.app.log.debug("%s is checked.")
|
|
|
+ for t in self.tools:
|
|
|
+ if t != tool:
|
|
|
+ self.tools[t]["button"].setChecked(False)
|
|
|
+
|
|
|
+ self.active_tool = self.tools[tool]["constructor"](self)
|
|
|
+ self.app.info(self.active_tool.start_msg)
|
|
|
+ else:
|
|
|
+ self.app.log.debug("%s is NOT checked.")
|
|
|
+ for t in self.tools:
|
|
|
+ self.tools[t]["button"].setChecked(False)
|
|
|
+ self.active_tool = None
|
|
|
+
|
|
|
+ def on_canvas_click(self, event):
|
|
|
+ """
|
|
|
+ event.x .y have canvas coordinates
|
|
|
+ event.xdaya .ydata have plot coordinates
|
|
|
+
|
|
|
+ :param event:
|
|
|
+ :return:
|
|
|
+ """
|
|
|
+ if self.active_tool is not None:
|
|
|
+ # Dispatch event to active_tool
|
|
|
+ msg = self.active_tool.click((event.xdata, event.ydata))
|
|
|
+ self.app.info(msg)
|
|
|
+
|
|
|
+ # If it is a shape generating tool
|
|
|
+ if isinstance(self.active_tool, FCShapeTool) and self.active_tool.complete:
|
|
|
+ self.on_shape_complete()
|
|
|
+ return
|
|
|
+
|
|
|
+ if isinstance(self.active_tool, FCSelect):
|
|
|
+ self.app.log.debug("Replotting after click.")
|
|
|
+ self.replot()
|
|
|
+
|
|
|
+ def on_canvas_move(self, event):
|
|
|
+ """
|
|
|
+ event.x .y have canvas coordinates
|
|
|
+ event.xdaya .ydata have plot coordinates
|
|
|
+
|
|
|
+ :param event:
|
|
|
+ :return:
|
|
|
+ """
|
|
|
+ self.on_canvas_move_effective(event)
|
|
|
+ return
|
|
|
+
|
|
|
+ self.move_timer.stop()
|
|
|
+
|
|
|
+ if self.active_tool is None:
|
|
|
+ return
|
|
|
+
|
|
|
+ # Make a function to avoid late evaluation
|
|
|
+ def make_callback():
|
|
|
+ def f():
|
|
|
+ self.on_canvas_move_effective(event)
|
|
|
+ return f
|
|
|
+ callback = make_callback()
|
|
|
+
|
|
|
+ self.move_timer.timeout.connect(callback)
|
|
|
+ self.move_timer.start(500) # Stops if aready running
|
|
|
+
|
|
|
+ def on_canvas_move_effective(self, event):
|
|
|
+ """
|
|
|
+ Is called after timeout on timer set in on_canvas_move.
|
|
|
+
|
|
|
+ For details on animating on MPL see:
|
|
|
+ http://wiki.scipy.org/Cookbook/Matplotlib/Animations
|
|
|
+
|
|
|
+ event.x .y have canvas coordinates
|
|
|
+ event.xdaya .ydata have plot coordinates
|
|
|
+
|
|
|
+ :param event:
|
|
|
+ :return:
|
|
|
+ """
|
|
|
+
|
|
|
+ try:
|
|
|
+ x = float(event.xdata)
|
|
|
+ y = float(event.ydata)
|
|
|
+ except TypeError:
|
|
|
+ return
|
|
|
+
|
|
|
+ if self.active_tool is None:
|
|
|
+ return
|
|
|
+
|
|
|
+ geo = self.active_tool.utility_geometry(data=(x, y))
|
|
|
+
|
|
|
+ if geo is not None:
|
|
|
+
|
|
|
+ # Remove any previous utility shape
|
|
|
+ for shape in self.shape_buffer:
|
|
|
+ if shape['utility']:
|
|
|
+ self.shape_buffer.remove(shape)
|
|
|
+
|
|
|
+ # Add the new utility shape
|
|
|
+ self.shape_buffer.append({
|
|
|
+ 'geometry': geo,
|
|
|
+ 'selected': False,
|
|
|
+ 'utility': True
|
|
|
+ })
|
|
|
+
|
|
|
+ # Efficient plotting for fast animation
|
|
|
+ elements = self.plot_shape(geometry=geo, linespec="b--", animated=True)
|
|
|
+ self.canvas.canvas.restore_region(self.canvas.background)
|
|
|
+ for el in elements:
|
|
|
+ self.axes.draw_artist(el)
|
|
|
+ self.canvas.canvas.blit(self.axes.bbox)
|
|
|
+
|
|
|
+ #self.replot()
|
|
|
+
|
|
|
+ def on_canvas_key(self, event):
|
|
|
+ """
|
|
|
+ event.key has the key.
|
|
|
+
|
|
|
+ :param event:
|
|
|
+ :return:
|
|
|
+ """
|
|
|
+ self.key = event.key
|
|
|
+
|
|
|
+ ### Finish the current action. Use with tools that do not
|
|
|
+ ### complete automatically, like a polygon or path.
|
|
|
+ if event.key == ' ':
|
|
|
+ if isinstance(self.active_tool, FCShapeTool):
|
|
|
+ self.active_tool.click((event.xdata, event.ydata))
|
|
|
+ self.active_tool.make()
|
|
|
+ if self.active_tool.complete:
|
|
|
+ self.on_shape_complete()
|
|
|
+ return
|
|
|
+
|
|
|
+ ### Abort the current action
|
|
|
+ if event.key == 'escape':
|
|
|
+ # TODO: ...?
|
|
|
+ self.on_tool_select("select")
|
|
|
+ self.app.info("Cancelled.")
|
|
|
+ for_deletion = [shape for shape in self.shape_buffer if shape['utility']]
|
|
|
+ for shape in for_deletion:
|
|
|
+ self.shape_buffer.remove(shape)
|
|
|
+
|
|
|
+ self.replot()
|
|
|
+ return
|
|
|
+
|
|
|
+ ### Delete selected object
|
|
|
+ if event.key == '-':
|
|
|
+ self.delete_selected()
|
|
|
+ self.replot()
|
|
|
+
|
|
|
+ def on_canvas_key_release(self, event):
|
|
|
+ self.key = None
|
|
|
+
|
|
|
+ def delete_selected(self):
|
|
|
+ for_deletion = [shape for shape in self.shape_buffer if shape["selected"]]
|
|
|
+
|
|
|
+ for shape in for_deletion:
|
|
|
+ self.shape_buffer.remove(shape)
|
|
|
+ self.app.info("Shape deleted.")
|
|
|
+
|
|
|
+ def plot_shape(self, geometry=None, linespec='b-', linewidth=1, animated=False):
|
|
|
+ self.app.log.debug("plot_shape()")
|
|
|
+ plot_elements = []
|
|
|
+
|
|
|
+ if geometry is None:
|
|
|
+ geometry = self.active_tool.geometry
|
|
|
+ try:
|
|
|
+ _ = iter(geometry)
|
|
|
+ iterable_geometry = geometry
|
|
|
+ except TypeError:
|
|
|
+ iterable_geometry = [geometry]
|
|
|
+
|
|
|
+ for geo in iterable_geometry:
|
|
|
+
|
|
|
+ if type(geo) == Polygon:
|
|
|
+ x, y = geo.exterior.coords.xy
|
|
|
+ element, = self.axes.plot(x, y, linespec, linewidth=linewidth, animated=animated)
|
|
|
+ plot_elements.append(element)
|
|
|
+ for ints in geo.interiors:
|
|
|
+ x, y = ints.coords.xy
|
|
|
+ element, = self.axes.plot(x, y, linespec, linewidth=linewidth, animated=animated)
|
|
|
+ plot_elements.append(element)
|
|
|
+ continue
|
|
|
+
|
|
|
+ if type(geo) == LineString or type(geo) == LinearRing:
|
|
|
+ x, y = geo.coords.xy
|
|
|
+ element, = self.axes.plot(x, y, linespec, linewidth=linewidth, animated=animated)
|
|
|
+ plot_elements.append(element)
|
|
|
+ continue
|
|
|
+
|
|
|
+ if type(geo) == MultiPolygon:
|
|
|
+ for poly in geo:
|
|
|
+ x, y = poly.exterior.coords.xy
|
|
|
+ element, = self.axes.plot(x, y, linespec, linewidth=linewidth, animated=animated)
|
|
|
+ plot_elements.append(element)
|
|
|
+ for ints in poly.interiors:
|
|
|
+ x, y = ints.coords.xy
|
|
|
+ element, = self.axes.plot(x, y, linespec, linewidth=linewidth, animated=animated)
|
|
|
+ plot_elements.append(element)
|
|
|
+ continue
|
|
|
+
|
|
|
+ return plot_elements
|
|
|
+ # self.canvas.auto_adjust_axes()
|
|
|
+
|
|
|
+ def plot_all(self):
|
|
|
+ self.app.log.debug("plot_all()")
|
|
|
+ self.axes.cla()
|
|
|
+ for shape in self.shape_buffer:
|
|
|
+ if shape['utility']:
|
|
|
+ self.plot_shape(geometry=shape['geometry'], linespec='k--', linewidth=1)
|
|
|
+ continue
|
|
|
+
|
|
|
+ if shape['selected']:
|
|
|
+ self.plot_shape(geometry=shape['geometry'], linespec='k-', linewidth=2)
|
|
|
+ continue
|
|
|
+
|
|
|
+ self.plot_shape(geometry=shape['geometry'])
|
|
|
+
|
|
|
+ self.canvas.auto_adjust_axes()
|
|
|
+
|
|
|
+ def on_shape_complete(self):
|
|
|
+ self.app.log.debug("on_shape_complete()")
|
|
|
+
|
|
|
+ # For some reason plotting just the last created figure does not
|
|
|
+ # work. The figure is not shown. Calling replot does the trick
|
|
|
+ # which generates a new axes object.
|
|
|
+ #self.plot_shape()
|
|
|
+ #self.canvas.auto_adjust_axes()
|
|
|
+
|
|
|
+ self.shape_buffer.append({'geometry': self.active_tool.geometry,
|
|
|
+ 'selected': False,
|
|
|
+ 'utility': False})
|
|
|
+
|
|
|
+ # Remove any utility shapes
|
|
|
+ for shape in self.shape_buffer:
|
|
|
+ if shape['utility']:
|
|
|
+ self.shape_buffer.remove(shape)
|
|
|
+
|
|
|
+ self.replot()
|
|
|
+ self.active_tool = type(self.active_tool)(self)
|
|
|
+
|
|
|
+ def replot(self):
|
|
|
+ #self.canvas.clear()
|
|
|
+ self.axes = self.canvas.new_axes("draw")
|
|
|
+ self.plot_all()
|
|
|
+
|
|
|
+ def edit_fcgeometry(self, fcgeometry):
|
|
|
+ try:
|
|
|
+ _ = iter(fcgeometry.solid_geometry)
|
|
|
+ geometry = fcgeometry.solid_geometry
|
|
|
+ except TypeError:
|
|
|
+ geometry = [fcgeometry.solid_geometry]
|
|
|
+
|
|
|
+ # Delete contents of editor.
|
|
|
+ self.shape_buffer = []
|
|
|
+
|
|
|
+ # Link shapes into editor.
|
|
|
+ for shape in geometry:
|
|
|
+ self.shape_buffer.append({'geometry': shape,
|
|
|
+ 'selected': False,
|
|
|
+ 'utility': False})
|
|
|
+
|
|
|
+ self.replot()
|
|
|
+ self.drawing_toolbar.setDisabled(False)
|
|
|
+
|
|
|
+ def update_fcgeometry(self, fcgeometry):
|
|
|
+ """
|
|
|
+ Transfers the drawing tool shape buffer to the selected geometry
|
|
|
+ object. The geometry already in the object are removed.
|
|
|
+
|
|
|
+ :param fcgeometry: FlatCAMGeometry
|
|
|
+ :return: None
|
|
|
+ """
|
|
|
+ fcgeometry.solid_geometry = []
|
|
|
+ for shape in self.shape_buffer:
|
|
|
+ fcgeometry.solid_geometry.append(shape['geometry'])
|
|
|
+
|
|
|
+ def union(self):
|
|
|
+ """
|
|
|
+ Makes union of selected polygons. Original polygons
|
|
|
+ are deleted.
|
|
|
+
|
|
|
+ :return: None.
|
|
|
+ """
|
|
|
+ targets = [shape for shape in self.shape_buffer if shape['selected']]
|
|
|
+
|
|
|
+ results = cascaded_union([t['geometry'] for t in targets])
|
|
|
+
|
|
|
+ for shape in targets:
|
|
|
+ self.shape_buffer.remove(shape)
|
|
|
+
|
|
|
+ try:
|
|
|
+ for geo in results:
|
|
|
+
|
|
|
+ self.shape_buffer.append({
|
|
|
+ 'geometry': geo,
|
|
|
+ 'selected': True,
|
|
|
+ 'utility': False
|
|
|
+ })
|
|
|
+ except TypeError:
|
|
|
+ self.shape_buffer.append({
|
|
|
+ 'geometry': results,
|
|
|
+ 'selected': True,
|
|
|
+ 'utility': False
|
|
|
+ })
|
|
|
+
|
|
|
+ self.replot()
|