فهرست منبع

Merge Geometry. Excellon coordinate parse fix. New GCode generation algorithm. Improved status bar.

Juan Pablo Caram 11 سال پیش
والد
کامیت
cea41c827e
7فایلهای تغییر یافته به همراه678 افزوده شده و 65 حذف شده
  1. 55 21
      FlatCAMApp.py
  2. 48 4
      FlatCAMGUI.py
  3. 2 1
      FlatCAMObj.py
  4. 434 37
      camlib.py
  5. 1 1
      manual/_theme/sphinx_rtd_theme/layout.html
  6. 14 0
      manual/cmdreference.rst
  7. 124 1
      manual/editor.rst

+ 55 - 21
FlatCAMApp.py

@@ -9,6 +9,7 @@ import re
 import webbrowser
 import os
 import Tkinter
+import re
 
 from PyQt4 import QtCore
 
@@ -186,8 +187,12 @@ class App(QtCore.QObject):
             "zoom_out_key": '2',
             "zoom_in_key": '3',
             "zoom_ratio": 1.5,
-            "point_clipboard_format": "(%.4f, %.4f)"
+            "point_clipboard_format": "(%.4f, %.4f)",
+            "zdownrate": None                   #
         })
+
+        ###############################
+        ### Load defaults from file ###
         self.load_defaults()
 
         chars = 'abcdefghijklmnopqrstuvwxyz0123456789'
@@ -195,6 +200,8 @@ class App(QtCore.QObject):
             self.defaults['serial'] = ''.join([random.choice(chars) for i in range(20)])
             self.save_defaults()
 
+        self.propagate_defaults()
+
         def auto_save_defaults():
             try:
                 self.save_defaults()
@@ -543,14 +550,18 @@ class App(QtCore.QObject):
             self.shell.append_error(''.join(traceback.format_exc()))
             #self.shell.append_error("?\n")
 
-    def info(self, text):
+    def info(self, msg):
         """
         Writes on the status bar.
 
-        :param text: Text to write.
+        :param msg: Text to write.
         :return: None
         """
-        self.ui.info_label.setText(QtCore.QString(text))
+        match = re.search("\[([^\]]+)\](.*)", msg)
+        if match:
+            self.ui.fcinfo.set_status(QtCore.QString(match.group(2)), level=match.group(1))
+        else:
+            self.ui.fcinfo.set_status(QtCore.QString(msg), level="info")
 
     def load_defaults(self):
         """
@@ -783,7 +794,7 @@ class App(QtCore.QObject):
             e = sys.exc_info()[0]
             App.log.error("Could not load defaults file.")
             App.log.error(str(e))
-            self.inform.emit("ERROR: Could not load defaults file.")
+            self.inform.emit("[error] Could not load defaults file.")
             return
 
         try:
@@ -792,7 +803,7 @@ class App(QtCore.QObject):
             e = sys.exc_info()[0]
             App.log.error("Failed to parse defaults file.")
             App.log.error(str(e))
-            self.inform.emit("ERROR: Failed to parse defaults file.")
+            self.inform.emit("[error] Failed to parse defaults file.")
             return
 
         # Update options
@@ -805,7 +816,7 @@ class App(QtCore.QObject):
             json.dump(defaults, f)
             f.close()
         except:
-            self.inform.emit("ERROR: Failed to write defaults to file.")
+            self.inform.emit("[error] Failed to write defaults to file.")
             return
 
         self.inform.emit("Defaults saved.")
@@ -1442,10 +1453,14 @@ class App(QtCore.QObject):
 
             # Opening the file happens here
             self.progress.emit(30)
-            gerber_obj.parse_file(filename, follow=follow)
+            try:
+                gerber_obj.parse_file(filename, follow=follow)
+            except IOError:
+                app_obj.inform.emit("[error] Failed to open file: " + filename)
+                app_obj.progress.emit(0)
 
             # Further parsing
-            self.progress.emit(70)
+            self.progress.emit(70)  # TODO: Note the mixture of self and app_obj used here
 
         # Object name
         name = outname or filename.split('/')[-1].split('\\')[-1]
@@ -1492,7 +1507,14 @@ class App(QtCore.QObject):
         # How the object should be initialized
         def obj_init(excellon_obj, app_obj):
             self.progress.emit(20)
-            excellon_obj.parse_file(filename)
+
+            try:
+                excellon_obj.parse_file(filename)
+            except IOError:
+                app_obj.inform.emit("[error] Cannot open file: " + filename)
+                self.progress.emit(0)  # TODO: self and app_bjj mixed
+                raise IOError
+
             excellon_obj.create_geometry()
             self.progress.emit(70)
 
@@ -1599,14 +1621,14 @@ class App(QtCore.QObject):
             f = open(filename, 'r')
         except IOError:
             App.log.error("Failed to open project file: %s" % filename)
-            self.inform.emit("ERROR: Failed to open project file: %s" % filename)
+            self.inform.emit("[error] Failed to open project file: %s" % filename)
             return
 
         try:
             d = json.load(f, object_hook=dict2obj)
         except:
             App.log.error("Failed to parse project file: %s" % filename)
-            self.inform.emit("ERROR: Failed to parse project file: %s" % filename)
+            self.inform.emit("[error] Failed to parse project file: %s" % filename)
             f.close()
             return
 
@@ -1633,6 +1655,15 @@ class App(QtCore.QObject):
         self.inform.emit("Project loaded from: " + filename)
         App.log.debug("Project loaded")
 
+    def propagate_defaults(self):
+
+        routes = {
+            "zdownrate": CNCjob
+        }
+
+        for param in routes:
+            routes[param].defaults[param] = self.defaults[param]
+
     def plot_all(self):
         """
         Re-generates all plots from all objects.
