From e6b5fd663261e71a2f1e4ea70bbd88e513af8a52 Mon Sep 17 00:00:00 2001 From: Juan Pablo Caram Date: Mon, 13 Jan 2014 01:25:57 -0500 Subject: [PATCH] Cutout generator implemented --- camlib.py | 251 +++++++++++++++++++++++++++-------------------------- cirkuix.py | 91 +++++++++++++++---- cirkuix.ui | 192 ++++++++++++++++++++++++++++++++++++++-- 3 files changed, 387 insertions(+), 147 deletions(-) diff --git a/camlib.py b/camlib.py index 88681e70..e89b3299 100644 --- a/camlib.py +++ b/camlib.py @@ -7,8 +7,10 @@ from shapely.geometry import MultiPoint, MultiPolygon from shapely.geometry import box as shply_box from shapely.ops import cascaded_union +# Used for solid polygons in Matplotlib from descartes.patch import PolygonPatch + class Geometry: def __init__(self): # Units (in or mm) @@ -18,20 +20,20 @@ class Geometry: self.solid_geometry = None def isolation_geometry(self, offset): - ''' + """ Creates contours around geometry at a given offset distance. - ''' + """ return self.solid_geometry.buffer(offset) def bounds(self): - ''' + """ Returns coordinates of rectangular bounds of geometry: (xmin, ymin, xmax, ymax). - ''' - if self.solid_geometry == None: + """ + if self.solid_geometry is None: print "Warning: solid_geometry not computed yet." - return (0,0,0,0) + return (0, 0, 0, 0) if type(self.solid_geometry) == list: return cascaded_union(self.solid_geometry).bounds @@ -39,33 +41,33 @@ class Geometry: return self.solid_geometry.bounds def size(self): - ''' + """ Returns (width, height) of rectangular bounds of geometry. - ''' - if self.solid_geometry == None: + """ + if self.solid_geometry is None: print "Warning: solid_geometry not computed yet." return 0 bounds = self.bounds() return (bounds[2]-bounds[0], bounds[3]-bounds[1]) def get_empty_area(self, boundary=None): - ''' + """ Returns the complement of self.solid_geometry within the given boundary polygon. If not specified, it defaults to the rectangular bounding box of self.solid_geometry. - ''' - if boundary == None: + """ + if boundary is None: boundary = self.solid_geometry.envelope return boundary.difference(self.solid_geometry) - def clear_polygon(self, polygon, tooldia, overlap = 0.15): - ''' + def clear_polygon(self, polygon, tooldia, overlap=0.15): + """ Creates geometry inside a polygon for a tool to cover the whole area. - ''' + """ poly_cuts = [polygon.buffer(-tooldia/2.0)] - while(1): + while True: polygon = poly_cuts[-1].buffer(-tooldia*(1-overlap)) if polygon.area > 0: poly_cuts.append(polygon) @@ -107,12 +109,12 @@ class Gerber (Geometry): self.flash_geometry = [] def fix_regions(self): - ''' + """ Overwrites the region polygons with fixed versions if found to be invalid (according to Shapely). - ''' + """ for region in self.regions: - if region['polygon'].is_valid == False: + if not region['polygon'].is_valid: region['polygon'] = region['polygon'].buffer(0) def buffer_paths(self): @@ -122,98 +124,98 @@ class Gerber (Geometry): self.buffered_paths.append(path["linestring"].buffer(width/2)) def aperture_parse(self, gline): - ''' + """ Parse gerber aperture definition into dictionary of apertures. - ''' + """ indexstar = gline.find("*") indexC = gline.find("C,") if indexC != -1: # Circle, example: %ADD11C,0.1*% apid = gline[4:indexC] - self.apertures[apid] = {"type":"C", - "size":float(gline[indexC+2:indexstar])} + self.apertures[apid] = {"type": "C", + "size": float(gline[indexC+2:indexstar])} return apid indexR = gline.find("R,") if indexR != -1: # Rectangle, example: %ADD15R,0.05X0.12*% apid = gline[4:indexR] indexX = gline.find("X") - self.apertures[apid] = {"type":"R", - "width":float(gline[indexR+2:indexX]), - "height":float(gline[indexX+1:indexstar])} + self.apertures[apid] = {"type": "R", + "width": float(gline[indexR+2:indexX]), + "height": float(gline[indexX+1:indexstar])} return apid indexO = gline.find("O,") if indexO != -1: # Obround apid = gline[4:indexO] indexX = gline.find("X") - self.apertures[apid] = {"type":"O", - "width":float(gline[indexO+2:indexX]), - "height":float(gline[indexX+1:indexstar])} + self.apertures[apid] = {"type": "O", + "width": float(gline[indexO+2:indexX]), + "height": float(gline[indexX+1:indexstar])} return apid print "WARNING: Aperture not implemented:", gline return None def parse_file(self, filename): - ''' + """ Calls Gerber.parse_lines() with array of lines read from the given file. - ''' + """ gfile = open(filename, 'r') gstr = gfile.readlines() gfile.close() self.parse_lines(gstr) def parse_lines(self, glines): - ''' + """ Main Gerber parser. - ''' - path = [] # Coordinates of the current path + """ + path = [] # Coordinates of the current path last_path_aperture = None current_aperture = None for gline in glines: - if gline.find("D01*") != -1: # pen down + if gline.find("D01*") != -1: # pen down path.append(coord(gline, self.digits, self.fraction)) last_path_aperture = current_aperture continue - if gline.find("D02*") != -1: # pen up + if gline.find("D02*") != -1: # pen up if len(path) > 1: # Path completed, create shapely LineString - self.paths.append({"linestring":LineString(path), - "aperture":last_path_aperture}) + self.paths.append({"linestring": LineString(path), + "aperture": last_path_aperture}) path = [coord(gline, self.digits, self.fraction)] continue indexD3 = gline.find("D03*") - if indexD3 > 0: # Flash - self.flashes.append({"loc":coord(gline, self.digits, self.fraction), - "aperture":current_aperture}) + if indexD3 > 0: # Flash + self.flashes.append({"loc": coord(gline, self.digits, self.fraction), + "aperture": current_aperture}) continue - if indexD3 == 0: # Flash? + if indexD3 == 0: # Flash? print "WARNING: Uninplemented flash style:", gline continue - if gline.find("G37*") != -1: # end region + if gline.find("G37*") != -1: # end region # Only one path defines region? - self.regions.append({"polygon":Polygon(path), - "aperture":last_path_aperture}) + self.regions.append({"polygon": Polygon(path), + "aperture": last_path_aperture}) path = [] continue - if gline.find("%ADD") != -1: # aperture definition - self.aperture_parse(gline) # adds element to apertures + if gline.find("%ADD") != -1: # aperture definition + self.aperture_parse(gline) # adds element to apertures continue indexstar = gline.find("*") - if gline.find("D") == 0: # Aperture change + if gline.find("D") == 0: # Aperture change current_aperture = gline[1:indexstar] continue - if gline.find("G54D") == 0: # Aperture change (deprecated) + if gline.find("G54D") == 0: # Aperture change (deprecated) current_aperture = gline[4:indexstar] continue - if gline.find("%FS") != -1: # Format statement + if gline.find("%FS") != -1: # Format statement indexX = gline.find("X") self.digits = int(gline[indexX + 1]) self.fraction = int(gline[indexX + 2]) @@ -226,17 +228,17 @@ class Gerber (Geometry): "aperture":last_path_aperture}) def do_flashes(self): - ''' + """ Creates geometry for Gerber flashes (aperture on a single point). - ''' + """ self.flash_geometry = [] for flash in self.flashes: aperture = self.apertures[flash['aperture']] - if aperture['type'] == 'C': # Circles + if aperture['type'] == 'C': # Circles circle = Point(flash['loc']).buffer(aperture['size']/2) self.flash_geometry.append(circle) continue - if aperture['type'] == 'R': # Rectangles + if aperture['type'] == 'R': # Rectangles loc = flash['loc'] width = aperture['width'] height = aperture['height'] @@ -260,6 +262,7 @@ class Gerber (Geometry): [poly['polygon'] for poly in self.regions] + self.flash_geometry) + class Excellon(Geometry): def __init__(self): Geometry.__init__(self) @@ -275,9 +278,9 @@ class Excellon(Geometry): self.parse_lines(estr) def parse_lines(self, elines): - ''' + """ Main Excellon parser. - ''' + """ current_tool = "" for eline in elines: @@ -320,7 +323,7 @@ class Excellon(Geometry): if indexX != -1 and indexY != -1: x = float(int(eline[indexX+1:indexY])/10000.0) y = float(int(eline[indexY+1:-1])/10000.0) - self.drills.append({'point':Point((x,y)), 'tool':current_tool}) + self.drills.append({'point': Point((x, y)), 'tool': current_tool}) continue print "WARNING: Line ignored:", eline @@ -335,9 +338,10 @@ class Excellon(Geometry): self.solid_geometry.append(poly) self.solid_geometry = cascaded_union(self.solid_geometry) + class CNCjob(Geometry): - def __init__(self, units="in", kind="generic", z_move = 0.1, - feedrate = 3.0, z_cut = -0.002, tooldia = 0.0): + def __init__(self, units="in", kind="generic", z_move=0.1, + feedrate=3.0, z_cut=-0.002, tooldia=0.0): # Options self.kind = kind @@ -358,19 +362,17 @@ class CNCjob(Geometry): # Bounds of geometry given to CNCjob.generate_from_geometry() self.input_geometry_bounds = None - - - + # Output generated by CNCjob.create_gcode_geometry() #self.G_geometry = None self.gcode_parsed = None def generate_from_excellon(self, exobj): - ''' + """ Generates G-code for drilling from excellon text. self.gcode becomes a list, each element is a different job for each tool in the excellon code. - ''' + """ self.kind = "drill" self.gcode = [] @@ -399,23 +401,23 @@ class CNCjob(Geometry): gcode += t%point gcode += down + up - gcode += t%(0,0) - gcode += "M05\n" # Spindle stop + gcode += t%(0, 0) + gcode += "M05\n" # Spindle stop self.gcode.append(gcode) def generate_from_geometry(self, geometry, append=True, tooldia=None): - ''' + """ Generates G-Code for geometry (Shapely collection). - ''' - if tooldia == None: + """ + if tooldia is None: tooldia = self.tooldia else: self.tooldia = tooldia self.input_geometry_bounds = geometry.bounds - if append == False: + if not append: self.gcode = "" t = "G0%d X%.4fY%.4f\n" self.gcode = self.unitcode[self.units] + "\n" @@ -429,50 +431,50 @@ class CNCjob(Geometry): for geo in geometry: if type(geo) == Polygon: - path = list(geo.exterior.coords) # Polygon exterior - self.gcode += t%(0, path[0][0], path[0][1]) # Move to first point - self.gcode += "G01 Z%.4f\n"%self.z_cut # Start cutting + path = list(geo.exterior.coords) # Polygon exterior + self.gcode += t%(0, path[0][0], path[0][1]) # Move to first point + self.gcode += "G01 Z%.4f\n"%self.z_cut # Start cutting for pt in path[1:]: - self.gcode += t%(1, pt[0], pt[1]) # Linear motion to point - self.gcode += "G00 Z%.4f\n"%self.z_move # Stop cutting - for ints in geo.interiors: # Polygon interiors + self.gcode += t%(1, pt[0], pt[1]) # Linear motion to point + self.gcode += "G00 Z%.4f\n"%self.z_move # Stop cutting + for ints in geo.interiors: # Polygon interiors path = list(ints.coords) - self.gcode += t%(0, path[0][0], path[0][1]) # Move to first point - self.gcode += "G01 Z%.4f\n"%self.z_cut # Start cutting + self.gcode += t%(0, path[0][0], path[0][1]) # Move to first point + self.gcode += "G01 Z%.4f\n"%self.z_cut # Start cutting for pt in path[1:]: - self.gcode += t%(1, pt[0], pt[1]) # Linear motion to point - self.gcode += "G00 Z%.4f\n"%self.z_move # Stop cutting + self.gcode += t%(1, pt[0], pt[1]) # Linear motion to point + self.gcode += "G00 Z%.4f\n"%self.z_move # Stop cutting continue if type(geo) == LineString or type(geo) == LinearRing: path = list(geo.coords) - self.gcode += t%(0, path[0][0], path[0][1]) # Move to first point - self.gcode += "G01 Z%.4f\n"%self.z_cut # Start cutting + self.gcode += t%(0, path[0][0], path[0][1]) # Move to first point + self.gcode += "G01 Z%.4f\n"%self.z_cut # Start cutting for pt in path[1:]: - self.gcode += t%(1, pt[0], pt[1]) # Linear motion to point - self.gcode += "G00 Z%.4f\n"%self.z_move # Stop cutting + self.gcode += t%(1, pt[0], pt[1]) # Linear motion to point + self.gcode += "G00 Z%.4f\n"%self.z_move # Stop cutting continue if type(geo) == Point: path = list(geo.coords) - self.gcode += t%(0, path[0][0], path[0][1]) # Move to first point - self.gcode += "G01 Z%.4f\n"%self.z_cut # Start cutting - self.gcode += "G00 Z%.4f\n"%self.z_move # Stop cutting + self.gcode += t%(0, path[0][0], path[0][1]) # Move to first point + self.gcode += "G01 Z%.4f\n"%self.z_cut # Start cutting + self.gcode += "G00 Z%.4f\n"%self.z_move # Stop cutting continue print "WARNING: G-code generation not implemented for %s"%(str(type(geo))) self.gcode += "G00 Z%.4f\n"%self.z_move # Stop cutting self.gcode += "G00 X0Y0\n" - self.gcode += "M05\n" # Spindle stop + self.gcode += "M05\n" # Spindle stop def gcode_parse(self): steps_per_circ = 20 - ''' + """ G-Code parser (from self.gcode). Generates dictionary with single-segment LineString's and "kind" indicating cut or travel, fast or feedrate speed. - ''' + """ geometry = [] # TODO: ???? bring this into the class?? @@ -513,20 +515,19 @@ class CNCjob(Geometry): kind[1] = 'S' arcdir = [None, None, "cw", "ccw"] - if current['G'] in [0,1]: # line - geometry.append({'geom':LineString([(current['X'],current['Y']), - (x,y)]), 'kind':kind}) - if current['G'] in [2,3]: # arc + if current['G'] in [0, 1]: # line + geometry.append({'geom': LineString([(current['X'], current['Y']), + (x, y)]), 'kind': kind}) + if current['G'] in [2, 3]: # arc center = [gobj['I'] + current['X'], gobj['J'] + current['Y']] radius = sqrt(gobj['I']**2 + gobj['J']**2) start = arctan2( -gobj['J'], -gobj['I']) stop = arctan2(-center[1]+y, -center[0]+x) - geometry.append({'geom':arc(center, radius, start, stop, + geometry.append({'geom': arc(center, radius, start, stop, arcdir[current['G']], steps_per_circ), - 'kind':kind}) - - + 'kind': kind}) + # Update current instruction for code in gobj: current[code] = gobj[code] @@ -536,13 +537,13 @@ class CNCjob(Geometry): return geometry def plot(self, tooldia=None, dpi=75, margin=0.1, - color={"T":["#F0E24D", "#B5AB3A"], "C":["#5E6CFF", "#4650BD"]}, - alpha={"T":0.3, "C":1.0}): - ''' + color={"T": ["#F0E24D", "#B5AB3A"], "C": ["#5E6CFF", "#4650BD"]}, + alpha={"T": 0.3, "C": 1.0}): + """ Creates a Matplotlib figure with a plot of the G-code job. - ''' - if tooldia == None: + """ + if tooldia is None: tooldia = self.tooldia fig = Figure(dpi=dpi) @@ -571,12 +572,12 @@ class CNCjob(Geometry): return fig def plot2(self, axes, tooldia=None, dpi=75, margin=0.1, - color={"T":["#F0E24D", "#B5AB3A"], "C":["#5E6CFF", "#4650BD"]}, - alpha={"T":0.3, "C":1.0}): - ''' + color={"T": ["#F0E24D", "#B5AB3A"], "C": ["#5E6CFF", "#4650BD"]}, + alpha={"T": 0.3, "C":1.0}): + """ Plots the G-code job onto the given axes. - ''' - if tooldia == None: + """ + if tooldia is None: tooldia = self.tooldia if tooldia == 0: @@ -598,14 +599,13 @@ class CNCjob(Geometry): def create_geometry(self): self.solid_geometry = cascaded_union([geo['geom'] for geo in self.gcode_parsed]) - def gparse1b(gtext): - ''' + """ gtext is a single string with g-code - ''' + """ gcmds = [] - lines = gtext.split("\n") # TODO: This is probably a lot of work! + lines = gtext.split("\n") # TODO: This is probably a lot of work! for line in lines: line = line.strip() @@ -635,8 +635,8 @@ def gparse1b(gtext): # Separate codes in line parts = [] for p in range(n_codes-1): - parts.append( line[ codes_idx[p]:codes_idx[p+1] ].strip() ) - parts.append( line[codes_idx[-1]:].strip() ) + parts.append(line[codes_idx[p]:codes_idx[p+1]].strip()) + parts.append(line[codes_idx[-1]:].strip()) # Separate codes from values cmds = {} @@ -645,6 +645,7 @@ def gparse1b(gtext): gcmds.append(cmds) return gcmds + def get_bounds(geometry_set): xmin = Inf ymin = Inf @@ -660,12 +661,13 @@ def get_bounds(geometry_set): return [xmin, ymin, xmax, ymax] + def arc(center, radius, start, stop, direction, steps_per_circ): - da_sign = {"cw":-1.0, "ccw":1.0} + da_sign = {"cw": -1.0, "ccw": 1.0} points = [] - if direction=="ccw" and stop <= start: + if direction == "ccw" and stop <= start: stop += 2*pi - if direction=="cw" and stop >= start: + if direction == "cw" and stop >= start: stop -= 2*pi angle = abs(stop - start) @@ -677,20 +679,21 @@ def arc(center, radius, start, stop, direction, steps_per_circ): theta = start + delta_angle*i points.append([center[0]+radius*cos(theta), center[1]+radius*sin(theta)]) return LineString(points) - + + ############### cam.py #################### def coord(gstr,digits,fraction): - ''' + """ Parse Gerber coordinates - ''' + """ global gerbx, gerby xindex = gstr.find("X") yindex = gstr.find("Y") index = gstr.find("D") - if (xindex == -1): + if xindex == -1: x = gerbx y = int(gstr[(yindex+1):index])*(10**(-fraction)) - elif (yindex == -1): + elif yindex == -1: y = gerby x = int(gstr[(xindex+1):index])*(10**(-fraction)) else: @@ -698,7 +701,5 @@ def coord(gstr,digits,fraction): y = int(gstr[(yindex+1):index])*(10**(-fraction)) gerbx = x gerby = y - return [x,y] - - + return [x, y] ################ end of cam.py ############# diff --git a/cirkuix.py b/cirkuix.py index 154f3159..0cc4d262 100644 --- a/cirkuix.py +++ b/cirkuix.py @@ -86,10 +86,10 @@ def get_entry_float(entry): def get_entry_eval(entry): return eval(entry.get_text) -getters = {"entry_text":get_entry_text, - "entry_int":get_entry_int, - "entry_float":get_entry_float, - "entry_eval":get_entry_eval} +getters = {"entry_text": get_entry_text, + "entry_int": get_entry_int, + "entry_float": get_entry_float, + "entry_eval": get_entry_eval} setters = {"entry"} @@ -105,7 +105,7 @@ class App: self.builder.add_from_file(self.gladefile) self.window = self.builder.get_object("window1") self.window.set_title("Cirkuix") - self.positionLabel = self.builder.get_object("label3") + self.position_label = self.builder.get_object("label3") self.grid = self.builder.get_object("grid1") self.notebook = self.builder.get_object("notebook1") self.info_label = self.builder.get_object("label_status") @@ -248,20 +248,27 @@ class App: def plot_geometry(self, geometry): for geo in geometry.solid_geometry: - x, y = geo.exterior.coords.xy - self.axes.plot(x, y, 'r-') - for ints in geo.interiors: - x, y = ints.coords.xy + + if type(geo) == Polygon: + x, y = geo.exterior.coords.xy self.axes.plot(x, y, 'r-') + for ints in geo.interiors: + x, y = ints.coords.xy + self.axes.plot(x, y, 'r-') + continue + + if type(geo) == LineString or type(geo) == LinearRing: + x, y = geo.coords.xy + self.axes.plot(x, y, 'r-') + continue self.canvas.queue_draw() - - + def setup_component_viewer(self): - ''' + """ List or Tree where whatever has been loaded or created is displayed. - ''' + """ self.store = Gtk.ListStore(str) self.tree = Gtk.TreeView(self.store) select = self.tree.get_selection() @@ -353,7 +360,59 @@ class App: ######################################## ## EVENT HANDLERS ## ######################################## + def on_gerber_generate_boundary(self, widget): + margin = self.get_eval("entry_gerber_cutout_margin") + gap_size = self.get_eval("entry_gerber_cutout_gapsize") + gerber = self.stuff[self.selected_item_name] + minx, miny, maxx, maxy = gerber.bounds() + minx -= margin + maxx += margin + miny -= margin + maxy += margin + midx = 0.5 * (minx + maxx) + midy = 0.5 * (miny + maxy) + hgap = 0.5 * gap_size + pts = [[midx-hgap, maxy], + [minx, maxy], + [minx, midy+hgap], + [minx, midy-hgap], + [minx, miny], + [midx-hgap, miny], + [midx+hgap, miny], + [maxx, miny], + [maxx, midy-hgap], + [maxx, midy+hgap], + [maxx, maxy], + [midx+hgap, maxy]] + cases = {"tb": [[pts[0], pts[1], pts[4], pts[5]], + [pts[6], pts[7], pts[10], pts[11]]], + "lr": [[pts[9], pts[10], pts[1], pts[2]], + [pts[3], pts[4], pts[7], pts[8]]], + "4": [[pts[0], pts[1], pts[2]], + [pts[3], pts[4], pts[5]], + [pts[6], pts[7], pts[8]], + [pts[9], pts[10], pts[11]]]} + name = self.selected_item_name + "_cutout" + geometry = CirkuixGeometry(name) + cuts = None + if self.builder.get_object("rb_2tb").get_active(): + cuts = cases["tb"] + elif self.builder.get_object("rb_2lr").get_active(): + cuts = cases["lr"] + else: + cuts = cases["4"] + geometry.solid_geometry = cascaded_union([LineString(segment) for segment in cuts]) + + # Add to App and update. + self.stuff[name] = geometry + self.build_list() + def on_eval_update(self, widget): + """ + Modifies the content of a Gtk.Entry by running + eval() on its contents and puting it back as a + string. + """ # TODO: error handling here widget.set_text(str(eval(widget.get_text()))) @@ -384,7 +443,7 @@ class App: geometry = self.stuff[self.selected_item_name] job_name = self.selected_item_name + "_cnc" - job = CirkuixCNCjob(job_name, z_move = travelz, z_cut = cutz, feedrate = feedrate) + job = CirkuixCNCjob(job_name, z_move=travelz, z_cut=cutz, feedrate=feedrate) job.generate_from_geometry(geometry.solid_geometry) job.gcode_parse() job.create_geometry() @@ -613,11 +672,11 @@ class App: def on_mouse_move_over_plot(self, event): try: # May fail in case mouse not within axes - self.positionLabel.set_label("X: %.4f Y: %.4f"%( + self.position_label.set_label("X: %.4f Y: %.4f"%( event.xdata, event.ydata)) self.mouse = [event.xdata, event.ydata] except: - self.positionLabel.set_label("") + self.position_label.set_label("") self.mouse = None def on_click_over_plot(self, event): diff --git a/cirkuix.ui b/cirkuix.ui index b7e52544..9358d795 100644 --- a/cirkuix.ui +++ b/cirkuix.ui @@ -767,9 +767,6 @@ 6 - - - True @@ -785,7 +782,7 @@ False True - 8 + 7 @@ -830,7 +827,7 @@ False True - 9 + 8 @@ -842,12 +839,195 @@ + + False + True + 9 + + + + + True + False + 5 + 0 + 3 + Board cutout: + + + + False True 10 + + + True + False + 3 + + + True + False + 1 + Margin: + + + 0 + 0 + 1 + 1 + + + + + True + True + + + + 1 + 0 + 1 + 1 + + + + + True + True + + + + 1 + 1 + 1 + 1 + + + + + True + False + 1 + Gap size: + + + 0 + 1 + 1 + 1 + + + + + True + False + 1 + Gaps: + + + 0 + 2 + 1 + 1 + + + + + True + False + + + 2 (T/B) + True + True + False + 8 + 0 + True + True + rb_2lr + + + False + True + 0 + + + + + 2 (L/R) + True + True + False + 8 + 0 + True + True + + + False + True + 1 + + + + + 4 + True + True + False + 0 + True + True + rb_2lr + + + False + True + 2 + + + + + 1 + 2 + 1 + 1 + + + + + False + True + 11 + + + + + Generate Geometry + True + True + True + + + + + False + True + 12 + + + + + + + + @@ -997,7 +1177,7 @@ True False - _View + _Tools True