Преглед изворни кода

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

+ 48 - 4
FlatCAMGUI.py

@@ -197,12 +197,14 @@ class FlatCAMGUI(QtGui.QMainWindow):
         ################
         ################
         infobar = self.statusBar()
         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 = 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)
         self.position_label.setMinimumWidth(110)
         infobar.addWidget(self.position_label)
         infobar.addWidget(self.position_label)
 
 
@@ -233,6 +235,48 @@ class FlatCAMGUI(QtGui.QMainWindow):
         self.show()
         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):
 class OptionsGroupUI(QtGui.QGroupBox):
     def __init__(self, title, parent=None):
     def __init__(self, title, parent=None):
         QtGui.QGroupBox.__init__(self, title, parent=parent)
         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..."))
             # GLib.idle_add(lambda: app_obj.set_progress_bar(0.4, "Analyzing Geometry..."))
             app_obj.progress.emit(40)
             app_obj.progress.emit(40)
             # TODO: The tolerance should not be hard coded. Just for testing.
             # 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..."))
             # GLib.idle_add(lambda: app_obj.set_progress_bar(0.5, "Parsing G-Code..."))
             app_obj.progress.emit(50)
             app_obj.progress.emit(50)

+ 434 - 37
camlib.py

@@ -5,13 +5,21 @@
 # Date: 2/5/2014                                           #
 # Date: 2/5/2014                                           #
 # MIT Licence                                              #
 # MIT Licence                                              #
 ############################################################
 ############################################################
-
+#from __future__ import division
 import traceback
 import traceback
 
 
 from numpy import arctan2, Inf, array, sqrt, pi, ceil, sin, cos
 from numpy import arctan2, Inf, array, sqrt, pi, ceil, sin, cos
 from matplotlib.figure import Figure
 from matplotlib.figure import Figure
 import re
 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
 # See: http://toblerity.org/shapely/manual.html
 from shapely.geometry import Polygon, LineString, Point, LinearRing
 from shapely.geometry import Polygon, LineString, Point, LinearRing
 from shapely.geometry import MultiPoint, MultiPolygon
 from shapely.geometry import MultiPoint, MultiPolygon
@@ -54,20 +62,17 @@ class Geometry(object):
         # Units (in or mm)
         # Units (in or mm)
         self.units = Geometry.defaults["init_units"]
         self.units = Geometry.defaults["init_units"]
         
         
-        # Final geometry: MultiPolygon
+        # Final geometry: MultiPolygon or list (of geometry constructs)
         self.solid_geometry = None
         self.solid_geometry = None
 
 
         # Attributes to be included in serialization
         # Attributes to be included in serialization
         self.ser_attrs = ['units', 'solid_geometry']
         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):
     def add_circle(self, origin, radius):
         """
         """
@@ -112,18 +117,6 @@ class Geometry(object):
             print "Failed to run union on polygons."
             print "Failed to run union on polygons."
             raise
             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):
     def bounds(self):
         """
         """
         Returns coordinates of rectangular bounds
         Returns coordinates of rectangular bounds
@@ -133,19 +126,70 @@ class Geometry(object):
         if self.solid_geometry is None:
         if self.solid_geometry is None:
             log.debug("solid_geometry is None")
             log.debug("solid_geometry is None")
             log.warning("solid_geometry not computed yet.")
             log.warning("solid_geometry not computed yet.")
-            return (0, 0, 0, 0)
-            
+            return 0, 0, 0, 0
+
         if type(self.solid_geometry) is list:
         if type(self.solid_geometry) is list:
             log.debug("type(solid_geometry) is list")
             log.debug("type(solid_geometry) is list")
             # TODO: This can be done faster. See comment from Shapely mailing lists.
             # TODO: This can be done faster. See comment from Shapely mailing lists.
             if len(self.solid_geometry) == 0:
             if len(self.solid_geometry) == 0:
                 log.debug('solid_geometry is empty []')
                 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')
             log.debug('solid_geometry is not empty, returning cascaded union of items')
             return cascaded_union(self.solid_geometry).bounds
             return cascaded_union(self.solid_geometry).bounds
         else:
         else:
             log.debug("type(solid_geometry) is not list, returning .bounds property")
             log.debug("type(solid_geometry) is not list, returning .bounds property")
             return self.solid_geometry.bounds
             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):
     def size(self):
         """
         """
@@ -260,6 +304,16 @@ class Geometry(object):
         for attr in self.ser_attrs:
         for attr in self.ser_attrs:
             setattr(self, attr, d[attr])
             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:
 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()``
         The constructor takes no parameters. Use ``gerber.parse_files()``
         or ``gerber.parse_lines()`` to populate the object from Gerber source.
         or ``gerber.parse_lines()`` to populate the object from Gerber source.
