Sfoglia il codice sorgente

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

Juan Pablo Caram 11 anni fa
parent
commit
cea41c827e
7 ha cambiato i file con 678 aggiunte e 65 eliminazioni
  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.
+
+