From 6284156a0dc22e173275d58b06363e974bb53a65 Mon Sep 17 00:00:00 2001 From: Juan Pablo Caram Date: Wed, 5 Feb 2014 19:36:47 -0500 Subject: [PATCH] Double-sided PCB support. --- FlatCAM.py | 209 ++++++++++++++++++++++++++- FlatCAM.ui | 413 +++++++++++++++++++++++++++++++++++++++++++++++++++++ camlib.py | 95 +++++++++++- 3 files changed, 708 insertions(+), 9 deletions(-) diff --git a/FlatCAM.py b/FlatCAM.py index 686cc578..98bb3e22 100644 --- a/FlatCAM.py +++ b/FlatCAM.py @@ -1,3 +1,9 @@ +############################################################ +# Author: Juan Pablo Caram # +# Date: 2/5/2014 # +# caram.cl # +############################################################ + import threading from gi.repository import Gtk, Gdk, GLib, GObject import simplejson as json @@ -255,7 +261,7 @@ class FlatCAMGerber(FlatCAMObj, Gerber): def plot(self, figure): FlatCAMObj.plot(self, figure) - self.create_geometry() + #self.create_geometry() if self.options["mergepolys"]: geometry = self.solid_geometry @@ -334,7 +340,7 @@ class FlatCAMExcellon(FlatCAMObj, Excellon): def plot(self, figure): FlatCAMObj.plot(self, figure) #self.setup_axes(figure) - self.create_geometry() + #self.create_geometry() # Plot excellon for geo in self.solid_geometry: @@ -572,6 +578,7 @@ class App: self.setup_component_editor() # The "Selected" tab #### DATA #### + self.clipboard = Gtk.Clipboard.get(Gdk.SELECTION_CLIPBOARD) self.setup_obj_classes() self.stuff = {} # FlatCAMObj's by name self.mouse = None # Mouse coordinates over plot @@ -1229,10 +1236,196 @@ class App: self.info("Project loaded from: " + filename) + def populate_objects_combo(self, combo): + """ + Populates a Gtk.Comboboxtext with the list of the object in the project. + + :param combo: Name or instance of the comboboxtext. + :type combo: str or Gtk.ComboBoxText + :return: None + """ + print "Populating combo!" + if type(combo) == str: + combo = self.builder.get_object(combo) + + combo.remove_all() + for obj in self.stuff: + combo.append_text(obj) + ######################################## ## EVENT HANDLERS ## ######################################## + def on_create_mirror(self, widget): + """ + Creates a mirror image of a Gerber object to be used as a bottom + copper layer. + + :param widget: Ignored. + :return: None + """ + + # Layer to mirror + gerb_name = self.builder.get_object("comboboxtext_bottomlayer").get_active_text() + gerb = self.stuff[gerb_name] + + # For now, lets limit to Gerbers. + assert isinstance(gerb, FlatCAMGerber) + + # Mirror axis "X" or "Y + axis = self.get_radio_value({"rb_mirror_x": "X", + "rb_mirror_y": "Y"}) + mode = self.get_radio_value({"rb_mirror_box": "box", + "rb_mirror_point": "point"}) + if mode == "point": # A single point defines the mirror axis + # TODO: Error handling + px, py = eval(self.point_entry.get_text()) + else: # The axis is the line dividing the box in the middle + name = self.box_combo.get_active_text() + bb_obj = self.stuff[name] + xmin, ymin, xmax, ymax = bb_obj.bounds() + px = 0.5*(xmin+xmax) + py = 0.5*(ymin+ymax) + + # Do the mirroring + xscale, yscale = {"X": (1.0, -1.0), "Y": (-1.0, 1.0)}[axis] + mirrored = affinity.scale(gerb.solid_geometry, xscale, yscale, origin=(px, py)) + + def obj_init(obj_inst, app_inst): + obj_inst.solid_geometry = mirrored + + self.new_object("gerber", gerb.options["name"] + "_mirror", obj_init) + + def on_create_aligndrill(self, widget): + """ + Creates alignment holes Excellon object. Creates mirror duplicates + of the specified holes around the specified axis. + + :param widget: Ignored. + :return: None + """ + # Mirror axis. Same as in on_create_mirror. + axis = self.get_radio_value({"rb_mirror_x": "X", + "rb_mirror_y": "Y"}) + # TODO: Error handling + mode = self.get_radio_value({"rb_mirror_box": "box", + "rb_mirror_point": "point"}) + if mode == "point": + px, py = eval(self.point_entry.get_text()) + else: + name = self.box_combo.get_active_text() + bb_obj = self.stuff[name] + xmin, ymin, xmax, ymax = bb_obj.bounds() + px = 0.5*(xmin+xmax) + py = 0.5*(ymin+ymax) + xscale, yscale = {"X": (1.0, -1.0), "Y": (-1.0, 1.0)}[axis] + + # Tools + tools = {"1": self.get_eval("entry_dblsided_alignholediam")} + + # Parse hole list + # TODO: Better parsing + holes = self.builder.get_object("entry_dblsided_alignholes").get_text() + holes = eval("[" + holes + "]") + drills = [] + for hole in holes: + point = Point(hole) + point_mirror = affinity.scale(point, xscale, yscale, origin=(px, py)) + drills.append({"point": point, "tool": "1"}) + drills.append({"point": point_mirror, "tool": "1"}) + + def obj_init(obj_inst, app_inst): + obj_inst.tools = tools + obj_inst.drills = drills + obj_inst.create_geometry() + + self.new_object("excellon", "Alignment Drills", obj_init) + + + def on_toggle_pointbox(self, widget): + """ + Callback for radio selection change between point and box in the + Double-sided PCB tool. Updates the UI accordingly. + + :param widget: Ignored. + :return: None + """ + + # Where the entry or combo go + box = self.builder.get_object("box_pointbox") + + # Clear contents + children = box.get_children() + for child in children: + box.remove(child) + + choice = self.get_radio_value({"rb_mirror_point": "point", + "rb_mirror_box": "box"}) + + if choice == "point": + self.point_entry = Gtk.Entry() + self.builder.get_object("box_pointbox").pack_start(self.point_entry, + False, False, 1) + self.point_entry.show() + else: + self.box_combo = Gtk.ComboBoxText() + self.builder.get_object("box_pointbox").pack_start(self.box_combo, + False, False, 1) + self.populate_objects_combo(self.box_combo) + self.box_combo.show() + + + def on_tools_doublesided(self, param): + """ + Callback for menu item Tools->Double Sided PCB Tool. Launches the + tool placing its UI in the "Tool" tab in the notebook. + + :param param: Ignored. + :return: None + """ + + # Were are we drawing the UI + box_tool = self.builder.get_object("box_tool") + + # Remove anything else in the box + box_children = box_tool.get_children() + for child in box_children: + box_tool.remove(child) + + # Get the UI + osw = self.builder.get_object("offscreenwindow_dblsided") + sw = self.builder.get_object("sw_dblsided") + osw.remove(sw) + vp = self.builder.get_object("vp_dblsided") + vp.override_background_color(Gtk.StateType.NORMAL, Gdk.RGBA(1, 1, 1, 1)) + + # Put in the UI + box_tool.pack_start(sw, True, True, 0) + + # INITIALIZATION + # Populate combo box + self.populate_objects_combo("comboboxtext_bottomlayer") + + # Point entry + self.point_entry = Gtk.Entry() + box = self.builder.get_object("box_pointbox") + for child in box.get_children(): + box.remove(child) + box.pack_start(self.point_entry, False, False, 1) + + # Show the "Tool" tab + self.notebook.set_current_page(3) + sw.show_all() + def on_toggle_units(self, widget): + """ + Callback for the Units radio-button change in the Options tab. + Changes the application's default units or the current project's units. + If changing the project's units, the change propagates to all of + the objects in the project. + + :param widget: Ignored. + :return: None + """ if self.toggle_units_ignore: return @@ -1574,6 +1767,7 @@ class App: :param widget: Ignored. :return: None """ + # TODO: Use Gerber.get_bounding_box(...) gerber = self.get_current() gerber.read_form() name = self.selected_item_name + "_bbox" @@ -2094,8 +2288,10 @@ class App: GLib.idle_add(lambda: app_obj.set_progress_bar(0.1, "Opening Gerber ...")) def obj_init(gerber_obj, app_obj): + assert isinstance(gerber_obj, FlatCAMGerber) GLib.idle_add(lambda: app_obj.set_progress_bar(0.2, "Parsing ...")) gerber_obj.parse_file(filename) + gerber_obj.create_geometry() GLib.idle_add(lambda: app_obj.set_progress_bar(0.6, "Plotting ...")) name = filename.split('/')[-1].split('\\')[-1] @@ -2126,6 +2322,7 @@ class App: def obj_init(excellon_obj, app_obj): GLib.idle_add(lambda: app_obj.set_progress_bar(0.2, "Parsing ...")) excellon_obj.parse_file(filename) + excellon_obj.create_geometry() GLib.idle_add(lambda: app_obj.set_progress_bar(0.6, "Plotting ...")) name = filename.split('/')[-1].split('\\')[-1] @@ -2202,6 +2399,10 @@ class App: by the Matplotlib backend and has been registered in ``self.__init__()``. For details, see: http://matplotlib.org/users/event_handling.html + Default actions are: + + * Copy coordinates to clipboard. Ex.: (65.5473, -13.2679) + :param event: Contains information about the event, like which button was clicked, the pixel coordinates and the axes coordinates. :return: None @@ -2213,8 +2414,12 @@ class App: print 'button=%d, x=%d, y=%d, xdata=%f, ydata=%f' % ( event.button, event.x, event.y, event.xdata, event.ydata) + # TODO: This custom subscription mechanism is probably not necessary. for subscriber in self.plot_click_subscribers: self.plot_click_subscribers[subscriber](event) + + self.clipboard.set_text("(%.4f, %.4f)" % (event.xdata, event.ydata), -1) + except Exception, e: print "Outside plot!" diff --git a/FlatCAM.ui b/FlatCAM.ui index 46620147..90362812 100644 --- a/FlatCAM.ui +++ b/FlatCAM.ui @@ -6,6 +6,11 @@ False gtk-open + + True + False + gtk-page-setup + True False @@ -46,6 +51,366 @@ False gtk-open + + False + + + True + True + never + in + + + True + False + + + True + False + 5 + 5 + 5 + 5 + vertical + + + True + False + 6 + 3 + Double-Sided PCB Tool + + + + + + False + True + 0 + + + + + True + False + 3 + 3 + + + True + False + 1 + 3 + Bottom Layer: + + + 0 + 0 + 1 + 1 + + + + + 200 + True + False + start + 0 + 1 + + + 1 + 0 + 1 + 1 + + + + + True + False + 1 + 3 + Mirror Axis: + + + 0 + 1 + 1 + 1 + + + + + True + False + 10 + + + X + True + True + False + 0 + True + True + + + False + True + 0 + + + + + Y + True + True + False + 0 + True + rb_mirror_x + + + False + True + 1 + + + + + 1 + 1 + 1 + 1 + + + + + True + False + 1 + 3 + Axis location: + + + 0 + 2 + 1 + 1 + + + + + True + False + 10 + + + Point + True + True + False + 0 + True + True + + + + False + True + 0 + + + + + Box + True + True + False + 0 + True + rb_mirror_point + + + False + True + 1 + + + + + 1 + 2 + 1 + 1 + + + + + True + False + 1 + 3 + Point/Box: + + + 0 + 3 + 1 + 1 + + + + + True + False + vertical + + + + + + 1 + 3 + 1 + 1 + + + + + True + False + 1 + 3 + Algnmt holes: + + + 0 + 4 + 1 + 1 + + + + + True + True + + + + 1 + 4 + 1 + 1 + + + + + True + False + 1 + 3 + Drill diam.: + + + 0 + 5 + 1 + 1 + + + + + True + True + + True + + + 1 + 5 + 1 + 1 + + + + + False + True + 1 + + + + + True + False + end + 6 + 3 + + + Create Alignment Drill + 120 + True + True + True + end + + + + + False + False + 4 + 0 + + + + + Create Mirror + 120 + True + True + True + end + + + + + False + False + 4 + 1 + + + + + False + True + 2 + + + + + + + + + + + + + + + + + + + + + False @@ -2267,6 +2632,30 @@ to application defaults. + + + True + False + _Tools + True + + + True + False + + + Double-Sided PCB Tool + True + False + image10 + False + + + + + + + True @@ -3738,6 +4127,30 @@ to application defaults. False + + + True + False + vertical + + + + + + 3 + + + + + True + False + Tool + + + 3 + False + + False diff --git a/camlib.py b/camlib.py index 72597229..87f4ea0a 100644 --- a/camlib.py +++ b/camlib.py @@ -1,3 +1,9 @@ +############################################################ +# Author: Juan Pablo Caram # +# Date: 2/5/2014 # +# caram.cl # +############################################################ + from numpy import arctan2, Inf, array, sqrt, pi, ceil, sin, cos from matplotlib.figure import Figure import re @@ -16,6 +22,7 @@ from shapely.geometry.base import BaseGeometry from descartes.patch import PolygonPatch import simplejson as json +from matplotlib.pyplot import plot class Geometry: def __init__(self): @@ -202,9 +209,19 @@ class Gerber (Geometry): * ``buffered_paths`` (list): List of (Shapely) polygons resulting from *buffering* (or thickening) the ``paths`` with the aperture. These are generated from ``paths`` in ``buffer_paths()``. + + **USAGE** + + """ def __init__(self): + """ + The constructor takes no parameters. Use ``gerber.parse_files()`` + or ``gerber.parse_lines()`` to populate the object from Gerber source. + :return: Gerber object + :rtype: Gerber + """ # Initialize parent Geometry.__init__(self) @@ -457,8 +474,9 @@ class Gerber (Geometry): Every stroke (linear or circular) has an aperture which gives it thickness. Additionally, aperture strokes have non-zero area, and regions naturally do as well. + :rtype : None - @return: None + :return: None """ # if len(self.buffered_paths) == 0: # self.buffer_paths() @@ -470,6 +488,25 @@ class Gerber (Geometry): [poly['polygon'] for poly in self.regions] + self.flash_geometry) + def get_bounding_box(self, margin=0.0, rounded=False): + """ + Creates and returns a rectangular polygon bounding at a distance of + margin from the object's ``solid_geometry``. If margin > 0, the polygon + can optionally have rounded corners of radius equal to margin. + + :param margin: Distance to enlarge the rectangular bounding + box in both positive and negative, x and y axes. + :type margin: float + :param rounded: Wether or not to have rounded corners. + :type rounded: bool + :return: The bounding box. + :rtype: Shapely.Polygon + """ + bbox = self.solid_geometry.envelope.buffer(margin) + if not rounded: + bbox = bbox.envelope + return bbox + class Excellon(Geometry): """ @@ -488,6 +525,11 @@ class Excellon(Geometry): ================ ==================================== """ def __init__(self): + """ + The constructor takes no parameters. + :return: Excellon object. + :rtype: Excellon + """ Geometry.__init__(self) self.tools = {} @@ -1046,12 +1088,15 @@ def get_bounds(geometry_set): print "Getting bounds of:", str(geometry_set) for gs in geometry_set: - gxmin, gymin, gxmax, gymax = geometry_set[gs].bounds() - xmin = min([xmin, gxmin]) - ymin = min([ymin, gymin]) - xmax = max([xmax, gxmax]) - ymax = max([ymax, gymax]) - + try: + gxmin, gymin, gxmax, gymax = geometry_set[gs].bounds() + xmin = min([xmin, gxmin]) + ymin = min([ymin, gymin]) + xmax = max([xmax, gxmax]) + ymax = max([ymax, gymax]) + except: + print "DEV WARNING: Tried to get bounds of empty geometry." + return [xmin, ymin, xmax, ymax] @@ -1125,6 +1170,7 @@ def find_polygon(poly_set, point): return poly return None + def to_dict(geo): output = '' if isinstance(geo, BaseGeometry): @@ -1134,6 +1180,7 @@ def to_dict(geo): } return geo + def dict2obj(d): if '__class__' in d and '__inst__' in d: # For now assume all classes are Shapely geometry. @@ -1141,6 +1188,40 @@ def dict2obj(d): else: return d + +def plotg(geo): + try: + _ = iter(geo) + except: + geo = [geo] + + for g in geo: + if type(g) == Polygon: + x, y = g.exterior.coords.xy + plot(x, y) + for ints in g.interiors: + x, y = ints.coords.xy + plot(x, y) + continue + + if type(g) == LineString or type(g) == LinearRing: + x, y = g.coords.xy + plot(x, y) + continue + + if type(g) == Point: + x, y = g.coords.xy + plot(x, y, 'o') + continue + + try: + _ = iter(g) + plotg(g) + except: + print "Cannot plot:", str(type(g)) + continue + + ############### cam.py #################### def coord(gstr, digits, fraction): """