@@ -1948,6 +1979,7 @@ class App(QtCore.QObject):
         def set_sys(param, value):
             if param in self.defaults:
                 self.defaults[param] = value
+                self.propagate_defaults()
                 return
 
             return "ERROR: No such system parameter."
@@ -2165,14 +2197,14 @@ class App(QtCore.QObject):
             f = open('recent.json')
         except IOError:
             App.log.error("Failed to load recent item list.")
-            self.inform.emit("ERROR: Failed to load recent item list.")
+            self.inform.emit("[error] Failed to load recent item list.")
             return
 
         try:
             self.recent = json.load(f)
         except json.scanner.JSONDecodeError:
             App.log.error("Failed to parse recent item list.")
-            self.inform.emit("ERROR: Failed to parse recent item list.")
+            self.inform.emit("[error] Failed to parse recent item list.")
             f.close()
             return
         f.close()
@@ -2190,11 +2222,13 @@ class App(QtCore.QObject):
         # Create menu items
         for recent in self.recent:
             filename = recent['filename'].split('/')[-1].split('\\')[-1]
+
             action = QtGui.QAction(QtGui.QIcon(icons[recent["kind"]]), filename, self)
 
+            # Attach callback
             o = make_callback(openers[recent["kind"]], recent['filename'])
-
             action.triggered.connect(o)
+
             self.ui.recent.addAction(action)
 
         # self.builder.get_object('open_recent').set_submenu(recent_menu)
@@ -2235,14 +2269,14 @@ class App(QtCore.QObject):
             f = urllib.urlopen(full_url)
         except:
             App.log.warning("Failed checking for latest version. Could not connect.")
-            self.inform.emit("Failed checking for latest version. Could not connect.")
+            self.inform.emit("[warning] Failed checking for latest version. Could not connect.")
             return
 
         try:
             data = json.load(f)
         except Exception, e:
             App.log.error("Could not parse information about latest version.")
-            self.inform.emit("Could not parse information about latest version.")
+            self.inform.emit("[error] Could not parse information about latest version.")
             App.log.debug("json.load(): %s" % str(e))
             f.close()
             return
@@ -2251,7 +2285,7 @@ class App(QtCore.QObject):
 
         if self.version >= data["version"]:
             App.log.debug("FlatCAM is up to date!")
-            self.inform.emit("FlatCAM is up to date!")
+            self.inform.emit("[success] FlatCAM is up to date!")
             return
 
         App.log.debug("Newer version available.")
@@ -2301,7 +2335,7 @@ class App(QtCore.QObject):
         try:
             self.collection.get_active().read_form()
         except:
-            self.log.debug("There was no active object")
+            self.log.debug("[warning] There was no active object")
             pass
         # Project options
         self.options_read_form()
@@ -2315,14 +2349,14 @@ class App(QtCore.QObject):
         try:
             f = open(filename, 'w')
         except IOError:
-            App.log.error("ERROR: Failed to open file for saving:", filename)
+            App.log.error("[error] Failed to open file for saving:", filename)
             return
 
         # Write
         try:
             json.dump(d, f, default=to_dict)
         except:
-            App.log.error("ERROR: File open but failed to write:", filename)
+            App.log.error("[error] File open but failed to write:", filename)
             f.close()
             return
 

+ 48 - 4
FlatCAMGUI.py

@@ -197,12 +197,14 @@ class FlatCAMGUI(QtGui.QMainWindow):
         ################
         infobar = self.statusBar()
 
-        self.info_label = QtGui.QLabel("Welcome to FlatCAM.")
-        self.info_label.setFrameStyle(QtGui.QFrame.StyledPanel | QtGui.QFrame.Plain)
-        infobar.addWidget(self.info_label, stretch=1)
+        #self.info_label = QtGui.QLabel("Welcome to FlatCAM.")
+        #self.info_label.setFrameStyle(QtGui.QFrame.StyledPanel | QtGui.QFrame.Plain)
+        #infobar.addWidget(self.info_label, stretch=1)
+        self.fcinfo = FlatCAMInfoBar()
+        infobar.addWidget(self.fcinfo, stretch=1)
 
         self.position_label = QtGui.QLabel("")
-        self.position_label.setFrameStyle(QtGui.QFrame.StyledPanel | QtGui.QFrame.Plain)
+        #self.position_label.setFrameStyle(QtGui.QFrame.StyledPanel | QtGui.QFrame.Plain)
         self.position_label.setMinimumWidth(110)
         infobar.addWidget(self.position_label)
 
@@ -233,6 +235,48 @@ class FlatCAMGUI(QtGui.QMainWindow):
         self.show()
 
 
