فهرست منبع

- added a new method for GCode generation for Geometry objects
- added multiple algorithms for path optimization when generating GCode from an Geometry object beside the original Rtree algorithm: TSA, OR-Tools Basic, OR-Tools metaheuristics
- added controls for Geometry object path optimization in Preferences

Marius Stanciu 5 سال پیش
والد
کامیت
144a89f686

+ 6 - 0
CHANGELOG.md

@@ -7,6 +7,12 @@ CHANGELOG for FlatCAM beta
 
 =================================================
 
+16.07.2020
+
+- added a new method for GCode generation for Geometry objects
+- added multiple algorithms for path optimization when generating GCode from an Geometry object beside the original Rtree algorithm: TSA, OR-Tools Basic, OR-Tools metaheuristics
+- added controls for Geometry object path optimization in Preferences
+
 15.07.2020
 
 - added icons to some of the push buttons

+ 2 - 0
appGUI/preferences/PreferencesUIManager.py

@@ -246,6 +246,8 @@ class PreferencesUIManager:
             "geometry_cnctooldia":          self.ui.geometry_defaults_form.geometry_gen_group.cnctooldia_entry,
             "geometry_merge_fuse_tools":    self.ui.geometry_defaults_form.geometry_gen_group.fuse_tools_cb,
             "geometry_plot_line":           self.ui.geometry_defaults_form.geometry_gen_group.line_color_entry,
+            "geometry_optimization_type":   self.ui.geometry_defaults_form.geometry_gen_group.opt_algorithm_radio,
+            "geometry_search_time":         self.ui.geometry_defaults_form.geometry_gen_group.optimization_time_entry,
 
             # Geometry Options
             "geometry_cutz":            self.ui.geometry_defaults_form.geometry_opt_group.cutz_entry,

+ 1 - 1
appGUI/preferences/excellon/ExcellonGenPrefGroupUI.py

@@ -207,7 +207,7 @@ class ExcellonGenPrefGroupUI(OptionsGroupUI):
         separator_line.setFrameShadow(QtWidgets.QFrame.Sunken)
         grid2.addWidget(separator_line, 7, 0, 1, 2)
 
-        self.excellon_general_label = QtWidgets.QLabel("<b>%s:</b>" % _("Excellon Optimization"))
+        self.excellon_general_label = QtWidgets.QLabel("<b>%s:</b>" % _("Path Optimization"))
         grid2.addWidget(self.excellon_general_label, 8, 0, 1, 2)
 
         self.excellon_optimization_label = QtWidgets.QLabel(_('Algorithm:'))

+ 54 - 7
appGUI/preferences/geometry/GeometryGenPrefGroupUI.py

@@ -1,7 +1,7 @@
 from PyQt5 import QtWidgets
 from PyQt5.QtCore import QSettings
 
-from appGUI.GUIElements import FCCheckBox, FCSpinner, FCEntry, FCColorEntry
+from appGUI.GUIElements import FCCheckBox, FCSpinner, FCEntry, FCColorEntry, RadioSet
 from appGUI.preferences.OptionsGroupUI import OptionsGroupUI
 
 import gettext
@@ -86,25 +86,72 @@ class GeometryGenPrefGroupUI(OptionsGroupUI):
         separator_line.setFrameShadow(QtWidgets.QFrame.Sunken)
         grid0.addWidget(separator_line, 9, 0, 1, 2)
 
+        self.opt_label = QtWidgets.QLabel("<b>%s:</b>" % _("Path Optimization"))
+        grid0.addWidget(self.opt_label, 10, 0, 1, 2)
+
+        self.opt_algorithm_label = QtWidgets.QLabel(_('Algorithm:'))
+        self.opt_algorithm_label.setToolTip(
+            _("This sets the path optimization algorithm.\n"
+              "- Rtre -> Rtree algorithm\n"
+              "- MetaHeuristic -> Google OR-Tools algorithm with\n"
+              "MetaHeuristic Guided Local Path is used. Default search time is 3sec.\n"
+              "- Basic -> Using Google OR-Tools Basic algorithm\n"
+              "- TSA -> Using Travelling Salesman algorithm\n"
+              "\n"
+              "If this control is disabled, then FlatCAM works in 32bit mode and it uses\n"
+              "Travelling Salesman algorithm for path optimization.")
+        )
+
+        self.opt_algorithm_radio = RadioSet(
+            [
+                {'label': _('Rtree'), 'value': 'R'},
+                {'label': _('MetaHeuristic'), 'value': 'M'},
+                {'label': _('Basic'), 'value': 'B'},
+                {'label': _('TSA'), 'value': 'T'}
+            ], orientation='vertical', stretch=False)
+
+        grid0.addWidget(self.opt_algorithm_label, 12, 0)
+        grid0.addWidget(self.opt_algorithm_radio, 12, 1)
+
+        self.optimization_time_label = QtWidgets.QLabel('%s:' % _('Duration'))
+        self.optimization_time_label.setToolTip(
+            _("When OR-Tools Metaheuristic (MH) is enabled there is a\n"
+              "maximum threshold for how much time is spent doing the\n"
+              "path optimization. This max duration is set here.\n"
+              "In seconds.")
+
+        )
+
+        self.optimization_time_entry = FCSpinner()
+        self.optimization_time_entry.set_range(0, 999)
+
+        grid0.addWidget(self.optimization_time_label, 14, 0)
+        grid0.addWidget(self.optimization_time_entry, 14, 1)
+
+        separator_line = QtWidgets.QFrame()
+        separator_line.setFrameShape(QtWidgets.QFrame.HLine)
+        separator_line.setFrameShadow(QtWidgets.QFrame.Sunken)
+        grid0.addWidget(separator_line, 16, 0, 1, 2)
+
         # Fuse Tools
         self.join_geo_label = QtWidgets.QLabel('<b>%s</b>:' % _('Join Option'))
