svg2gcode/svg2gcode.py

Fri, 28 Jul 2017 15:56:52 +0200

author
mdd
date
Fri, 28 Jul 2017 15:56:52 +0200
changeset 28
23efe2c53872
parent 13
e2fd4d7b3cb6
permissions
-rwxr-xr-x

Bugfix SVG offset correction
Changed settings min/max values

#!/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")

mercurial