+class FlatCAMInfoBar(QtGui.QWidget):
+
+    def __init__(self, parent=None):
+        super(FlatCAMInfoBar, self).__init__(parent=parent)
+
+        self.icon = QtGui.QLabel(self)
+        self.icon.setGeometry(0, 0, 12, 12)
+        self.pmap = QtGui.QPixmap('share/graylight12.png')
+        self.icon.setPixmap(self.pmap)
+
+        layout = QtGui.QHBoxLayout()
+        layout.setContentsMargins(5, 0, 5, 0)
+        self.setLayout(layout)
+
+        layout.addWidget(self.icon)
+
+        self.text = QtGui.QLabel(self)
+        self.text.setText("Hello!")
+
+        layout.addWidget(self.text)
+
+        layout.addStretch()
+
+    def set_text_(self, text):
+        self.text.setText(text)
+
+    def set_status(self, text, level="info"):
+        level = str(level)
+        self.pmap.fill()
+        if level == "error":
+            self.pmap = QtGui.QPixmap('share/redlight12.png')
+        elif level == "success":
+            self.pmap = QtGui.QPixmap('share/greenlight12.png')
+        elif level == "warning":
+            self.pmap = QtGui.QPixmap('share/yellowlight12.png')
+        else:
+            self.pmap = QtGui.QPixmap('share/graylight12.png')
+
+        self.icon.setPixmap(self.pmap)
+        self.set_text_(text)
+
+
 class OptionsGroupUI(QtGui.QGroupBox):
     def __init__(self, title, parent=None):
         QtGui.QGroupBox.__init__(self, title, parent=parent)

+ 2 - 1
FlatCAMObj.py

@@ -988,7 +988,8 @@ class FlatCAMGeometry(FlatCAMObj, Geometry):
             # GLib.idle_add(lambda: app_obj.set_progress_bar(0.4, "Analyzing Geometry..."))
             app_obj.progress.emit(40)
             # TODO: The tolerance should not be hard coded. Just for testing.
-            job_obj.generate_from_geometry(self, tolerance=0.0005)
+            #job_obj.generate_from_geometry(self, tolerance=0.0005)
+            job_obj.generate_from_geometry_2(self, tolerance=0.0005)
 
             # GLib.idle_add(lambda: app_obj.set_progress_bar(0.5, "Parsing G-Code..."))
             app_obj.progress.emit(50)

+ 434 - 37
camlib.py

@@ -5,13 +5,21 @@
 # Date: 2/5/2014                                           #
 # MIT Licence                                              #
 ############################################################
-
+#from __future__ import division
 import traceback
 
 from numpy import arctan2, Inf, array, sqrt, pi, ceil, sin, cos
 from matplotlib.figure import Figure
 import re
 
+import collections
+import numpy as np
+import matplotlib
+import matplotlib.pyplot as plt
+from scipy.spatial import Delaunay, KDTree
+
+from rtree import index as rtindex
+
 # See: http://toblerity.org/shapely/manual.html
 from shapely.geometry import Polygon, LineString, Point, LinearRing
 from shapely.geometry import MultiPoint, MultiPolygon
@@ -54,20 +62,17 @@ class Geometry(object):
         # Units (in or mm)
         self.units = Geometry.defaults["init_units"]
         
-        # Final geometry: MultiPolygon
+        # Final geometry: MultiPolygon or list (of geometry constructs)
         self.solid_geometry = None
 
         # Attributes to be included in serialization
         self.ser_attrs = ['units', 'solid_geometry']
 
-    def union(self):
-        """
-        Runs a cascaded union on the list of objects in
-        solid_geometry.
+        # Flattened geometry (list of paths only)
+        self.flat_geometry = []
 
-        :return: None
-        """
-        self.solid_geometry = [cascaded_union(self.solid_geometry)]
+        # Flat geometry rtree index
+        self.flat_geometry_rtree = rtindex.Index()
 
     def add_circle(self, origin, radius):
         """
@@ -112,18 +117,6 @@ class Geometry(object):
             print "Failed to run union on polygons."
             raise
 
-    def isolation_geometry(self, offset):
-        """
-        Creates contours around geometry at a given
-        offset distance.
-
-        :param offset: Offset distance.
-        :type offset: float
-        :return: The buffered geometry.
-        :rtype: Shapely.MultiPolygon or Shapely.Polygon
-        """
-        return self.solid_geometry.buffer(offset)
-        
     def bounds(self):
         """
         Returns coordinates of rectangular bounds
@@ -133,19 +126,70 @@ class Geometry(object):
         if self.solid_geometry is None:
             log.debug("solid_geometry is None")
             log.warning("solid_geometry not computed yet.")
-            return (0, 0, 0, 0)
-            
+            return 0, 0, 0, 0
+
         if type(self.solid_geometry) is list:
             log.debug("type(solid_geometry) is list")
             # TODO: This can be done faster. See comment from Shapely mailing lists.
             if len(self.solid_geometry) == 0:
                 log.debug('solid_geometry is empty []')
-                return (0, 0, 0, 0)
+                return 0, 0, 0, 0
             log.debug('solid_geometry is not empty, returning cascaded union of items')
             return cascaded_union(self.solid_geometry).bounds
         else:
             log.debug("type(solid_geometry) is not list, returning .bounds property")
             return self.solid_geometry.bounds