-        grid0.addWidget(self.join_geo_label, 10, 0, 1, 2)
+        grid0.addWidget(self.join_geo_label, 18, 0, 1, 2)
 
         self.fuse_tools_cb = FCCheckBox(_("Fuse Tools"))
         self.fuse_tools_cb.setToolTip(
             _("When checked the joined (merged) object tools\n"
               "will be merged also but only if they share some of their attributes.")
         )
-        grid0.addWidget(self.fuse_tools_cb, 11, 0, 1, 2)
+        grid0.addWidget(self.fuse_tools_cb, 20, 0, 1, 2)
 
         separator_line = QtWidgets.QFrame()
         separator_line.setFrameShape(QtWidgets.QFrame.HLine)
         separator_line.setFrameShadow(QtWidgets.QFrame.Sunken)
-        grid0.addWidget(separator_line, 12, 0, 1, 2)
+        grid0.addWidget(separator_line, 22, 0, 1, 2)
 
         # Geometry Object Color
         self.gerber_color_label = QtWidgets.QLabel('<b>%s</b>:' % _('Object Color'))
-        grid0.addWidget(self.gerber_color_label, 13, 0, 1, 2)
+        grid0.addWidget(self.gerber_color_label, 24, 0, 1, 2)
 
         # Plot Line Color
         self.line_color_label = QtWidgets.QLabel('%s:' % _('Outline'))
@@ -113,8 +160,8 @@ class GeometryGenPrefGroupUI(OptionsGroupUI):
         )
         self.line_color_entry = FCColorEntry()
 
-        grid0.addWidget(self.line_color_label, 14, 0)
-        grid0.addWidget(self.line_color_entry, 14, 1)
+        grid0.addWidget(self.line_color_label, 26, 0)
+        grid0.addWidget(self.line_color_entry, 26, 1)
 
         self.layout.addStretch()
 

+ 27 - 54
appObjects/FlatCAMGeometry.py

@@ -474,41 +474,19 @@ class GeometryObject(FlatCAMObj, Geometry):
 
         # store here the default data for Geometry Data
         self.default_data = {}
-        self.default_data.update({
-            "name": None,
-            "plot": None,
-            "cutz": None,
-            "vtipdia": None,
-            "vtipangle": None,
-            "travelz": None,
-            "feedrate": None,
-            "feedrate_z": None,
-            "feedrate_rapid": None,
-            "dwell": None,
-            "dwelltime": None,
-            "multidepth": None,
-            "ppname_g": None,
-            "depthperpass": None,
-            "extracut": None,
-            "extracut_length": None,
-            "toolchange": None,
-            "toolchangez": None,
-            "endz": None,
-            "endxy": '',
-            "area_exclusion": None,
-            "area_shape": None,
-            "area_strategy": None,
-            "area_overz": None,
-            "spindlespeed": 0,
-            "toolchangexy": None,
-            "startz": None
-        })
 
+        for opt_key, opt_val in self.app.options.items():
+            if opt_key.find('geometry' + "_") == 0:
+                oname = opt_key[len('geometry') + 1:]
+                self.default_data[oname] = self.app.options[opt_key]
+            if opt_key.find('tools_mill' + "_") == 0:
+                oname = opt_key[len('tools_mill') + 1:]
+                self.default_data[oname] = self.app.options[opt_key]
         # fill in self.default_data values from self.options
-        for def_key in self.default_data:
-            for opt_key, opt_val in self.options.items():
-                if def_key == opt_key:
-                    self.default_data[def_key] = deepcopy(opt_val)
+        # for def_key in self.default_data:
+        #     for opt_key, opt_val in self.options.items():
+        #         if def_key == opt_key:
+        #             self.default_data[def_key] = deepcopy(opt_val)
 
         if type(self.options["cnctooldia"]) == float:
             tools_list = [self.options["cnctooldia"]]
@@ -1809,16 +1787,6 @@ class GeometryObject(FlatCAMObj, Geometry):
         # test to see if we have tools available in the tool table
         if self.ui.geo_tools_table.selectedItems():
             for x in self.ui.geo_tools_table.selectedItems():
-                # try:
-                #     tooldia = float(self.ui.geo_tools_table.item(x.row(), 1).text())
-                # except ValueError:
-                #     # try to convert comma to decimal point. if it's still not working error message and return
-                #     try:
-                #         tooldia = float(self.ui.geo_tools_table.item(x.row(), 1).text().replace(',', '.'))
-                #     except ValueError:
-                #         self.app.inform.emit('[ERROR_NOTCL] %s' %
-                #                              _("Wrong value format entered, use a number."))
-                #         return
                 tooluid = int(self.ui.geo_tools_table.item(x.row(), 5).text())
 
                 for tooluid_key, tooluid_value in self.tools.items():
@@ -1884,6 +1852,7 @@ class GeometryObject(FlatCAMObj, Geometry):
             self.app.inform.emit(msg)
             return
 
+        self.multigeo = True
         # Object initialization function for app.app_obj.new_object()
         # RUNNING ON SEPARATE THREAD!
         def job_init_single_geometry(job_obj, app_obj):
@@ -2134,17 +2103,21 @@ class GeometryObject(FlatCAMObj, Geometry):
                 # it seems that the tolerance needs to be a lot lower value than 0.01 and it was hardcoded initially
                 # to a value of 0.0005 which is 20 times less than 0.01
                 tol = float(self.app.defaults['global_tolerance']) / 20