@@ -676,7 +734,7 @@ class Gerber (Geometry):
         """
         """
 
 
         # Initialize parent
         # Initialize parent
-        Geometry.__init__(self)        
+        Geometry.__init__(self)
 
 
         self.solid_geometry = Polygon()
         self.solid_geometry = Polygon()
 
 
@@ -778,8 +836,8 @@ class Gerber (Geometry):
         self.am1_re = re.compile(r'^%AM([^\*]+)\*([^%]+)?(%)?$')
         self.am1_re = re.compile(r'^%AM([^\*]+)\*([^%]+)?(%)?$')
         self.am2_re = re.compile(r'(.*)%$')
         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):
     def scale(self, factor):
         """
         """
@@ -1836,8 +1894,13 @@ class CNCjob(Geometry):
                            "C" (cut). B is "F" (fast) or "S" (slow).
                            "C" (cut). B is "F" (fast) or "S" (slow).
     =====================  =========================================
     =====================  =========================================
     """
     """
+
+    defaults = {
+        "zdownrate": None
+    }
+
     def __init__(self, units="in", kind="generic", z_move=0.1,
     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)
         Geometry.__init__(self)
         self.kind = kind
         self.kind = kind
@@ -1854,6 +1917,11 @@ class CNCjob(Geometry):
         self.input_geometry_bounds = None
         self.input_geometry_bounds = None
         self.gcode_parsed = None
         self.gcode_parsed = None
         self.steps_per_circ = 20  # Used when parsing G-code arcs
         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
         # Attributes to be included in serialization
         # Always append to it because it carries contents
         # Always append to it because it carries contents
@@ -1862,6 +1930,34 @@ class CNCjob(Geometry):
                            'gcode', 'input_geometry_bounds', 'gcode_parsed',
                            'gcode', 'input_geometry_bounds', 'gcode_parsed',
                            'steps_per_circ']
                            '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):
     def convert_units(self, units):
         factor = Geometry.convert_units(self, units)
         factor = Geometry.convert_units(self, units)
         log.debug("CNCjob.convert_units()")
         log.debug("CNCjob.convert_units()")
@@ -1986,14 +2082,17 @@ class CNCjob(Geometry):
         if not append:
         if not append:
             self.gcode = ""
             self.gcode = ""
 
 
+        # Initial G-Code
         self.gcode = self.unitcode[self.units.upper()] + "\n"
         self.gcode = self.unitcode[self.units.upper()] + "\n"
         self.gcode += self.absolutecode + "\n"
         self.gcode += self.absolutecode + "\n"
         self.gcode += self.feedminutecode + "\n"
         self.gcode += self.feedminutecode + "\n"
         self.gcode += "F%.2f\n" % self.feedrate
         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 += "M03\n"  # Spindle start
         self.gcode += self.pausecode + "\n"
         self.gcode += self.pausecode + "\n"
-        
+
+        # Iterate over geometry and run individual methods
+        # depending on type
         for geo in geometry.solid_geometry:
         for geo in geometry.solid_geometry:
             
             
             if type(geo) == Polygon:
             if type(geo) == Polygon:
@@ -2005,7 +2104,6 @@ class CNCjob(Geometry):
                 continue
                 continue
             
             
             if type(geo) == Point:
             if type(geo) == Point:
-                # TODO: point2gcode does not return anything...
                 self.gcode += self.point2gcode(geo)
                 self.gcode += self.point2gcode(geo)
                 continue
                 continue
 
 
@@ -2016,6 +2114,74 @@ class CNCjob(Geometry):
 
 
             log.warning("G-code generation not implemented for %s" % (str(type(geo))))
             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 Z%.4f\n" % self.z_move  # Stop cutting
         self.gcode += "G00 X0Y0\n"
         self.gcode += "G00 X0Y0\n"
         self.gcode += "M05\n"  # Spindle stop
         self.gcode += "M05\n"  # Spindle stop
@@ -2262,14 +2428,28 @@ class CNCjob(Geometry):
         t = "G0%d X%.4fY%.4f\n"
         t = "G0%d X%.4fY%.4f\n"
         path = list(target_polygon.exterior.coords)             # Polygon exterior
         path = list(target_polygon.exterior.coords)             # Polygon exterior
         gcode += t % (0, path[0][0], path[0][1])  # Move to first point
         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:]:
         for pt in path[1:]:
             gcode += t % (1, pt[0], pt[1])    # Linear motion to point
             gcode += t % (1, pt[0], pt[1])    # Linear motion to point
         gcode += "G00 Z%.4f\n" % self.z_move  # Stop cutting
         gcode += "G00 Z%.4f\n" % self.z_move  # Stop cutting
         for ints in target_polygon.interiors:               # Polygon interiors
         for ints in target_polygon.interiors:               # Polygon interiors
             path = list(ints.coords)
             path = list(ints.coords)
             gcode += t % (0, path[0][0], path[0][1])  # Move to first point
             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:]:
             for pt in path[1:]:
                 gcode += t % (1, pt[0], pt[1])    # Linear motion to point
                 gcode += t % (1, pt[0], pt[1])    # Linear motion to point
             gcode += "G00 Z%.4f\n" % self.z_move  # Stop cutting
             gcode += "G00 Z%.4f\n" % self.z_move  # Stop cutting