+
+    def flatten_to_paths(self, geometry=None, reset=True):
+        """
+        Creates a list of non-iterable linear geometry elements and
+        indexes them in rtree.
+
+        :param geometry: Iterable geometry
+        :param reset: Wether to clear (True) or append (False) to self.flat_geometry
+        :return: self.flat_geometry, self.flat_geometry_rtree
+        """
+
+        if geometry is None:
+            geometry = self.solid_geometry
+
+        if reset:
+            self.flat_geometry = []
+
+        try:
+            for geo in geometry:
+                self.flatten_to_paths(geometry=geo, reset=False)
+        except TypeError:
+            if type(geometry) == Polygon:
+                g = geometry.exterior
+                self.flat_geometry.append(g)
+                self.flat_geometry_rtree.insert(len(self.flat_geometry)-1, g.coords[0])
+                self.flat_geometry_rtree.insert(len(self.flat_geometry)-1, g.coords[-1])
+
+                for interior in geometry.interiors:
+                    g = interior
+                    self.flat_geometry.append(g)
+                    self.flat_geometry_rtree.insert(len(self.flat_geometry)-1, g.coords[0])
+                    self.flat_geometry_rtree.insert(len(self.flat_geometry)-1, g.coords[-1])
+            else:
+                g = geometry
+                self.flat_geometry.append(g)
+                self.flat_geometry_rtree.insert(len(self.flat_geometry)-1, g.coords[0])
+                self.flat_geometry_rtree.insert(len(self.flat_geometry)-1, g.coords[-1])
+
+        return self.flat_geometry, self.flat_geometry_rtree
+
+    def isolation_geometry(self, offset):
+        """
+        Creates contours around geometry at a given
+        offset distance.
+
+        :param offset: Offset distance.
+        :type offset: float
+        :return: The buffered geometry.
+        :rtype: Shapely.MultiPolygon or Shapely.Polygon
+        """
+        return self.solid_geometry.buffer(offset)
         
     def size(self):
         """
@@ -260,6 +304,16 @@ class Geometry(object):
         for attr in self.ser_attrs:
             setattr(self, attr, d[attr])
 
+    def union(self):
+        """
+        Runs a cascaded union on the list of objects in
+        solid_geometry.
+
+        :return: None
+        """
+        self.solid_geometry = [cascaded_union(self.solid_geometry)]
+
+
 
 class ApertureMacro:
     """
@@ -666,7 +720,11 @@ class Gerber (Geometry):
 
     """
 
-    def __init__(self):
+    defaults = {
+        "steps_per_circle": 40
+    }
+
+    def __init__(self, steps_per_circle=None):
         """
         The constructor takes no parameters. Use ``gerber.parse_files()``
         or ``gerber.parse_lines()`` to populate the object from Gerber source.
@@ -676,7 +734,7 @@ class Gerber (Geometry):
         """
 
         # Initialize parent
-        Geometry.__init__(self)        
+        Geometry.__init__(self)
 
         self.solid_geometry = Polygon()
 
@@ -778,8 +836,8 @@ class Gerber (Geometry):
         self.am1_re = re.compile(r'^%AM([^\*]+)\*([^%]+)?(%)?$')
         self.am2_re = re.compile(r'(.*)%$')
 
-        # TODO: This is bad.
-        self.steps_per_circ = 40
+        # How to discretize a circle.
+        self.steps_per_circ = steps_per_circle or Gerber.defaults['steps_per_circle']
 
     def scale(self, factor):
         """