-                res = job_obj.generate_from_multitool_geometry(
-                    tool_solid_geometry, tooldia=tooldia_val, offset=tool_offset,
-                    tolerance=tol, z_cut=z_cut, z_move=z_move,
-                    feedrate=feedrate, feedrate_z=feedrate_z, feedrate_rapid=feedrate_rapid,
-                    spindlespeed=spindlespeed, spindledir=spindledir, dwell=dwell, dwelltime=dwelltime,
-                    multidepth=multidepth, depthpercut=depthpercut,
-                    extracut=extracut, extracut_length=extracut_length, startz=startz, endz=endz, endxy=endxy,
-                    toolchange=toolchange, toolchangez=toolchangez, toolchangexy=toolchangexy,
-                    pp_geometry_name=pp_geometry_name,
-                    tool_no=tool_cnt)
-
+                # res = job_obj.generate_from_multitool_geometry(
+                #     tool_solid_geometry, tooldia=tooldia_val, offset=tool_offset,
+                #     tolerance=tol, z_cut=z_cut, z_move=z_move,
+                #     feedrate=feedrate, feedrate_z=feedrate_z, feedrate_rapid=feedrate_rapid,
+                #     spindlespeed=spindlespeed, spindledir=spindledir, dwell=dwell, dwelltime=dwelltime,
+                #     multidepth=multidepth, depthpercut=depthpercut,
+                #     extracut=extracut, extracut_length=extracut_length, startz=startz, endz=endz, endxy=endxy,
+                #     toolchange=toolchange, toolchangez=toolchangez, toolchangexy=toolchangexy,
+                #     pp_geometry_name=pp_geometry_name,
+                #     tool_no=tool_cnt)
+                tool_lst = list(tools_dict.keys())
+                is_first = True if tooluid_key == tool_lst[0] else False
+                is_last = True if tooluid_key == tool_lst[-1] else False
+                res = job_obj.geometry_tool_gcode_gen(tooluid_key, tools_dict, first_pt=(0, 0), tolerance = tol,
+                                                      is_first=is_first, is_last=is_last, toolchange = True)
                 if res == 'fail':
                     log.debug("GeometryObject.mtool_gen_cncjob() --> generate_from_geometry2() failed")
                     return 'fail'

+ 7 - 2
appTools/ToolIsolation.py

@@ -345,8 +345,7 @@ class ToolIsolation(AppTool, Gerber):
             "feedrate":                 self.app.defaults["geometry_feedrate"],
             "feedrate_z":               self.app.defaults["geometry_feedrate_z"],
             "feedrate_rapid":           self.app.defaults["geometry_feedrate_rapid"],
-            "dwell":                    self.app.defaults["geometry_dwell"],
-            "dwelltime":                self.app.defaults["geometry_dwelltime"],
+
             "multidepth":               self.app.defaults["geometry_multidepth"],
             "ppname_g":                 self.app.defaults["geometry_ppname_g"],
             "depthperpass":             self.app.defaults["geometry_depthperpass"],
@@ -357,7 +356,13 @@ class ToolIsolation(AppTool, Gerber):
             "endz":                     self.app.defaults["geometry_endz"],
             "endxy":                    self.app.defaults["geometry_endxy"],
 
+            "dwell":                    self.app.defaults["geometry_dwell"],
+            "dwelltime":                self.app.defaults["geometry_dwelltime"],
             "spindlespeed":             self.app.defaults["geometry_spindlespeed"],
+            "spindledir":               self.app.defaults["geometry_spindledir"],
+
+            "optimization_type":        self.app.defaults["geometry_optimization_type"],
+            "search_time":              self.app.defaults["geometry_search_time"],
             "toolchangexy":             self.app.defaults["geometry_toolchangexy"],
             "startz":                   self.app.defaults["geometry_startz"],
 

+ 494 - 94
camlib.py

@@ -2518,8 +2518,11 @@ class CNCjob(Geometry):
         self.z_end = endz
         self.xy_end = endxy
 
+        self.extracut = False
         self.extracut_length = None
 
+        self.tolerance = self.drawing_tolerance
+
         # used by the self.generate_from_excellon_by_tool() method
         # but set directly before the actual usage of the method with obj.excellon_optimization_type = value
         self.excellon_optimization_type = 'No'
@@ -2721,7 +2724,7 @@ class CNCjob(Geometry):
         # Create the data.
         return [(pt.coords.xy[0][0], pt.coords.xy[1][0]) for pt in points]
 
-    def optimized_ortools_meta(self, locations, start=None):
+    def optimized_ortools_meta(self, locations, start=None, opt_time=0):
         optimized_path = []
 
         tsp_size = len(locations)
@@ -2731,56 +2734,57 @@ class CNCjob(Geometry):
         depot = 0 if start is None else start
 
         # Create routing model.
-        if tsp_size > 0:
-            manager = pywrapcp.RoutingIndexManager(tsp_size, num_routes, depot)
-            routing = pywrapcp.RoutingModel(manager)
-            search_parameters = pywrapcp.DefaultRoutingSearchParameters()
-            search_parameters.local_search_metaheuristic = (
-                routing_enums_pb2.LocalSearchMetaheuristic.GUIDED_LOCAL_SEARCH)
-
-            # Set search time limit in milliseconds.
-            if float(self.app.defaults["excellon_search_time"]) != 0:
-                search_parameters.time_limit.seconds = int(
-                    float(self.app.defaults["excellon_search_time"]))
-            else:
-                search_parameters.time_limit.seconds = 3
+        if tsp_size == 0:
+            log.warning('OR-tools metaheuristics - Specify an instance greater than 0.')
+            return optimized_path
+
+        manager = pywrapcp.RoutingIndexManager(tsp_size, num_routes, depot)
+        routing = pywrapcp.RoutingModel(manager)
+        search_parameters = pywrapcp.DefaultRoutingSearchParameters()
+        search_parameters.local_search_metaheuristic = (
+            routing_enums_pb2.LocalSearchMetaheuristic.GUIDED_LOCAL_SEARCH)
+
+        # Set search time limit in milliseconds.
+        if float(opt_time) != 0:
+            search_parameters.time_limit.seconds = int(
+                float(opt_time))
+        else:
+            search_parameters.time_limit.seconds = 3
 