@@ -2297,20 +2477,34 @@ class CNCjob(Geometry):
         t = "G0%d X%.4fY%.4f\n"
         t = "G0%d X%.4fY%.4f\n"
         path = list(target_linear.coords)
         path = list(target_linear.coords)
         gcode += t % (0, path[0][0], path[0][1])  # Move to first point
         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:]:
         for pt in path[1:]:
             gcode += t % (1, pt[0], pt[1])    # Linear motion to point
             gcode += t % (1, pt[0], pt[1])    # Linear motion to point
         gcode += "G00 Z%.4f\n" % self.z_move  # Stop cutting
         gcode += "G00 Z%.4f\n" % self.z_move  # Stop cutting
         return gcode
         return gcode
 
 
     def point2gcode(self, point):
     def point2gcode(self, point):
-        # TODO: This is not doing anything.
         gcode = ""
         gcode = ""
         t = "G0%d X%.4fY%.4f\n"
         t = "G0%d X%.4fY%.4f\n"
         path = list(point.coords)
         path = list(point.coords)
         gcode += t % (0, path[0][0], path[0][1])  # Move to first point
         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
         gcode += "G00 Z%.4f\n" % self.z_move      # Stop cutting
+        return gcode
 
 
     def scale(self, factor):
     def scale(self, factor):
         """
         """
@@ -2384,6 +2578,7 @@ def get_bounds(geometry_list):
 
 
     return [xmin, ymin, xmax, ymax]
     return [xmin, ymin, xmax, ymax]
 
 
+
 def arc(center, radius, start, stop, direction, steps_per_circ):
 def arc(center, radius, start, stop, direction, steps_per_circ):
     """
     """
     Creates a list of point along the specified arc.
     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))
     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="{{ 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" class="icon icon-home"> {{ project }}</a>-->
         <a href="http://flatcam.org">
         <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;
                     style="height: auto;
                     width: auto;
                     width: auto;
                     border-radius: 0px;
                     border-radius: 0px;

+ 14 - 0
manual/cmdreference.rst

@@ -6,6 +6,8 @@ Shell Command Reference
 .. warning::
 .. warning::
     The FlatCAM Shell is under development and its behavior might change in the future. This includes available commands and their syntax.
     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
 add_circle
 ~~~~~~~~~~
 ~~~~~~~~~~
 Creates a circle in the given Geometry object.
 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.
        radius: Radius of the circle.
 
 
+.. _add_poly:
+
 add_poly
 add_poly
 ~~~~~~~~
 ~~~~~~~~
 Creates a polygon in the given Geometry object.
 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.
        xi, yi: Coordinates of points in the polygon.
 
 
+.. _add_rect:
+
 add_rect
 add_rect
 ~~~~~~~~
 ~~~~~~~~
 Creates a rectange in the given Geometry object.
 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.
        outname: Name of the output geometry object.
 
 
+.. _geo_union:
+
 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.
 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
     > new
        No parameters.
        No parameters.
 
 
+.. _new_geometry:
+
 new_geometry
 new_geometry
 ~~~~~~~~~~~~
 ~~~~~~~~~~~~
 Creates a new empty geometry object.
 Creates a new empty geometry object.
@@ -121,6 +131,8 @@ Creates a new empty geometry object.
     > new_geometry <name>
     > new_geometry <name>
        name: New object name
        name: New object name
 
 
+.. _offset:
+
 offset
 offset
 ~~~~~~
 ~~~~~~
 Changes the position of the object.
 Changes the position of the object.
@@ -200,6 +212,8 @@ Saves the FlatCAM project to file.
     > save_project <filename>
     > save_project <filename>
        filename: Path to file to save.
        filename: Path to file to save.
 
 
+.. _scale:
+
 scale
 scale
 ~~~~~
 ~~~~~
 Resizes the object by a factor.
 Resizes the object by a factor.

+ 124 - 1
manual/editor.rst

@@ -1,7 +1,130 @@
 Geometry Editor
 Geometry Editor
 ===============
 ===============
 
 
+Introduction
+------------
+
 The Geometry Editor is a drawing CAD that allows you to edit
 The Geometry Editor is a drawing CAD that allows you to edit
 FlatCAM Geometry Objects or create new ones from scratch. This
 FlatCAM Geometry Objects or create new ones from scratch. This
 provides the ultimate flexibility by letting you specify precisely
 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.
+
+