@@ -1836,8 +1894,13 @@ class CNCjob(Geometry):
                            "C" (cut). B is "F" (fast) or "S" (slow).
     =====================  =========================================
     """
+
+    defaults = {
+        "zdownrate": None
+    }
+
     def __init__(self, units="in", kind="generic", z_move=0.1,
-                 feedrate=3.0, z_cut=-0.002, tooldia=0.0):
+                 feedrate=3.0, z_cut=-0.002, tooldia=0.0, zdownrate=None):
 
         Geometry.__init__(self)
         self.kind = kind
@@ -1854,6 +1917,11 @@ class CNCjob(Geometry):
         self.input_geometry_bounds = None
         self.gcode_parsed = None
         self.steps_per_circ = 20  # Used when parsing G-code arcs
+        if zdownrate is not None:
+            self.zdownrate = float(zdownrate)
+        elif CNCjob.defaults["zdownrate"] is not None:
+            self.zdownrate = float(CNCjob.defaults["zdownrate"])
+
 
         # Attributes to be included in serialization
         # Always append to it because it carries contents
@@ -1862,6 +1930,34 @@ class CNCjob(Geometry):
                            'gcode', 'input_geometry_bounds', 'gcode_parsed',
                            'steps_per_circ']
 
+        # Buffer for linear (No polygons or iterable geometry) elements
+        # and their properties.
+        self.flat_geometry = []
+
+        # 2D index of self.flat_geometry
+        self.flat_geometry_rtree = rtindex.Index()
+
+        # Current insert position to flat_geometry
+        self.fg_current_index = 0
+
+    def flatten(self, geo):
+        """
+        Flattens the input geometry into an array of non-iterable geometry
+        elements and indexes into rtree by their first and last coordinate
+        pairs.
+
+        :param geo:
+        :return:
+        """
+        try:
+            for g in geo:
+                self.flatten(g)
+        except TypeError:  # is not iterable
+            self.flat_geometry.append({"path": geo})
+            self.flat_geometry_rtree.insert(self.fg_current_index, geo.coords[0])
+            self.flat_geometry_rtree.insert(self.fg_current_index, geo.coords[-1])
+            self.fg_current_index += 1
+
     def convert_units(self, units):
         factor = Geometry.convert_units(self, units)
         log.debug("CNCjob.convert_units()")
@@ -1986,14 +2082,17 @@ class CNCjob(Geometry):
         if not append:
             self.gcode = ""
 
+        # Initial G-Code
         self.gcode = self.unitcode[self.units.upper()] + "\n"
         self.gcode += self.absolutecode + "\n"
         self.gcode += self.feedminutecode + "\n"
         self.gcode += "F%.2f\n" % self.feedrate
-        self.gcode += "G00 Z%.4f\n" % self.z_move  # Move to travel height
+        self.gcode += "G00 Z%.4f\n" % self.z_move  # Move (up) to travel height
         self.gcode += "M03\n"  # Spindle start
         self.gcode += self.pausecode + "\n"
-        
+
+        # Iterate over geometry and run individual methods
+        # depending on type
         for geo in geometry.solid_geometry:
             
             if type(geo) == Polygon:
@@ -2005,7 +2104,6 @@ class CNCjob(Geometry):
                 continue
             
             if type(geo) == Point:
-                # TODO: point2gcode does not return anything...
                 self.gcode += self.point2gcode(geo)
                 continue
 
@@ -2016,6 +2114,74 @@ class CNCjob(Geometry):
 
             log.warning("G-code generation not implemented for %s" % (str(type(geo))))
 
+        # Finish
+        self.gcode += "G00 Z%.4f\n" % self.z_move  # Stop cutting
+        self.gcode += "G00 X0Y0\n"
+        self.gcode += "M05\n"  # Spindle stop
+
+    def generate_from_geometry_2(self, geometry, append=True, tooldia=None, tolerance=0):
+        """
+        Second algorithm to generate from Geometry.
+
+        :param geometry:
+        :param append:
+        :param tooldia:
+        :param tolerance:
+        :return:
+        """
+        assert isinstance(geometry, Geometry)
+        flat_geometry, rtindex = geometry.flatten_to_paths()
+
+        if tooldia is not None:
+            self.tooldia = tooldia
+
+        self.input_geometry_bounds = geometry.bounds()
+
+        if not append:
+            self.gcode = ""
+
+        # Initial G-Code
+        self.gcode = self.unitcode[self.units.upper()] + "\n"
+        self.gcode += self.absolutecode + "\n"
+        self.gcode += self.feedminutecode + "\n"
+        self.gcode += "F%.2f\n" % self.feedrate
+        self.gcode += "G00 Z%.4f\n" % self.z_move  # Move (up) to travel height
+        self.gcode += "M03\n"  # Spindle start
+        self.gcode += self.pausecode + "\n"
+
+        # Iterate over geometry and run individual methods
+        # depending on type
+        # for geo in flat_geometry:
+        #
+        #     if type(geo) == LineString or type(geo) == LinearRing:
+        #         self.gcode += self.linear2gcode(geo, tolerance=tolerance)
+        #         continue
+        #
+        #     if type(geo) == Point:
+        #         self.gcode += self.point2gcode(geo)
+        #         continue
+        #
+        #     log.warning("G-code generation not implemented for %s" % (str(type(geo))))
+
+        hits = list(rtindex.nearest((0, 0), 1))
+        while len(hits) > 0:
+            geo = flat_geometry[hits[0]]
+
+            if type(geo) == LineString or type(geo) == LinearRing:
+                self.gcode += self.linear2gcode(geo, tolerance=tolerance)
+            elif type(geo) == Point:
+                self.gcode += self.point2gcode(geo)
+            else:
+                log.warning("G-code generation not implemented for %s" % (str(type(geo))))
+
+            start_pt = geo.coords[0]
+            stop_pt = geo.coords[-1]
+            rtindex.delete(hits[0], start_pt)
+            rtindex.delete(hits[0], stop_pt)
+            hits = list(rtindex.nearest(stop_pt, 1))
+
+
+        # Finish
         self.gcode += "G00 Z%.4f\n" % self.z_move  # Stop cutting
         self.gcode += "G00 X0Y0\n"
         self.gcode += "M05\n"  # Spindle stop
@@ -2262,14 +2428,28 @@ class CNCjob(Geometry):
         t = "G0%d X%.4fY%.4f\n"
         path = list(target_polygon.exterior.coords)             # Polygon exterior
         gcode += t % (0, path[0][0], path[0][1])  # Move to first point
-        gcode += "G01 Z%.4f\n" % self.z_cut       # Start cutting
+
+        if self.zdownrate is not None:
+            gcode += "F%.2f\n" % self.zdownrate
+            gcode += "G01 Z%.4f\n" % self.z_cut       # Start cutting
+            gcode += "F%.2f\n" % self.feedrate
+        else:
+            gcode += "G01 Z%.4f\n" % self.z_cut       # Start cutting
+
         for pt in path[1:]:
             gcode += t % (1, pt[0], pt[1])    # Linear motion to point
         gcode += "G00 Z%.4f\n" % self.z_move  # Stop cutting
         for ints in target_polygon.interiors:               # Polygon interiors
             path = list(ints.coords)
             gcode += t % (0, path[0][0], path[0][1])  # Move to first point
-            gcode += "G01 Z%.4f\n" % self.z_cut       # Start cutting
+
+            if self.zdownrate is not None:
+                gcode += "F%.2f\n" % self.zdownrate
+                gcode += "G01 Z%.4f\n" % self.z_cut       # Start cutting
+                gcode += "F%.2f\n" % self.feedrate
+            else:
+                gcode += "G01 Z%.4f\n" % self.z_cut       # Start cutting
+
             for pt in path[1:]:
                 gcode += t % (1, pt[0], pt[1])    # Linear motion to point
             gcode += "G00 Z%.4f\n" % self.z_move  # Stop cutting
@@ -2297,20 +2477,34 @@ class CNCjob(Geometry):
         t = "G0%d X%.4fY%.4f\n"
         path = list(target_linear.coords)
         gcode += t % (0, path[0][0], path[0][1])  # Move to first point
-        gcode += "G01 Z%.4f\n" % self.z_cut       # Start cutting
+
+        if self.zdownrate is not None:
+            gcode += "F%.2f\n" % self.zdownrate
+            gcode += "G01 Z%.4f\n" % self.z_cut       # Start cutting
+            gcode += "F%.2f\n" % self.feedrate
+        else:
+            gcode += "G01 Z%.4f\n" % self.z_cut       # Start cutting
+
         for pt in path[1:]:
             gcode += t % (1, pt[0], pt[1])    # Linear motion to point
         gcode += "G00 Z%.4f\n" % self.z_move  # Stop cutting
         return gcode
 
     def point2gcode(self, point):
-        # TODO: This is not doing anything.
         gcode = ""
         t = "G0%d X%.4fY%.4f\n"
         path = list(point.coords)
         gcode += t % (0, path[0][0], path[0][1])  # Move to first point
-        gcode += "G01 Z%.4f\n" % self.z_cut       # Start cutting
+
+        if self.zdownrate is not None:
+            gcode += "F%.2f\n" % self.zdownrate
+            gcode += "G01 Z%.4f\n" % self.z_cut       # Start cutting
+            gcode += "F%.2f\n" % self.feedrate
+        else:
+            gcode += "G01 Z%.4f\n" % self.z_cut       # Start cutting
+
         gcode += "G00 Z%.4f\n" % self.z_move      # Stop cutting
+        return gcode
 
     def scale(self, factor):
         """