-            # Callback to the distance function. The callback takes two
-            # arguments (the from and to node indices) and returns the distance between them.
-            dist_between_locations = self.CreateDistanceCallback(locs=locations, manager=manager)
+        # Callback to the distance function. The callback takes two
+        # arguments (the from and to node indices) and returns the distance between them.
+        dist_between_locations = self.CreateDistanceCallback(locs=locations, manager=manager)
 
-            # if there are no distances then go to the next tool
-            if not dist_between_locations:
-                return
+        # if there are no distances then go to the next tool
+        if not dist_between_locations:
+            return
 
-            dist_callback = dist_between_locations.Distance
-            transit_callback_index = routing.RegisterTransitCallback(dist_callback)
-            routing.SetArcCostEvaluatorOfAllVehicles(transit_callback_index)
+        dist_callback = dist_between_locations.Distance
+        transit_callback_index = routing.RegisterTransitCallback(dist_callback)
+        routing.SetArcCostEvaluatorOfAllVehicles(transit_callback_index)
 
-            # Solve, returns a solution if any.
-            assignment = routing.SolveWithParameters(search_parameters)
+        # Solve, returns a solution if any.
+        assignment = routing.SolveWithParameters(search_parameters)
 
-            if assignment:
-                # Solution cost.
-                log.info("OR-tools metaheuristics - Total distance: " + str(assignment.ObjectiveValue()))
+        if assignment:
+            # Solution cost.
+            log.info("OR-tools metaheuristics - Total distance: " + str(assignment.ObjectiveValue()))
 
-                # Inspect solution.
-                # Only one route here; otherwise iterate from 0 to routing.vehicles() - 1.
-                route_number = 0
-                node = routing.Start(route_number)
-                start_node = node
+            # Inspect solution.
+            # Only one route here; otherwise iterate from 0 to routing.vehicles() - 1.
+            route_number = 0
+            node = routing.Start(route_number)
+            start_node = node
 
-                while not routing.IsEnd(node):
-                    if self.app.abort_flag:
-                        # graceful abort requested by the user
-                        raise grace
+            while not routing.IsEnd(node):
+                if self.app.abort_flag:
+                    # graceful abort requested by the user
+                    raise grace
 
-                    optimized_path.append(node)
-                    node = assignment.Value(routing.NextVar(node))
-            else:
-                log.warning('OR-tools metaheuristics - No solution found.')
+                optimized_path.append(node)
+                node = assignment.Value(routing.NextVar(node))
         else:
-            log.warning('OR-tools metaheuristics - Specify an instance greater than 0.')
+            log.warning('OR-tools metaheuristics - No solution found.')
 
         return optimized_path
         # ############################################# ##
@@ -2795,43 +2799,44 @@ class CNCjob(Geometry):
         depot = 0 if start is None else start
 
         # Create routing model.
-        if tsp_size > 0:
-            manager = pywrapcp.RoutingIndexManager(tsp_size, num_routes, depot)
-            routing = pywrapcp.RoutingModel(manager)
-            search_parameters = pywrapcp.DefaultRoutingSearchParameters()
-
-            # Callback to the distance function. The callback takes two
-            # arguments (the from and to node indices) and returns the distance between them.
-            dist_between_locations = self.CreateDistanceCallback(locs=locations, manager=manager)
-
-            # if there are no distances then go to the next tool
-            if not dist_between_locations:
-                return
-
-            dist_callback = dist_between_locations.Distance
-            transit_callback_index = routing.RegisterTransitCallback(dist_callback)
-            routing.SetArcCostEvaluatorOfAllVehicles(transit_callback_index)
-
-            # Solve, returns a solution if any.
-            assignment = routing.SolveWithParameters(search_parameters)
-
-            if assignment:
-                # Solution cost.
-                log.info("Total distance: " + str(assignment.ObjectiveValue()))
-
-                # Inspect solution.
-                # Only one route here; otherwise iterate from 0 to routing.vehicles() - 1.
-                route_number = 0
-                node = routing.Start(route_number)
-                start_node = node
-
-                while not routing.IsEnd(node):
-                    optimized_path.append(node)
-                    node = assignment.Value(routing.NextVar(node))
-            else:
-                log.warning('No solution found.')
-        else:
+        if tsp_size == 0:
             log.warning('Specify an instance greater than 0.')
+            return optimized_path
+
+        manager = pywrapcp.RoutingIndexManager(tsp_size, num_routes, depot)
+        routing = pywrapcp.RoutingModel(manager)
+        search_parameters = pywrapcp.DefaultRoutingSearchParameters()
+
+        # Callback to the distance function. The callback takes two
+        # arguments (the from and to node indices) and returns the distance between them.
+        dist_between_locations = self.CreateDistanceCallback(locs=locations, manager=manager)
+
+        # if there are no distances then go to the next tool
+        if not dist_between_locations:
+            return
+
+        dist_callback = dist_between_locations.Distance
+        transit_callback_index = routing.RegisterTransitCallback(dist_callback)
+        routing.SetArcCostEvaluatorOfAllVehicles(transit_callback_index)
+
+        # Solve, returns a solution if any.
+        assignment = routing.SolveWithParameters(search_parameters)
+
+        if assignment:
+            # Solution cost.
+            log.info("Total distance: " + str(assignment.ObjectiveValue()))
+
+            # Inspect solution.
+            # Only one route here; otherwise iterate from 0 to routing.vehicles() - 1.
+            route_number = 0
+            node = routing.Start(route_number)
+            start_node = node
+
+            while not routing.IsEnd(node):
+                optimized_path.append(node)
+                node = assignment.Value(routing.NextVar(node))
+        else:
+            log.warning('No solution found.')
 
         return optimized_path
         # ############################################# ##
