diff --git a/AppGUI/MainGUI.py b/AppGUI/MainGUI.py index 46761cb8..cec8ede0 100644 --- a/AppGUI/MainGUI.py +++ b/AppGUI/MainGUI.py @@ -3404,7 +3404,8 @@ class MainGUI(QtWidgets.QMainWindow): elif modifiers == QtCore.Qt.NoModifier: if key == QtCore.Qt.Key_Escape or key == 'Escape': sel_obj = self.app.collection.get_active() - assert sel_obj.kind == 'geometry', "Expected a Geometry Object, got %s" % type(sel_obj) + assert sel_obj.kind == 'geometry' or sel_obj.kind == 'excellon', \ + "Expected a Geometry or Excellon Object, got %s" % type(sel_obj) sel_obj.area_disconnect() return diff --git a/CHANGELOG.md b/CHANGELOG.md index a0457748..ce536790 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,11 @@ CHANGELOG for FlatCAM beta ================================================= +21.05.2020 + +- added the Exclusion zones processing to Excellon GCode generation +- fixed a non frequent plotting problem for CNCJob objects made out of Excellon objects + 19.05.2020 - updated the Italian language (translation incomplete) diff --git a/Common.py b/Common.py index efdd3252..705bed62 100644 --- a/Common.py +++ b/Common.py @@ -12,11 +12,13 @@ # ########################################################## from PyQt5 import QtCore -from shapely.geometry import Polygon, MultiPolygon +from shapely.geometry import Polygon, MultiPolygon, Point, LineString from AppGUI.VisPyVisuals import ShapeCollection from AppTool import AppTool +from copy import deepcopy + import numpy as np import gettext @@ -167,8 +169,8 @@ class ExclusionAreas(QtCore.QObject): { "obj_type": string ("excellon" or "geometry") <- self.obj_type "shape": Shapely polygon - "strategy": string ("over" or "around") <- self.strategy - "overz": float <- self.over_z + "strategy": string ("over" or "around") <- self.strategy_button + "overz": float <- self.over_z_button } ''' self.exclusion_areas_storage = [] @@ -178,9 +180,9 @@ class ExclusionAreas(QtCore.QObject): self.solid_geometry = [] self.obj_type = None - self.shape_type = 'square' # TODO use the self.app.defaults when made general (not in Geo object Pref UI) - self.over_z = 0.1 - self.strategy = None + self.shape_type_button = None + self.over_z_button = None + self.strategy_button = None self.cnc_button = None def on_add_area_click(self, shape_button, overz_button, strategy_radio, cnc_button, solid_geo, obj_type): @@ -188,21 +190,25 @@ class ExclusionAreas(QtCore.QObject): :param shape_button: a FCButton that has the value for the shape :param overz_button: a FCDoubleSpinner that holds the Over Z value - :param strategy_radio: a RadioSet button with the strategy value + :param strategy_radio: a RadioSet button with the strategy_button value :param cnc_button: a FCButton in Object UI that when clicked the CNCJob is created We have a reference here so we can change the color signifying that exclusion areas are available. :param solid_geo: reference to the object solid geometry for which we add exclusion areas - :param obj_type: Type of FlatCAM object that called this method - :type obj_type: String: "excellon" or "geometry" - :return: + :param obj_type: Type of FlatCAM object that called this method. String: "excellon" or "geometry" + :type obj_type: str + :return: None """ self.app.inform.emit('[WARNING_NOTCL] %s' % _("Click the start point of the area.")) self.app.call_source = 'geometry' - self.shape_type = shape_button.get_value() - self.over_z = overz_button.get_value() - self.strategy = strategy_radio.get_value() + self.shape_type_button = shape_button + + # TODO use the self.app.defaults when made general (not in Geo object Pref UI) + # self.shape_type_button.set_value('square') + + self.over_z_button = overz_button + self.strategy_button = strategy_radio self.cnc_button = cnc_button self.solid_geometry = solid_geo @@ -240,11 +246,11 @@ class ExclusionAreas(QtCore.QObject): x1, y1 = curr_pos[0], curr_pos[1] - # shape_type = self.ui.area_shape_radio.get_value() + # shape_type_button = self.ui.area_shape_radio.get_value() # do clear area only for left mouse clicks if event.button == 1: - if self.shape_type == "square": + if self.shape_type_button.get_value() == "square": if self.first_click is False: self.first_click = True self.app.inform.emit('[WARNING_NOTCL] %s' % _("Click the end point of the area.")) @@ -268,14 +274,14 @@ class ExclusionAreas(QtCore.QObject): # { # "obj_type": string("excellon" or "geometry") < - self.obj_type # "shape": Shapely polygon - # "strategy": string("over" or "around") < - self.strategy - # "overz": float < - self.over_z + # "strategy_button": string("over" or "around") < - self.strategy_button + # "overz": float < - self.over_z_button # } new_el = { "obj_type": self.obj_type, "shape": new_rectangle, - "strategy": self.strategy, - "overz": self.over_z + "strategy": self.strategy_button.get_value(), + "overz": self.over_z_button.get_value() } self.exclusion_areas_storage.append(new_el) @@ -305,7 +311,7 @@ class ExclusionAreas(QtCore.QObject): return "" elif event.button == right_button and self.mouse_is_dragging is False: - shape_type = self.shape_type + shape_type = self.shape_type_button.get_value() if shape_type == "square": self.first_click = False @@ -326,17 +332,19 @@ class ExclusionAreas(QtCore.QObject): pol = Polygon(self.points) # do not add invalid polygons even if they are drawn by utility geometry if pol.is_valid: - # { - # "obj_type": string("excellon" or "geometry") < - self.obj_type - # "shape": Shapely polygon - # "strategy": string("over" or "around") < - self.strategy - # "overz": float < - self.over_z - # } + """ + { + "obj_type": string("excellon" or "geometry") < - self.obj_type + "shape": Shapely polygon + "strategy": string("over" or "around") < - self.strategy_button + "overz": float < - self.over_z_button + } + """ new_el = { "obj_type": self.obj_type, "shape": pol, - "strategy": self.strategy, - "overz": self.over_z + "strategy": self.strategy_button.get_value(), + "overz": self.over_z_button.get_value() } self.exclusion_areas_storage.append(new_el) @@ -382,9 +390,9 @@ class ExclusionAreas(QtCore.QObject): if len(self.exclusion_areas_storage) == 0: return - self.app.inform.emit( - "[success] %s" % _("Exclusion areas added. Checking overlap with the object geometry ...")) - + # since the exclusion areas should apply to all objects in the app collection, this check is limited to + # only the current object therefore it will not guarantee success + self.app.inform.emit("%s" % _("Exclusion areas added. Checking overlap with the object geometry ...")) for el in self.exclusion_areas_storage: if el["shape"].intersects(MultiPolygon(self.solid_geometry)): self.on_clear_area_click() @@ -406,8 +414,6 @@ class ExclusionAreas(QtCore.QObject): ) self.e_shape_modified.emit() - for k in self.exclusion_areas_storage: - print(k) def area_disconnect(self): if self.app.is_legacy is False: @@ -436,7 +442,7 @@ class ExclusionAreas(QtCore.QObject): # called on mouse move def on_mouse_move(self, event): - shape_type = self.shape_type + shape_type = self.shape_type_button.get_value() if self.app.is_legacy is False: event_pos = event.pos @@ -573,3 +579,194 @@ class ExclusionAreas(QtCore.QObject): self.cnc_button.setToolTip('%s' % _("Generate the CNC Job object.")) self.app.inform.emit('[success] %s' % _("All exclusion zones deleted.")) + + def travel_coordinates(self, start_point, end_point, tooldia): + """ + WIll create a path the go around the exclusion areas on the shortest path + + :param start_point: X,Y coordinates for the start point of the travel line + :type start_point: tuple + :param end_point: X,Y coordinates for the destination point of the travel line + :type end_point: tuple + :param tooldia: THe tool diameter used and which generates the travel lines + :type tooldia float + :return: A list of x,y tuples that describe the avoiding path + :rtype: list + """ + + ret_list = [] + + # Travel lines: rapids. Should not pass through Exclusion areas + travel_line = LineString([start_point, end_point]) + origin_point = Point(start_point) + + buffered_storage = [] + # add a little something to the half diameter, to make sure that we really don't enter in the exclusion zones + buffered_distance = (tooldia / 2.0) + (0.1 if self.app.defaults['units'] == 'MM' else 0.00393701) + + for area in self.exclusion_areas_storage: + new_area = deepcopy(area) + new_area['shape'] = area['shape'].buffer(buffered_distance, join_style=2) + buffered_storage.append(new_area) + + # sort the Exclusion areas from the closest to the start_point to the farthest + tmp = [] + for area in buffered_storage: + dist = Point(start_point).distance(area['shape']) + tmp.append((dist, area)) + tmp.sort(key=lambda k: k[0]) + + sorted_area_storage = [k[1] for k in tmp] + + # process the ordered exclusion areas list + for area in sorted_area_storage: + outline = area['shape'].exterior + if travel_line.intersects(outline): + intersection_pts = travel_line.intersection(outline) + + if isinstance(intersection_pts, Point): + # it's just a touch, continue + continue + + entry_pt = nearest_point(origin_point, intersection_pts) + exit_pt = farthest_point(origin_point, intersection_pts) + + if area['strategy'] == 'around': + full_vertex_points = [Point(x) for x in list(outline.coords)] + + # the last coordinate in outline, a LinearRing, is the closing one + # therefore a duplicate of the first one; discard it + vertex_points = full_vertex_points[:-1] + + # dist_from_entry = [(entry_pt.distance(vt), vertex_points.index(vt)) for vt in vertex_points] + # closest_point_entry = nsmallest(1, dist_from_entry, key=lambda x: x[0]) + # start_idx = closest_point_entry[0][1] + # + # dist_from_exit = [(exit_pt.distance(vt), vertex_points.index(vt)) for vt in vertex_points] + # closest_point_exit = nsmallest(1, dist_from_exit, key=lambda x: x[0]) + # end_idx = closest_point_exit[0][1] + + pts_line_entry = None + pts_line_exit = None + for i in range(len(full_vertex_points) - 1): + line = LineString( + [ + (full_vertex_points[i].x, full_vertex_points[i].y), + (full_vertex_points[i + 1].x, full_vertex_points[i + 1].y) + ] + ) + if entry_pt.intersects(line) or entry_pt.almost_equals(Point(line.coords[0]), decimal=3) or \ + entry_pt.almost_equals(Point(line.coords[1]), decimal=3): + pts_line_entry = [Point(x) for x in line.coords] + + if exit_pt.intersects(line) or exit_pt.almost_equals(Point(line.coords[0]), decimal=3) or \ + exit_pt.almost_equals(Point(line.coords[1]), decimal=3): + pts_line_exit = [Point(x) for x in line.coords] + + closest_point_entry = nearest_point(entry_pt, pts_line_entry) + start_idx = vertex_points.index(closest_point_entry) + + closest_point_exit = nearest_point(exit_pt, pts_line_exit) + end_idx = vertex_points.index(closest_point_exit) + + # calculate possible paths: one clockwise the other counterclockwise on the exterior of the + # exclusion area outline (Polygon.exterior) + vp_len = len(vertex_points) + if end_idx > start_idx: + path_1 = vertex_points[start_idx:(end_idx + 1)] + path_2 = [vertex_points[start_idx]] + idx = start_idx + for __ in range(vp_len): + idx = idx - 1 if idx > 0 else (vp_len - 1) + path_2.append(vertex_points[idx]) + if idx == end_idx: + break + else: + path_1 = vertex_points[end_idx:(start_idx + 1)] + path_2 = [vertex_points[end_idx]] + idx = end_idx + for __ in range(vp_len): + idx = idx - 1 if idx > 0 else (vp_len - 1) + path_2.append(vertex_points[idx]) + if idx == start_idx: + break + path_1.reverse() + path_2.reverse() + + # choose the one with the lesser length + length_path_1 = 0 + for i in range(len(path_1)): + try: + length_path_1 += path_1[i].distance(path_1[i + 1]) + except IndexError: + pass + + length_path_2 = 0 + for i in range(len(path_2)): + try: + length_path_2 += path_2[i].distance(path_2[i + 1]) + except IndexError: + pass + + path = path_1 if length_path_1 < length_path_2 else path_2 + + # transform the list of Points into a list of Points coordinates + path_coords = [[None, (p.x, p.y)] for p in path] + ret_list += path_coords + + else: + path_coords = [[float(area['overz']), (entry_pt.x, entry_pt.y)], [None, (exit_pt.x, exit_pt.y)]] + ret_list += path_coords + + # create a new LineString to test again for possible other Exclusion zones + last_pt_in_path = path_coords[-1][1] + travel_line = LineString([last_pt_in_path, end_point]) + + ret_list.append([None, end_point]) + return ret_list + + +def farthest_point(origin, points_list): + """ + Calculate the farthest Point in a list from another Point + + :param origin: Reference Point + :type origin: Point + :param points_list: List of Points or a MultiPoint + :type points_list: list + :return: Farthest Point + :rtype: Point + """ + old_dist = 0 + fartherst_pt = None + + for pt in points_list: + dist = abs(origin.distance(pt)) + if dist >= old_dist: + fartherst_pt = pt + old_dist = dist + + return fartherst_pt + + +def nearest_point(origin, points_list): + """ + Calculate the nearest Point in a list from another Point + + :param origin: Reference Point + :type origin: Point + :param points_list: List of Points or a MultiPoint + :type points_list: list + :return: Nearest Point + :rtype: Point + """ + old_dist = np.Inf + nearest_pt = None + + for pt in points_list: + dist = abs(origin.distance(pt)) + if dist <= old_dist: + nearest_pt = pt + old_dist = dist + + return nearest_pt diff --git a/camlib.py b/camlib.py index 905586dd..492b86af 100644 --- a/camlib.py +++ b/camlib.py @@ -2630,16 +2630,17 @@ class CNCjob(Geometry): def generate_from_excellon_by_tool(self, exobj, tools="all", use_ui=False): """ - Creates gcode for this object from an Excellon object + Creates Gcode for this object from an Excellon object for the specified tools. - :param exobj: Excellon object to process - :type exobj: Excellon - :param tools: Comma separated tool names - :type: tools: str - :param use_ui: Bool, if True the method will use parameters set in UI - :return: None - :rtype: None + :param exobj: Excellon object to process + :type exobj: Excellon + :param tools: Comma separated tool names + :type tools: str + :param use_ui: if True the method will use parameters set in UI + :type use_ui: bool + :return: None + :rtype: None """ # create a local copy of the exobj.drills so it can be used for creating drill CCode geometry @@ -2780,7 +2781,7 @@ class CNCjob(Geometry): self.app.inform.emit(_("Creating a list of points to drill...")) - # Points (Group by tool) + # Points (Group by tool): a dictionary of shapely Point geo elements grouped by tool number points = {} for drill in exobj.drills: if self.app.abort_flag: @@ -2795,6 +2796,17 @@ class CNCjob(Geometry): # log.debug("Found %d drills." % len(points)) + # check if there are drill points in the exclusion areas. + # If we find any within the exclusion areas return 'fail' + for tool in points: + for pt in points[tool]: + for area in self.app.exc_areas.exclusion_areas_storage: + pt_buf = pt.buffer(exobj.tools[tool]['C'] / 2.0) + if pt_buf.within(area['shape']) or pt_buf.intersects(area['shape']): + self.app.inform.emit("[ERROR_NOTCL] %s" % _("Failed. Drill points inside the exclusion zones.")) + return 'fail' + + # this holds the resulting GCode self.gcode = [] self.f_plunge = self.app.defaults["excellon_f_plunge"] @@ -3042,7 +3054,41 @@ class CNCjob(Geometry): locx = locations[k][0] locy = locations[k][1] - gcode += self.doformat(p.rapid_code, x=locx, y=locy) + travels = self.app.exc_areas.travel_coordinates(start_point=(self.oldx, self.oldy), + end_point=(locx, locy), + tooldia=current_tooldia) + prev_z = None + for travel in travels: + locx = travel[1][0] + locy = travel[1][1] + + if travel[0] is not None: + # move to next point + gcode += self.doformat(p.rapid_code, x=locx, y=locy) + + # raise to safe Z (travel[0]) each time because safe Z may be different + self.z_move = travel[0] + gcode += self.doformat(p.lift_code, x=locx, y=locy) + + # restore z_move + self.z_move = exobj.tools[tool]['data']['travelz'] + else: + if prev_z is not None: + # move to next point + gcode += self.doformat(p.rapid_code, x=locx, y=locy) + + # we assume that previously the z_move was altered therefore raise to + # the travel_z (z_move) + self.z_move = exobj.tools[tool]['data']['travelz'] + gcode += self.doformat(p.lift_code, x=locx, y=locy) + else: + # move to next point + gcode += self.doformat(p.rapid_code, x=locx, y=locy) + + # store prev_z + prev_z = travel[0] + + # gcode += self.doformat(p.rapid_code, x=locx, y=locy) if self.multidepth and abs(self.z_cut) > abs(self.z_depthpercut): doc = deepcopy(self.z_cut) @@ -3260,7 +3306,41 @@ class CNCjob(Geometry): locx = locations[k][0] locy = locations[k][1] - gcode += self.doformat(p.rapid_code, x=locx, y=locy) + travels = self.app.exc_areas.travel_coordinates(start_point=(self.oldx, self.oldy), + end_point=(locx, locy), + tooldia=current_tooldia) + prev_z = None + for travel in travels: + locx = travel[1][0] + locy = travel[1][1] + + if travel[0] is not None: + # move to next point + gcode += self.doformat(p.rapid_code, x=locx, y=locy) + + # raise to safe Z (travel[0]) each time because safe Z may be different + self.z_move = travel[0] + gcode += self.doformat(p.lift_code, x=locx, y=locy) + + # restore z_move + self.z_move = exobj.tools[tool]['data']['travelz'] + else: + if prev_z is not None: + # move to next point + gcode += self.doformat(p.rapid_code, x=locx, y=locy) + + # we assume that previously the z_move was altered therefore raise to + # the travel_z (z_move) + self.z_move = exobj.tools[tool]['data']['travelz'] + gcode += self.doformat(p.lift_code, x=locx, y=locy) + else: + # move to next point + gcode += self.doformat(p.rapid_code, x=locx, y=locy) + + # store prev_z + prev_z = travel[0] + + # gcode += self.doformat(p.rapid_code, x=locx, y=locy) if self.multidepth and abs(self.z_cut) > abs(self.z_depthpercut): doc = deepcopy(self.z_cut) @@ -3429,7 +3509,41 @@ class CNCjob(Geometry): locx = point[0] locy = point[1] - gcode += self.doformat(p.rapid_code, x=locx, y=locy) + travels = self.app.exc_areas.travel_coordinates(start_point=(self.oldx, self.oldy), + end_point=(locx, locy), + tooldia=current_tooldia) + prev_z = None + for travel in travels: + locx = travel[1][0] + locy = travel[1][1] + + if travel[0] is not None: + # move to next point + gcode += self.doformat(p.rapid_code, x=locx, y=locy) + + # raise to safe Z (travel[0]) each time because safe Z may be different + self.z_move = travel[0] + gcode += self.doformat(p.lift_code, x=locx, y=locy) + + # restore z_move + self.z_move = exobj.tools[tool]['data']['travelz'] + else: + if prev_z is not None: + # move to next point + gcode += self.doformat(p.rapid_code, x=locx, y=locy) + + # we assume that previously the z_move was altered therefore raise to + # the travel_z (z_move) + self.z_move = exobj.tools[tool]['data']['travelz'] + gcode += self.doformat(p.lift_code, x=locx, y=locy) + else: + # move to next point + gcode += self.doformat(p.rapid_code, x=locx, y=locy) + + # store prev_z + prev_z = travel[0] + + # gcode += self.doformat(p.rapid_code, x=locx, y=locy) if self.multidepth and abs(self.z_cut) > abs(self.z_depthpercut): doc = deepcopy(self.z_cut) @@ -4827,14 +4941,27 @@ class CNCjob(Geometry): # plot the geometry of Excellon objects if self.origin_kind == 'excellon': try: - poly = Polygon(geo['geom']) - except ValueError: - # if the geos are travel lines it will enter into Exception - poly = geo['geom'].buffer(distance=(tooldia / 1.99999999), resolution=self.steps_per_circle) + if geo['kind'][0] == 'T': + # if the geos are travel lines it will enter into Exception + poly = geo['geom'].buffer(distance=(tooldia / 1.99999999), + resolution=self.steps_per_circle) + else: + poly = Polygon(geo['geom']) + poly = poly.simplify(tool_tolerance) except Exception: # deal here with unexpected plot errors due of LineStrings not valid continue + + # try: + # poly = Polygon(geo['geom']) + # except ValueError: + # # if the geos are travel lines it will enter into Exception + # poly = geo['geom'].buffer(distance=(tooldia / 1.99999999), resolution=self.steps_per_circle) + # poly = poly.simplify(tool_tolerance) + # except Exception: + # # deal here with unexpected plot errors due of LineStrings not valid + # continue else: # plot the geometry of any objects other than Excellon poly = geo['geom'].buffer(distance=(tooldia / 1.99999999), resolution=self.steps_per_circle)