@@ -2384,6 +2578,7 @@ def get_bounds(geometry_list):
 
     return [xmin, ymin, xmax, ymax]
 
+
 def arc(center, radius, start, stop, direction, steps_per_circ):
     """
     Creates a list of point along the specified arc.
@@ -2552,3 +2747,205 @@ def parse_gerber_number(strnumber, frac_digits):
     """
     return int(strnumber)*(10**(-frac_digits))
 
+
+def voronoi(P):
+    """
+    Returns a list of all edges of the voronoi diagram for the given input points.
+    """
+    delauny = Delaunay(P)
+    triangles = delauny.points[delauny.vertices]
+
+    circum_centers = np.array([triangle_csc(tri) for tri in triangles])
+    long_lines_endpoints = []
+
+    lineIndices = []
+    for i, triangle in enumerate(triangles):
+        circum_center = circum_centers[i]
+        for j, neighbor in enumerate(delauny.neighbors[i]):
+            if neighbor != -1:
+                lineIndices.append((i, neighbor))
+            else:
+                ps = triangle[(j+1)%3] - triangle[(j-1)%3]
+                ps = np.array((ps[1], -ps[0]))
+
+                middle = (triangle[(j+1)%3] + triangle[(j-1)%3]) * 0.5
+                di = middle - triangle[j]
+
+                ps /= np.linalg.norm(ps)
+                di /= np.linalg.norm(di)
+
+                if np.dot(di, ps) < 0.0:
+                    ps *= -1000.0
+                else:
+                    ps *= 1000.0
+
+                long_lines_endpoints.append(circum_center + ps)
+                lineIndices.append((i, len(circum_centers) + len(long_lines_endpoints)-1))
+
+    vertices = np.vstack((circum_centers, long_lines_endpoints))
+
+    # filter out any duplicate lines
+    lineIndicesSorted = np.sort(lineIndices) # make (1,2) and (2,1) both (1,2)
+    lineIndicesTupled = [tuple(row) for row in lineIndicesSorted]
+    lineIndicesUnique = np.unique(lineIndicesTupled)
+
+    return vertices, lineIndicesUnique
+
+
+def triangle_csc(pts):
+    rows, cols = pts.shape
+
+    A = np.bmat([[2 * np.dot(pts, pts.T), np.ones((rows, 1))],
+                 [np.ones((1, rows)), np.zeros((1, 1))]])
+
+    b = np.hstack((np.sum(pts * pts, axis=1), np.ones((1))))
+    x = np.linalg.solve(A,b)
+    bary_coords = x[:-1]
+    return np.sum(pts * np.tile(bary_coords.reshape((pts.shape[0], 1)), (1, pts.shape[1])), axis=0)
+
+
+def voronoi_cell_lines(points, vertices, lineIndices):
+    """
+    Returns a mapping from a voronoi cell to its edges.
+
+    :param points: shape (m,2)
+    :param vertices: shape (n,2)
+    :param lineIndices: shape (o,2)
+    :rtype: dict point index -> list of shape (n,2) with vertex indices
+    """
+    kd = KDTree(points)
+
+    cells = collections.defaultdict(list)
+    for i1, i2 in lineIndices:
+        v1, v2 = vertices[i1], vertices[i2]
+        mid = (v1+v2)/2
+        _, (p1Idx, p2Idx) = kd.query(mid, 2)
+        cells[p1Idx].append((i1, i2))
+        cells[p2Idx].append((i1, i2))
+
+    return cells
+
+
+def voronoi_edges2polygons(cells):
+    """
+    Transforms cell edges into polygons.
+
+    :param cells: as returned from voronoi_cell_lines
+    :rtype: dict point index -> list of vertex indices which form a polygon
+    """
+
+    # first, close the outer cells
+    for pIdx, lineIndices_ in cells.items():
+        dangling_lines = []
+        for i1, i2 in lineIndices_:
+            connections = filter(lambda (i1_, i2_): (i1, i2) != (i1_, i2_) and (i1 == i1_ or i1 == i2_ or i2 == i1_ or i2 == i2_), lineIndices_)
+            assert 1 <= len(connections) <= 2
+            if len(connections) == 1:
+                dangling_lines.append((i1, i2))
+        assert len(dangling_lines) in [0, 2]
+        if len(dangling_lines) == 2:
+            (i11, i12), (i21, i22) = dangling_lines
+
+            # determine which line ends are unconnected
+            connected = filter(lambda (i1,i2): (i1,i2) != (i11,i12) and (i1 == i11 or i2 == i11), lineIndices_)
+            i11Unconnected = len(connected) == 0
+
+            connected = filter(lambda (i1,i2): (i1,i2) != (i21,i22) and (i1 == i21 or i2 == i21), lineIndices_)
+            i21Unconnected = len(connected) == 0
+
+            startIdx = i11 if i11Unconnected else i12
+            endIdx = i21 if i21Unconnected else i22
+
+            cells[pIdx].append((startIdx, endIdx))
+
+    # then, form polygons by storing vertex indices in (counter-)clockwise order
+    polys = dict()
+    for pIdx, lineIndices_ in cells.items():
+        # get a directed graph which contains both directions and arbitrarily follow one of both
+        directedGraph = lineIndices_ + [(i2, i1) for (i1, i2) in lineIndices_]
+        directedGraphMap = collections.defaultdict(list)
+        for (i1, i2) in directedGraph:
+            directedGraphMap[i1].append(i2)
+        orderedEdges = []
+        currentEdge = directedGraph[0]
+        while len(orderedEdges) < len(lineIndices_):
+            i1 = currentEdge[1]
+            i2 = directedGraphMap[i1][0] if directedGraphMap[i1][0] != currentEdge[0] else directedGraphMap[i1][1]
+            nextEdge = (i1, i2)
+            orderedEdges.append(nextEdge)
+            currentEdge = nextEdge
+
+        polys[pIdx] = [i1 for (i1, i2) in orderedEdges]
+
+    return polys
+
+
+def voronoi_polygons(points):
+    """
+    Returns the voronoi polygon for each input point.
+
+    :param points: shape (n,2)
+    :rtype: list of n polygons where each polygon is an array of vertices
+    """
+    vertices, lineIndices = voronoi(points)
+    cells = voronoi_cell_lines(points, vertices, lineIndices)
+    polys = voronoi_edges2polygons(cells)
+    polylist = []
+    for i in xrange(len(points)):
+        poly = vertices[np.asarray(polys[i])]
+        polylist.append(poly)
+    return polylist
+
+
+class Zprofile:
+    def __init__(self):
+
+        # data contains lists of [x, y, z]
+        self.data = []
+
+        # Computed voronoi polygons (shapely)
+        self.polygons = []
+        pass
+
+    def plot_polygons(self):
+        axes = plt.subplot(1, 1, 1)
+
+        plt.axis([-0.05, 1.05, -0.05, 1.05])
+
+        for poly in self.polygons:
+            p = PolygonPatch(poly, facecolor=np.random.rand(3, 1), alpha=0.3)
+            axes.add_patch(p)
+
+    def init_from_csv(self, filename):
+        pass
+
+    def init_from_string(self, zpstring):
+        pass
+
+    def init_from_list(self, zplist):
+        self.data = zplist
+
+    def generate_polygons(self):
+        self.polygons = [Polygon(p) for p in voronoi_polygons(array([[x[0], x[1]] for x in self.data]))]
+
+    def normalize(self, origin):
+        pass
+
+    def paste(self, path):
+        """
+        Return a list of dictionaries containing the parts of the original
+        path and their z-axis offset.
+        """
+
+        # At most one region/polygon will contain the path
+        containing = [i for i in range(len(self.polygons)) if self.polygons[i].contains(path)]
+
+        if len(containing) > 0:
+            return [{"path": path, "z": self.data[containing[0]][2]}]
+
+        # All region indexes that intersect with the path
+        crossing = [i for i in range(len(self.polygons)) if self.polygons[i].intersects(path)]
+
+        return [{"path": path.intersection(self.polygons[i]),
+                 "z": self.data[i][2]} for i in crossing]
+