@@ -2871,6 +2876,46 @@ class CNCjob(Geometry):
             must_visit.remove(nearest)
         return path
 
+    def geo_optimized_rtree(self, geometry):
+        locations = []
+
+        # ## Index first and last points in paths. What points to index.
+        def get_pts(o):
+            return [o.coords[0], o.coords[-1]]
+
+        # Create the indexed storage.
+        storage = FlatCAMRTreeStorage()
+        storage.get_points = get_pts
+
+        # Store the geometry
+        log.debug("Indexing geometry before generating G-Code...")
+        self.app.inform.emit(_("Indexing geometry before generating G-Code..."))
+
+        for geo_shape in geometry:
+            if self.app.abort_flag:
+                # graceful abort requested by the user
+                raise grace
+
+            if geo_shape is not None:
+                storage.insert(geo_shape)
+
+        current_pt = (0, 0)
+        pt, geo = storage.nearest(current_pt)
+        try:
+            while True:
+                storage.remove(geo)
+                locations.append((pt, geo))
+                current_pt = geo.coords[-1]
+                pt, geo = storage.nearest(current_pt)
+        except StopIteration:
+            pass
+
+        # if there are no locations then go to the next tool
+        if not locations:
+            return 'fail'
+
+        return locations
+
     def check_zcut(self, zcut):
         if zcut > 0:
             self.app.inform.emit('[WARNING] %s' %
@@ -2980,12 +3025,10 @@ class CNCjob(Geometry):
 
                 # and now, xy_toolchange is made into a list of floats in format [x, y]
                 if self.xy_toolchange:
-                    self.xy_toolchange = [
-                        float(eval(a)) for a in self.xy_toolchange.split(",")
-                    ]
+                    self.xy_toolchange = [float(eval(a)) for a in self.xy_toolchange.split(",")]
 
                 if self.xy_toolchange and len(self.xy_toolchange) != 2:
-                    self.app.inform.emit('[ERROR]%s' % _("The Toolchange X,Y format has to be (x, y)."))
+                    self.app.inform.emit('[ERROR] %s' % _("The Toolchange X,Y format has to be (x, y)."))
                     return 'fail'
         except Exception as e:
             log.debug("camlib.CNCJob.generate_from_excellon_by_tool() xy_toolchange --> %s" % str(e))
@@ -3032,7 +3075,8 @@ class CNCjob(Geometry):
             # if there are no locations then go to the next tool
             if not locations:
                 return 'fail'
-            optimized_path = self.optimized_ortools_meta(locations=locations)
+            opt_time = self.app.defaults["excellon_search_time"]
+            optimized_path = self.optimized_ortools_meta(locations=locations, opt_time=opt_time)
         elif opt_type == 'B':
             locations = self.create_tool_data_array(points=points)
             # if there are no locations then go to the next tool
@@ -3547,7 +3591,8 @@ class CNCjob(Geometry):
                     # if there are no locations then go to the next tool
                     if not locations:
                         continue
-                    optimized_path = self.optimized_ortools_meta(locations=locations)
+                    opt_time = self.app.defaults["excellon_search_time"]
+                    optimized_path = self.optimized_ortools_meta(locations=locations, opt_time=opt_time)
                 elif used_excellon_optimization_type == 'B':
                     if tool in points:
                         locations = self.create_tool_data_array(points=points[tool])
@@ -3782,7 +3827,8 @@ class CNCjob(Geometry):
                 # if there are no locations then go to the next tool
                 if not locations:
                     return 'fail'
-                optimized_path = self.optimized_ortools_meta(locations=locations)
+                opt_time = self.app.defaults["excellon_search_time"]
+                optimized_path = self.optimized_ortools_meta(locations=locations, opt_time=opt_time)
             elif used_excellon_optimization_type == 'B':
                 if all_points:
                     locations = self.create_tool_data_array(points=all_points)
@@ -4931,6 +4977,365 @@ class CNCjob(Geometry):
         )
         return self.gcode
 
