553 lines
17 KiB
Python
553 lines
17 KiB
Python
|
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()
|