+ 1 - 1
manual/_theme/sphinx_rtd_theme/layout.html

@@ -120,7 +120,7 @@
         <!--<a href="{{ pathto(master_doc) }}" class="icon icon-home"> {{ project }}</a>-->
         <!--<a href="http://flatcam.org" class="icon icon-home"> {{ project }}</a>-->
         <a href="http://flatcam.org">
-            <img src="http://flatcam.org/static/images/fcweblogo1_halloween.png"
+            <img src="http://flatcam.org/static/images/fcweblogo1.png"
                     style="height: auto;
                     width: auto;
                     border-radius: 0px;

+ 14 - 0
manual/cmdreference.rst

@@ -6,6 +6,8 @@ Shell Command Reference
 .. warning::
     The FlatCAM Shell is under development and its behavior might change in the future. This includes available commands and their syntax.
 
+.. _add_circle:
+
 add_circle
 ~~~~~~~~~~
 Creates a circle in the given Geometry object.
@@ -17,6 +19,8 @@ Creates a circle in the given Geometry object.
 
        radius: Radius of the circle.
 
+.. _add_poly:
+
 add_poly
 ~~~~~~~~
 Creates a polygon in the given Geometry object.
@@ -26,6 +30,8 @@ Creates a polygon in the given Geometry object.
 
        xi, yi: Coordinates of points in the polygon.
 