+    def geometry_tool_gcode_gen(self, tool, tools, first_pt, tolerance, is_first=False, is_last=False,
+                                toolchange=False):
+        """
+        Algorithm to generate GCode from multitool Geometry.
+
+        :param tool:        tool number for which to generate GCode
+        :type tool:         int
+        :param tools:       a dictionary holding all the tools and data
+        :type tools:        dict
+        :param first_pt:    a tuple of coordinates for the first point of the current tool
+        :type first_pt:     tuple
+        :param tolerance:   geometry tolerance
+        :type tolerance:
+        :param is_first:    if the current tool is the first tool (for this we need to add start GCode)
+        :type is_first:     bool
+        :param is_last:     if the current tool is the last tool (for this we need to add the end GCode)
+        :type is_last:      bool
+        :param toolchange:  add toolchange event
+        :type toolchange:   bool
+        :return:            GCode
+        :rtype:             str
+        """
+
+        log.debug("Generate_from_multitool_geometry()")
+
+        t_gcode = ''
+        temp_solid_geometry = []
+
+        # The Geometry from which we create GCode
+        geometry = tools[tool]['solid_geometry']
+        # ## Flatten the geometry. Only linear elements (no polygons) remain.
+        flat_geometry = self.flatten(geometry, pathonly=True)
+        log.debug("%d paths" % len(flat_geometry))
+
+        # #########################################################################################################
+        # #########################################################################################################
+        # ############# PARAMETERS used in PREPROCESSORS so they need to be updated ###############################
+        # #########################################################################################################
+        # #########################################################################################################
+        self.tool = str(tool)
+        tool_dict = tools[tool]['data']
+        # this is the tool diameter, it is used as such to accommodate the preprocessor who need the tool diameter
+        # given under the name 'toolC'
+        self.postdata['toolC'] = float(tools[tool]['tooldia'])
+        self.tooldia = float(tools[tool]['tooldia'])
+        self.use_ui = True
+        self.tolerance = tolerance
+
+        # Optimization type. Can be: 'M', 'B', 'T', 'R', 'No'
+        opt_type = tool_dict['optimization_type']
+        opt_time = tool_dict['search_time'] if 'search_time' in tool_dict else 'R'
+
+        if opt_type == 'M':
+            log.debug("Using OR-Tools Metaheuristic Guided Local Search path optimization.")
+        elif opt_type == 'B':
+            log.debug("Using OR-Tools Basic path optimization.")
+        elif opt_type == 'T':
+            log.debug("Using Travelling Salesman path optimization.")
+        elif opt_type == 'R':
+            log.debug("Using RTree path optimization.")
+        else:
+            log.debug("Using no path optimization.")
+
+        # Preprocessor
+        self.pp_geometry_name = tool_dict['ppname_g']
+        self.pp_geometry = self.app.preprocessors[self.pp_geometry_name]
+        p = self.pp_geometry
+
+        # Offset the Geometry if it is the case
+        # FIXME need to test if in ["Path", "In", "Out", "Custom"]. For now only 'Custom' is somehow done
+        offset = tools[tool]['offset_value']
+        if offset != 0.0:
+            for it in flat_geometry:
+                # if the geometry is a closed shape then create a Polygon out of it
+                if isinstance(it, LineString):
+                    if it.is_ring:
+                        it = Polygon(it)
+                temp_solid_geometry.append(it.buffer(offset, join_style=2))
+        else:
+            temp_solid_geometry = flat_geometry
+
+        if self.z_cut is None:
+            if 'laser' not in self.pp_geometry_name:
+                self.app.inform.emit(
+                    '[ERROR_NOTCL] %s' % _("Cut_Z parameter is None or zero. Most likely a bad combinations of "
+                                           "other parameters."))
+                return 'fail'
+            else:
+                self.z_cut = 0
+        if self.machinist_setting == 0:
+            if self.z_cut > 0:
+                self.app.inform.emit('[WARNING] %s' %
+                                     _("The Cut Z parameter has positive value. "
+                                       "It is the depth value to cut into material.\n"
+                                       "The Cut Z parameter needs to have a negative value, assuming it is a typo "
+                                       "therefore the app will convert the value to negative."
+                                       "Check the resulting CNC code (Gcode etc)."))
+                self.z_cut = -self.z_cut
+            elif self.z_cut == 0 and 'laser' not in self.pp_geometry_name:
+                self.app.inform.emit('[WARNING] %s: %s' %
+                                     (_("The Cut Z parameter is zero. There will be no cut, skipping file"),
+                                      self.options['name']))
+                return 'fail'
+
+            if self.z_move is None:
+                self.app.inform.emit('[ERROR_NOTCL] %s' % _("Travel Z parameter is None or zero."))
+                return 'fail'
+
+            if self.z_move < 0:
+                self.app.inform.emit('[WARNING] %s' %
+                                     _("The Travel Z parameter has negative value. "
+                                       "It is the height value to travel between cuts.\n"
+                                       "The Z Travel parameter needs to have a positive value, assuming it is a typo "
+                                       "therefore the app will convert the value to positive."
+                                       "Check the resulting CNC code (Gcode etc)."))
+                self.z_move = -self.z_move
+            elif self.z_move == 0:
+                self.app.inform.emit('[WARNING] %s: %s' %
+                                     (_("The Z Travel parameter is zero. This is dangerous, skipping file"),
+                                      self.options['name']))
+                return 'fail'
+
+        # made sure that depth_per_cut is no more then the z_cut
+        if abs(self.z_cut) < self.z_depthpercut:
+            self.z_depthpercut = abs(self.z_cut)
+
+        # Depth parameters
+        self.z_cut = float(tool_dict['cutz'])
+        self.multidepth = tool_dict['multidepth']
+        self.z_depthpercut = float(tool_dict['depthperpass'])
+        self.z_move = float(tool_dict['travelz'])
+        self.f_plunge = self.app.defaults["geometry_f_plunge"]
+
+        self.feedrate = float(tool_dict['feedrate'])
+        self.z_feedrate = float(tool_dict['feedrate_z'])
+        self.feedrate_rapid = float(tool_dict['feedrate_rapid'])
+
+        self.spindlespeed = float(tool_dict['spindlespeed'])
+        self.spindledir = tool_dict['spindledir']
+        self.dwell = tool_dict['dwell']
+        self.dwelltime = float(tool_dict['dwelltime'])
+
+        self.startz = float(tool_dict['startz']) if tool_dict['startz'] else None
+        if self.startz == '':
+            self.startz = None
+
+        self.z_end = float(tool_dict['endz'])
+        try:
+            if self.xy_end == '':
+                self.xy_end = None
+            else:
+                # either originally it was a string or not, xy_end will be made string
+                self.xy_end = re.sub('[()\[\]]', '', str(self.xy_end)) if self.xy_end else None
+
+                # and now, xy_end is made into a list of floats in format [x, y]
+                if self.xy_end:
+                    self.xy_end = [float(eval(a)) for a in self.xy_end.split(",")]
+
+                if self.xy_end and len(self.xy_end) != 2:
+                    self.app.inform.emit('[ERROR]%s' % _("The End X,Y format has to be (x, y)."))
+                    return 'fail'
+        except Exception as e:
+            log.debug("camlib.CNCJob.geometry_from_excellon_by_tool() xy_end --> %s" % str(e))
+            self.xy_end = [0, 0]
+
+        self.z_toolchange = tool_dict['toolchangez']
+        self.xy_toolchange = tool_dict["toolchangexy"]
+        try:
+            if self.xy_toolchange == '':
+                self.xy_toolchange = None
+            else:
+                # either originally it was a string or not, xy_toolchange will be made string
+                self.xy_toolchange = re.sub('[()\[\]]', '', str(self.xy_toolchange)) if self.xy_toolchange else None
+
+                # and now, xy_toolchange is made into a list of floats in format [x, y]
+                if self.xy_toolchange:
+                    self.xy_toolchange = [float(eval(a)) for a in self.xy_toolchange.split(",")]
+
+                if self.xy_toolchange and len(self.xy_toolchange) != 2:
+                    self.app.inform.emit('[ERROR] %s' % _("The Toolchange X,Y format has to be (x, y)."))
+                    return 'fail'
+        except Exception as e:
+            log.debug("camlib.CNCJob.geometry_from_excellon_by_tool() --> %s" % str(e))
+            pass
+
+        self.extracut = tool_dict['extracut']
+        self.extracut_length = tool_dict['extracut_length']
+
+        # Probe parameters
+        # self.z_pdepth = tool_dict["tools_drill_z_pdepth"]
+        # self.feedrate_probe = tool_dict["tools_drill_feedrate_probe"]
+
+        # #########################################################################################################
+        # ############ Create the data. ###########################################################################
+        # #########################################################################################################
+        optimized_path = []
+
+        geo_storage = {}
+        for geo in temp_solid_geometry:
+            geo_storage[geo.coords[0]] = geo
+        locations = list(geo_storage.keys())
+
+        if opt_type == 'M':
+            # if there are no locations then go to the next tool
+            if not locations:
+                return 'fail'
+            optimized_locations = self.optimized_ortools_meta(locations=locations, opt_time=opt_time)
+            optimized_path = [(locations[loc], geo_storage[locations[loc]]) for loc in optimized_locations]
+        elif opt_type == 'B':
+            # if there are no locations then go to the next tool
+            if not locations:
+                return 'fail'
+            optimized_locations = self.optimized_ortools_basic(locations=locations)
+            optimized_path = [(locations[loc], geo_storage[locations[loc]]) for loc in optimized_locations]
+        elif opt_type == 'T':
+            # if there are no locations then go to the next tool
+            if not locations:
+                return 'fail'
+            optimized_locations = self.optimized_travelling_salesman(locations)
+            optimized_path = [(loc, geo_storage[loc]) for loc in optimized_locations]
+        elif opt_type == 'R':
+            optimized_path = self.geo_optimized_rtree(temp_solid_geometry)
+            if optimized_path == 'fail':
+                return 'fail'
+        else:
+            # it's actually not optimized path but here we build a list of (x,y) coordinates
+            # out of the tool's drills
+            for geo in temp_solid_geometry:
+                optimized_path.append(geo.coords[0])
+        # #########################################################################################################
+        # #########################################################################################################
+
+        # Only if there are locations to drill
+        if not optimized_path:
+            log.debug("CNCJob.excellon_tool_gcode_gen() -> Optimized path is empty.")
+            return 'fail'
+
+        if self.app.abort_flag:
+            # graceful abort requested by the user
+            raise grace
+
+        # #############################################################################################################
+        # #############################################################################################################
+        # ################# MILLING !!! ##############################################################################
+        # #############################################################################################################
+        # #############################################################################################################
+        log.debug("Starting G-Code...")
+
+        current_tooldia = float('%.*f' % (self.decimals, float(self.tooldia)))
+        self.app.inform.emit('%s: %s%s.' % (_("Starting G-Code for tool with diameter"),
+                                            str(current_tooldia),
+                                            str(self.units)))
+
+        # Measurements
+        total_travel = 0.0
+        total_cut = 0.0
+
+        # Start GCode
+        if is_first:
+            t_gcode += self.doformat(p.start_code)
+
+        # Toolchange code
+        t_gcode += self.doformat(p.feedrate_code)  # sets the feed rate
+        if toolchange:
+            t_gcode += self.doformat(p.toolchange_code)
+
+            if 'laser' not in self.pp_geometry_name.lower():
+                t_gcode += self.doformat(p.spindle_code)  # Spindle start
+            else:
+                # for laser this will disable the laser
+                t_gcode += self.doformat(p.lift_code, x=self.oldx, y=self.oldy)  # Move (up) to travel height
+
+            if self.dwell:
+                t_gcode += self.doformat(p.dwell_code)  # Dwell time
+        else:
+            t_gcode += self.doformat(p.lift_code, x=0, y=0)  # Move (up) to travel height
+            t_gcode += self.doformat(p.startz_code, x=0, y=0)
+
+            if 'laser' not in self.pp_geometry_name.lower():
+                t_gcode += self.doformat(p.spindle_code)  # Spindle start
+
+            if self.dwell is True:
+                t_gcode += self.doformat(p.dwell_code)  # Dwell time
+        t_gcode += self.doformat(p.feedrate_code)  # sets the feed rate
+
+        # ## Iterate over geometry paths getting the nearest each time.
+        path_count = 0
+
+        # variables to display the percentage of work done
+        geo_len = len(flat_geometry)
+        log.warning("Number of paths for which to generate GCode: %s" % str(geo_len))
+        old_disp_number = 0
+
+        current_pt = (0, 0)
+        for pt, geo in optimized_path:
+            if self.app.abort_flag:
+                # graceful abort requested by the user
+                raise grace
+
+            path_count += 1
+
+            # If last point in geometry is the nearest but prefer the first one if last point == first point
+            # then reverse coordinates.
+            if pt != geo.coords[0] and pt == geo.coords[-1]:
+                geo.coords = list(geo.coords)[::-1]
+
+            # ---------- Single depth/pass --------
+            if not self.multidepth:
+                # calculate the cut distance
+                total_cut = total_cut + geo.length
+
+                t_gcode += self.create_gcode_single_pass(geo, current_tooldia, self.extracut,
+                                                         self.extracut_length, self.tolerance,
+                                                         z_move=self.z_move, old_point=current_pt)
+
+            # --------- Multi-pass ---------
+            else:
+                # calculate the cut distance
+                # due of the number of cuts (multi depth) it has to multiplied by the number of cuts
+                nr_cuts = 0
+                depth = abs(self.z_cut)
+                while depth > 0:
+                    nr_cuts += 1
+                    depth -= float(self.z_depthpercut)
+
+                total_cut += (geo.length * nr_cuts)
+
+                t_gcode += self.create_gcode_multi_pass(geo, current_tooldia, self.extracut,
+                                                        self.extracut_length, self.tolerance,
+                                                        z_move=self.z_move, postproc=p, old_point=current_pt)
+
+            # calculate the total distance
+            total_travel = total_travel + abs(distance(pt1=current_pt, pt2=pt))
+            current_pt = geo.coords[-1]
+
+            disp_number = int(np.interp(path_count, [0, geo_len], [0, 100]))
+            if old_disp_number < disp_number <= 100:
+                self.app.proc_container.update_view_text(' %d%%' % disp_number)
+                old_disp_number = disp_number
+
+        log.debug("Finished G-Code... %s paths traced." % path_count)
+
+        # add move to end position
+        total_travel += abs(distance_euclidian(current_pt[0], current_pt[1], 0, 0))
+        self.travel_distance += total_travel + total_cut
+        self.routing_time += total_cut / self.feedrate
+
+        # Finish
+        if is_last:
+            t_gcode += self.doformat(p.spindle_stop_code)
+            t_gcode += self.doformat(p.lift_code, x=current_pt[0], y=current_pt[1])
+            t_gcode += self.doformat(p.end_code, x=0, y=0)
+            self.app.inform.emit(
+                '%s... %s %s.' % (_("Finished G-Code generation"), str(path_count), _("paths traced"))
+            )
+
+        self.gcode = t_gcode
+        return self.gcode
+
     def generate_from_geometry_2(self, geometry, append=True, tooldia=None, offset=0.0, tolerance=0, z_cut=None,
                                  z_move=None, feedrate=None, feedrate_z=None, feedrate_rapid=None, spindlespeed=None,
                                  spindledir='CW', dwell=False, dwelltime=None, multidepth=False, depthpercut=None,
@@ -4973,10 +5378,6 @@ class CNCjob(Geometry):
         :param tool_no:
         :return:                    None
         """
-
-        if not isinstance(geometry, Geometry):
-            self.app.inform.emit('[ERROR] %s: %s' % (_("Expected a Geometry, got"), type(geometry)))
-            return 'fail'
         log.debug("Executing camlib.CNCJob.generate_from_geometry_2()")
 
         # if solid_geometry is empty raise an exception
@@ -4984,8 +5385,7 @@ class CNCjob(Geometry):
             self.app.inform.emit(
                 '[ERROR_NOTCL] %s' % _("Trying to generate a CNC Job from a Geometry object without solid_geometry.")
             )
-
-        temp_solid_geometry = []
+            return 'fail'
 
         def bounds_rec(obj):
             if type(obj) is list:
@@ -5013,6 +5413,8 @@ class CNCjob(Geometry):
                 # it's a Shapely object, return it's bounds
                 return obj.bounds
 
+        # Create the solid geometry which will be used to generate GCode
+        temp_solid_geometry = []
         if offset != 0.0:
             offset_for_use = offset
 
@@ -5110,9 +5512,7 @@ class CNCjob(Geometry):
                     self.xy_toolchange = [float(eval(a)) for a in self.xy_toolchange.split(",")]
 
                 if len(self.xy_toolchange) < 2:
-                    msg = _("The Toolchange X,Y field in Edit -> Preferences has to be in the format (x, y)\n"
-                            "but now there is only one value, not two.")
-                    self.app.inform.emit('[ERROR] %s' % msg)
+                    self.app.inform.emit('[ERROR] %s' % _("The Toolchange X,Y format has to be (x, y)."))
                     return 'fail'
         except Exception as e:
             log.debug("camlib.CNCJob.generate_from_geometry_2() --> %s" % str(e))

+ 2 - 0
defaults.py

@@ -298,6 +298,8 @@ class FlatCAMDefaults:
         "geometry_cnctooldia": "2.4",
         "geometry_merge_fuse_tools": True,
         "geometry_plot_line": "#FF0000",
+        "geometry_optimization_type": 'R',
+        "geometry_search_time": 3,
 
         # Geometry Options
         "geometry_cutz": -2.4,