Tue, 26 Sep 2017 19:08:02 +0200
SVG options: offset, original scale
SVG Bugfix: "scale" does now really fit to dimensions
#!/usr/bin/env python # -*- coding: utf-8 -*- import svg, sys, math from pprint import pprint from gcode import Gcode from optparse import OptionParser from tinycss import CSS21Parser from shapely.geometry import box, MultiLineString, Polygon from shapely.affinity import rotate from shapely import speedups from math import sqrt from functools import cmp_to_key # enable Shapely speedups, if possible if speedups.available: print "shapely speedups available and enabled!" speedups.enable() def hatchbox(rect, angle, spacing): """ returns a Shapely geometry (MULTILINESTRING, or more rarely, GEOMETRYCOLLECTION) for a simple hatched rectangle. args: rect - a Shapely geometry for the outer boundary of the hatch Likely most useful if it really is a rectangle angle - angle of hatch lines, conventional anticlockwise -ve spacing - spacing between hatch lines GEOMETRYCOLLECTION case occurs when a hatch line intersects with the corner of the clipping rectangle, which produces a point along with the usual lines. """ (llx, lly, urx, ury) = rect.bounds centre_x = (urx + llx) / 2 centre_y = (ury + lly) / 2 diagonal_length = sqrt((urx - llx) ** 2 + (ury - lly) ** 2) number_of_lines = 2 + int(diagonal_length / spacing) hatch_length = spacing * (number_of_lines - 1) # build a square (of side hatch_length) horizontal lines # centred on centroid of the bounding box, 'spacing' units apart coords = [] for i in range(number_of_lines): # alternate lines l2r and r2l to keep HP-7470A plotter happy ☺ if i % 2: coords.extend([(( centre_x - hatch_length / 2, \ centre_y - hatch_length / 2 + i * spacing), ( centre_x + hatch_length / 2, \ centre_y - hatch_length / 2 + i * spacing))]) else: coords.extend([(( \ centre_x + hatch_length / 2, \ centre_y - hatch_length / 2 + i * spacing), ( centre_x - hatch_length / 2, \ centre_y - hatch_length / 2 + i * spacing))]) # turn array into Shapely object lines = MultiLineString(coords) # Rotate by angle around box centre lines = rotate(lines, angle, origin='centroid', use_radians=False) # return clipped array return rect.intersection(lines) def parse_style(stylestr): """ Parse the given string containing CSS2.1 syntax Returns a dict with the keys/values """ if not stylestr or stylestr.strip() == '': return None parser = CSS21Parser() style = parser.parse_style_attr(stylestr) data = {} for obj in style[0]: val = obj.value[0] if hasattr(val, 'value'): data[obj.name] = val.value return data class Image(object): """ SVG Image handler class """ def __init__(self, filename, options, gcoder): self.gcoder = gcoder self.options = options self.svg = svg.parse(filename) self.bb1, self.bb2 = self.svg.bbox() self.width, self.height = self.bb2.coord() self.infill = None self._check_dimensions() self._generate_infill() def _check_dimensions(self): """ Output image dimensions/scaling to console and gcode """ msg = "Original dimension: %.2f x %.2f" % (self.width, self.height) print msg self.gcoder.comment(msg) self.gcoder.comment("Scale: %.2f" % (self.options.scale)) width = self.width * self.gcoder.mm_pixel * self.options.scale height = self.height * self.gcoder.mm_pixel * self.options.scale msg = "Print dimension: %.2fmm x %.2fmm" % (width, height) print msg self.gcoder.comment(msg) def _generate_infill(self): """ Generates infill pattern image for later use """ b1x, b1y = self.bb1.coord() b2x, b2y = self.bb2.coord() page = box(b1x, b1y, b2x, b2y) # TODO: Infill spacing needs to be calculated with proper scaling and gcode MM dimensions, when set to 0 (auto) # Make infill angle 0, 45 or 90 degrees configurable to options parser (0° = X, 90° = Y, 45° = X and Y but half the speed/accel needed!) self.infill = hatchbox(page, self.options.infill_angle, self.options.infill_spacing) def normalize(self, coord): """ Normalize X / Y Axis of coordinates At the moment only Y gets flipped to match Reprap coordinate system (0,0 is bottom left instead top left on SVG) """ c_x = coord[0] c_y = coord[1] # flip y c_y = (self.height - c_y) return (c_x, c_y) def get_drawings(self): """ Returns a list of all svg drawings with segments attribute """ data = [] for dwg in self.svg.flatten(): if hasattr(dwg, "segments"): data.append(dwg) return data def cmp_smallest_distance(line1, line2, coords1=None, coords2=None): if not coords1: coords1 = list(line1.coords) if not coords2: coords2 = list(line2.coords) dist = [ abs(math.hypot(coords1[0][0] - coords2[0][0], coords1[0][1] - coords2[0][1])), abs(math.hypot(coords1[0][0] - coords2[1][0], coords1[0][1] - coords2[1][1])), abs(math.hypot(coords1[1][0] - coords2[0][0], coords1[1][1] - coords2[0][1])), abs(math.hypot(coords1[1][0] - coords2[1][0], coords1[1][1] - coords2[1][1])) ] # return the smallest distance between the two lines # check both start and endpoints to each other return sorted(dist)[0] def slow_sort_lines_by_distance(multilines): lines = [] coords = [] for line in multilines: lines.append(line) # coords list for brutal speedup! # without this it would be terrible_slow_sort_lines_by_distance() coords.append(list(line.coords)) data = [lines.pop(0)] last = coords.pop(0) def pop_nearest(line, last): idx = -1 dist = 99999999 for test in lines: idx += 1 tmp = cmp_smallest_distance(line, test, last, coords[idx]) if tmp < dist: dist = tmp dist_idx = idx # nearest item found return (lines.pop(dist_idx), coords.pop(dist_idx)) print "Optimizing infill movement, please wait..." while len(lines) > 0: tmp = len(lines) if not (tmp % 10): sys.stdout.write("\r%d " % tmp) sys.stdout.flush() tmp = pop_nearest(data[-1], last) data.append(tmp[0]) last = tmp[1] print "\rdone" return data def svg2gcode(options, gcoder): image = Image(options.filename, options, gcoder) for dwg in image.get_drawings(): for l in dwg.segments(1): # THE OUTLINE coord = image.normalize(l[0].coord()) gcoder.move(coord[0], coord[1]) for pt in l[1:]: coord = image.normalize(pt.coord()) gcoder.engrave(coord[0], coord[1]) if options.outline: continue if isinstance(dwg, svg.Polygon) or isinstance(dwg, svg.Path): #check if we should infill? style = parse_style(dwg.style) if not style: continue if not 'fill' in style.keys(): continue if style['fill'] == 'none': continue # try to generate the infill poly complex poly = None for l in dwg.segments(1): segments = [] for pnt in l: segments.append(pnt.coord()) shape = Polygon(segments) #if shape.is_valid: if not poly: poly = shape else: try: if shape.within(poly): poly = poly.difference(shape) else: poly = poly.union(shape) except Exception: pass if not poly: continue try: lines = poly.intersection(image.infill) except Exception: lines = None if lines: #pprint (dir(lines)) # THE INFILL prev_end = None # sort lines by nearest lines_ordered = slow_sort_lines_by_distance(lines) #lines_distances = [] #prev_line = lines[0] #for line in lines: # lines_distances.append(cmp_smallest_distance(line, prev_line)) # prev_line = line ##lines_ordered = sorted(lines, key=cmp_to_key(cmp_smallest_distance)) ## decorate, sort, undecorate: #lines_distances, lines = zip(*sorted(zip(lines_distances, lines))) for line in lines_ordered: coords = [ image.normalize((line.coords[0][0], line.coords[0][1])), image.normalize((line.coords[1][0], line.coords[1][1])) ] if prev_end: # calculate distances to previous end, swap if current end is nearest dist = [ abs(math.hypot(coords[0][0] - prev_end[0], coords[0][1] - prev_end[1])), abs(math.hypot(coords[1][0] - prev_end[0], coords[1][1] - prev_end[1])) ] if dist[0] > dist[1]: coords = list(reversed(coords)) prev_end = coords[1] gcoder.move(coords[0][0], coords[0][1]) gcoder.engrave(coords[1][0], coords[1][1]) def init_options(): parser = OptionParser() parser.add_option("-f", "--file", dest="filename", default=None, help="Load SVG file", metavar="FILE") parser.add_option("-s", "--scale", dest="scale", type="float", default=1.0, help="set scale factor (default 1.0)") parser.add_option("-e", "", dest="engrave_speed", type="float", default=20, help="engrave speed mm/sec (default 20)") parser.add_option("-t", "", dest="travel_speed", type="float", default=130, help="travel speed mm/sec (default 130)") parser.add_option("-o", "--outline", action="store_true", dest="outline", default=False, help="no infill, only outlines") parser.add_option("", "--infill-angle", dest="infill_angle", type="int", default=45, help="infill angle: 0 = X, 90 = Y (default 45)") parser.add_option("", "--infill-spacing", dest="infill_spacing", type="float", default=1.5, help="infill spacing in SVG units (default 1.5)") return parser.parse_args() if __name__ == "__main__": (OPTIONS, ARGS) = init_options() if not OPTIONS.filename: print "no filename given!" sys.exit(1) # initialize gcode worker GCODER = Gcode(scale=OPTIONS.scale, travel_speed=OPTIONS.travel_speed, engrave_speed=OPTIONS.engrave_speed) # processing svg2gcode(OPTIONS, GCODER) # write gcode file GCODER.write(OPTIONS.filename + ".g")