+.. _add_rect:
+
 add_rect
 ~~~~~~~~
 Creates a rectange in the given Geometry object.
@@ -70,6 +76,8 @@ Creates a geometry object following gerber paths.
 
        outname: Name of the output geometry object.
 
+.. _geo_union:
+
 geo_union
 ~~~~~~~~~
 Runs a union operation (addition) on the components of the geometry object. For example, if it contains 2 intersecting polygons, this opperation adds them intoa single larger polygon.
@@ -114,6 +122,8 @@ Starts a new project. Clears objects from memory.
     > new
        No parameters.
 
+.. _new_geometry:
+
 new_geometry
 ~~~~~~~~~~~~
 Creates a new empty geometry object.
@@ -121,6 +131,8 @@ Creates a new empty geometry object.
     > new_geometry <name>
        name: New object name
 
+.. _offset:
+
 offset
 ~~~~~~
 Changes the position of the object.
@@ -200,6 +212,8 @@ Saves the FlatCAM project to file.
     > save_project <filename>
        filename: Path to file to save.
 
+.. _scale:
+
 scale
 ~~~~~
 Resizes the object by a factor.

+ 124 - 1
manual/editor.rst

@@ -1,7 +1,130 @@
 Geometry Editor
 ===============
 
+Introduction
+------------
+
 The Geometry Editor is a drawing CAD that allows you to edit
 FlatCAM Geometry Objects or create new ones from scratch. This
 provides the ultimate flexibility by letting you specify precisely
-and arbitrarily what you want your CNC router to do.
+and arbitrarily what you want your CNC router to do.
+
+Creating New Geometry Objects
+-----------------------------
+
+To create a blank Geometry Object, simply click on the menu item
+**Edit→New Geometry Object** or click the **New Blank Geometry** button on
+the toolbar. A Geometry object with the name "New Geometry" will
+be added to your project list.
+
+.. image:: editor1.png
+   :align: center
+
+.. seealso::
+
+   FlatCAM Shell command :ref:`new_geometry`
+
+
+Editing Existing Geometry Objects
+---------------------------------
+
+To edit a Geometry Object, select it from the project list and
+click on the menu item **Edit→Edit Geometry** or on the **Edit Geometry**
+toolbar button.
+
+This will make a copy of the selected object in the editor and
+the editor toolbar buttons will become active.
+
+Changes made to the geometry in the editor will not affect the
+Geometry Object until the **Edit->Update Geometry** button or
+**Update Geometry** toolbar button is clicked.
+This replaces the geometry in the currently selected Geometry
+Object (which can be different from which the editor copied its
+contents originally) with the geometry in the editor.
+
+Selecting Shapes
+~~~~~~~~~~~~~~~~
+
+When the **Selection Tool** is active in the toolbar (Hit ``Esc``), clicking on the
+plot will select the nearest shape. If one shape is inside the other,
+you might need to move the outer one to get to the inner one. This
+behavior might be improved in the future.
+
+Holding the ``Control`` key while clicking will add the nearest shape
+to the set of selected objects.
+
+Creating Shapes
+~~~~~~~~~~~~~~~
+
+The shape creation tools in the editor are:
+
+* Circle
+* Rectangle
+* Polygon
+* Path
+
+.. image:: editor2.png
+   :align: center
+
+After clicking on the respective toolbar button, follow the instructions
+on the status bar.
+
+Shapes that do not require a fixed number of clicks to complete, like
+polygons and paths, are complete by hitting the ``Space`` key.
+
+.. seealso::
+
+   The FlatCAM Shell commands :ref:`add_circle`, :ref:`add_poly` and :ref:`add_rect`,
+   create shapes directly on a given Geometry Object.
+
+Union
+~~~~~
+
+Clicking on the **Union** tool after selecting two or more shapes
+will create a union. For closed shapes, their union is a polygon covering
+the area that all the selected shapes encompassed. Unions of disjoint shapes
+can still be created and is equivalent to grouping shapes.
+
+.. image:: editor_union.png
+   :align: center
+
+.. seealso::
+
+   The FlatCAM Shell command :ref:`geo_union` executes a union of
+   all geometry in a Geometry object.
+
+Moving and Copying
+~~~~~~~~~~~~~~~~~~
+
+The **Move** and **Copy** tools work on selected objects. As soon as the tool
+is selected (On the toolbar or the ``m`` and ``c`` keys) the reference point
+is set at the mouse pointer location. Clicking on the plot sets the target
+location and finalizes the operation. An outline of the shapes is shown
+while moving the mouse.
+
+.. seealso::
+
+   The FlatCAM Shell command :ref:`offset` will move (offset) all
+   the geometry in a Geometry Object. This can also be done in
+   the **Selected** panel for selected FlatCAM object.
+
+Cancelling an operation
+~~~~~~~~~~~~~~~~~~~~~~~
+
+Hitting the ``Esc`` key cancels whatever tool/operation is active and
+selects the **Selection Tool**.
+
+Deleting selected shapes
+~~~~~~~~~~~~~~~~~~~~~~~~
+
+Selections are deleted by hitting the ``-`` sign key.
+
+Other
+~~~~~
+
+.. seealso::
+
+   The FlatCAM Shell command :ref:`scale` changes the size of the
+   geometry in a Geometry Object.
+
+