printrun-src/printrun/laser.py

Tue, 19 Jan 2021 20:25:47 +0100

author
mdd
date
Tue, 19 Jan 2021 20:25:47 +0100
changeset 43
f7e9bd735ce1
parent 42
ea4c43494a19
permissions
-rw-r--r--

NeoCube laser cutting improvements

"""
Lasercutter library
2015-2019 by NeoSoft, Malte Di Donato
Intended to use standalone or implemented in Pronterface/Printrun
"""

"""
LASERCUT SETTINGS
Will be overridden from pronterface settings
"""
E_FACTOR = 0.5

from PIL import Image
import sys
import math


# GENERAL HEADER AND FOOTER GCODE
GCODE_HEAD = """
; GCode generated by laser.py pronterface library (marlin code flavour)
; 2015-2019 by NeoSoft - Malte Di Donato

G21 ; Metric
; We assume Z is in focus height and laser head is focus at bottom left of image!
G92 X0 Y0 E0; set zero position - new origin
G90 ; absolute positioning
M201 X1000 Y1000 ; Set acceleration
M203 X1000 Y1000 ; Set max feedrate
M209 S0 ; disable firmware retraction, we dont want to burn holes...
;M85 S0 ; Disable idle hold timeout (BUG!)
M84 ; enable motors

"""

GCODE_FOOT = """
M400 ; Wait for all moves to finish
M5 ; Force laser off!
G0 X0 Y0 F%.4f ; Move back to origin
; M501 ; undo all settings made
""" % (100*60)


GCODE_HEAD_MELZI = """
; GCode generated by laser.py pronterface library (marlin code flavour)
; 2015-2019 by NeoSoft - Malte Di Donato

G21 ; Metric
; We assume Z is in focus height and laser head is focus at bottom left of image!
G92 X0 Y0 E0; set zero position - new origin
G90 ; absolute positioning
M82 ; Set extruder (laser) to absolute positioning
M201 X1000 Y1000 E1000 ; Set acceleration
M203 X1000 Y1000 Z4 E1000 ; Set max feedrate
M209 S0 ; disable firmware retraction, we dont want to burn holes...
M302 ; Allow cold extrudes - doesnt matter because we hack the extruder physically off with the M571 E mod
M571 S1 E1 ; Activate Laser output on extrusion, but block real motor movement!
;M85 S0 ; Disable idle hold timeout (BUG!)
M84 ; enable motors

"""

GCODE_FOOT_MELZI = """
M400 ; Wait for all moves to finish
M42 P28 S0 ; Force laser off!
;M85 S30 ; re-enable idle hold timeout (BUG!)
G0 X0 Y0 F%.4f ; Move back to origin
M571 S0 E0 ; disable extruder firmware hack
; M501 ; undo all settings made
""" % (100*60)


class LasercutterSettings:
    """
    Default settings object
    """
    def __init__(self):
        self.lc_engrave_speed = 10
        # 30mm/sec works for wood (regulate the output power to something between 10-30%)
        # 30mm/sec for black anodized aluminum to get a light engraving @ 100% power
        # 10mm/sec for black anodized aluminum to get maximum possible engraving! @ 100% power
        self.lc_travel_speed = 120

        # insert config option to enable the Melzi Marlin FW Hack (M571)
        self.lc_melzi_hack = False

        # BITMAP:
        self.lc_bitmap_speed_factor = 1.0
        self.lc_dpi = 300
        self.lc_grey_threshold = 0
        self.lc_change_dir = True
        self.lc_invert_cut = True

        # HPGL:
        self.lc_hpgl_speed_factor = 1.0

        # SVG:
        self.lc_svg_speed_factor = 1.0
        self.lc_svg_smoothness = 0.2
        self.lc_svg_width = 50
        self.lc_svg_height = 50
        self.lc_svg_scalemode = "scale"


class Lasercutter:
    """
    Lasercutter methods
    parameters: log = logger function (fuction has to accept a string)
    """
    def __init__(self, pronterwindow = None):
        if pronterwindow:
            self.pronterwindow = pronterwindow
            self.settings = pronterwindow.settings
            #self.log = pronterwindow.log
            self.log = self.log_print
            self.pronterwindow.clear_log(None)
        else:
            self.pronterwindow = None
            self.settings = LasercutterSettings()
            self.log = lambda : None

        # STATIC DEFINITIONS, DO NOT CHANGE WORLD's RULES!
        self.INCH = 25.4 # mm
        self.MM_PIXEL = round(self.INCH / self.settings.lc_dpi, 4)
        self.STEPS_PIXEL = self.MM_PIXEL * 80 # mine is 80 steps/mm on XY

        self.log("Lasercutter library initialized\n%d DPI (%f mm/pixel)" % (
            self.settings.lc_dpi, self.MM_PIXEL))
        if self.STEPS_PIXEL <= 5:
            self.log("WARNING: STEPS PER PIXEL NEEDS TO BE > 5 (otherwise marlin joins lines): %f" % (
                self.STEPS_PIXEL))
        self.log("Travel/Engrave speed: %d mm/sec, %d mm/sec" % (
            self.settings.lc_travel_speed, self.settings.lc_engrave_speed) )
        self.log("")
        
    def log_print(self, msg):
        print(msg)

    
    def pixel2bit(self, pixel):
        """Convert the pixel value to a bit."""
        # some really weird stuff here ;-P

        # RGB to greyscale
        #print pixel
        #print type(pixel)
        if isinstance(pixel, tuple):
            #rgb
            pixel = pixel[0]*0.2989 + pixel[1]*0.5870 + pixel[2]*0.1140
            if pixel > self.settings.lc_grey_threshold:
                return 1
            else:
                return 0

        # color palette
        # TODO: get the grey value of the palette index instead of using pixel which is the palette index?
        if pixel <= self.settings.lc_grey_threshold:
            return 1
        else:
            return 0

    def image2gcode(self, filename):
        """
        Open a image file and get the basic information about it.
        Then convert it to gcode (replacing the existing gcode buffer contents)
        """
        try:
            im = Image.open(filename)
        except:
            self.log("Unable to open %s" % filename)
            return False

        self.log("Converting Image for lasercut:")
        self.log("File: %s" % filename)
        self.log("format: %s, mode: %s" % (im.format, im.mode))
        width,height = im.size
        self.log("size: %d x %d pixels" % im.size)

        pix = im.load()

        fo = open(filename + ".g", "w")
        if self.settings.lc_melzi_hack:
            fo.write("; Filename: %s\n%s" % (filename, GCODE_HEAD_MELZI))
        else:
            fo.write("; Filename: %s\n%s" % (filename, GCODE_HEAD))

        fo.write(";Start engraving the raster image: %dx%d points @ %d DPI = %.0fx%.0f mm\n\n" % (
            im.size[0], im.size[1], self.settings.lc_dpi,
            im.size[0] * self.MM_PIXEL, im.size[1] * self.MM_PIXEL) )

        INVERT_Y = self.MM_PIXEL * (im.size[1] -1) * (-1)
        DIR = 1
        travel_speed = self.settings.lc_travel_speed * 60
        engrave_speed = self.settings.lc_engrave_speed * 60 * self.settings.lc_bitmap_speed_factor
        for X in range(im.size[0]):
            gcode_col = ""
            first_ymm = None
            first_xmm = None
            E = 0
            last_bit = 1 # we engrave on black pixel = 0
            START_Y = 0
            if DIR > 0:
                range_start = 0
                range_stop = im.size[1]
            else:
                range_start = im.size[1] -1
                range_stop = -1

            for Y in range(range_start, range_stop, DIR):
                YMM = abs((Y * self.MM_PIXEL) + INVERT_Y)
                XMM = X * self.MM_PIXEL

                #print "X %d Y %d" % (X, Y)
                bit = self.pixel2bit(pix[X, Y])
                if self.settings.lc_invert_cut:
                    if bit == 0:
                        bit = 1
                    else:
                        bit = 0
                if last_bit == bit:
                    if bit == 1:
                        # nothing to do,
                        continue
                    else:
                        # are we at the end of Y range?
                        #print Y
                        if (Y == (im.size[1] - 1)) or (Y == 0):
                            # draw line
                            if DIR > 0:
                                E = E + self.MM_PIXEL * (Y - START_Y)
                            else:
                                E = E + self.MM_PIXEL * (START_Y - Y)
                            gcode_col += "G1 X%.4f Y%.4f E%.4f F%.4f\n" % (
                                XMM, YMM, E * E_FACTOR, engrave_speed)
                            if not first_xmm:
                                first_xmm = XMM + 0.1 # little offset needed!
                                first_ymm = YMM * 1
                else:
                    if not first_xmm:
                        first_xmm = XMM + 0.1 # little offset needed!
                        first_ymm = YMM * 1
                    # bit value has changed!
                    if bit == 0:
                        # jump to start of line to write
                        START_Y = Y
                        gcode_col += "G0 X%.4f Y%.4f F%.4f\n" % (
                            XMM, YMM, travel_speed)
                        last_xmm = None
                        last_ymm = None
                    else:
                        # end of line to write
                        if DIR > 0:
                            E = E + (self.MM_PIXEL * (Y - START_Y))
                        else:
                            E = E + (self.MM_PIXEL * (START_Y - Y))
                        gcode_col += "G1 X%.4f Y%.4f E%.4f F%.4f\n" % (
                            XMM, YMM, E * E_FACTOR,  engrave_speed)
                last_bit = bit
            if gcode_col <> "":
                # we skip empty columns
                # place last position as G0 to be sure to switch off laser immediately at finish of the line!
                if first_xmm:
                    fo.write("G0 X%.4f Y%.4f F%.4f ; force laser off\n" % (
                            first_xmm, first_ymm, travel_speed))

                fo.write("M400 ; X=%d printing row: direction %i\nG92 E0\n%s" % (
                    X, DIR, gcode_col))
            if self.settings.lc_change_dir:
                DIR = DIR * (-1) # change y direction on every X

        if self.settings.lc_melzi_hack:
            fo.write(GCODE_FOOT_MELZI)
        else:
            fo.write(GCODE_FOOT)
        fo.close()

        if self.pronterwindow:
            self.log("")        
            self.pronterwindow.load_gcode_async(filename + '.g')

    def hpgl2gcode(self, filename):
        # FOR HPGL:
        SCALE_FACTOR = 1.0 / 40.0 # 40 plotter units
        OFFSET_X = 0.0
        OFFSET_Y = 0.0

        self.log("Converting HPGL plot for lasercut:")
        self.log("File: %s" % filename)
    
        fi = open(filename, "r")
        fo = open(filename + ".g", "w")
        if self.settings.lc_melzi_hack:
            fo.write("; Filename: %s\n%s" % (filename, GCODE_HEAD_MELZI))
        else:
            fo.write("; Filename: %s\n%s" % (filename, GCODE_HEAD))

        G = "0"
        LASER_STATE = 0
        last_coord = [0.0,0.0] 
        last_cmd = ""

        travel_speed = self.settings.lc_travel_speed * 60
        engrave_speed = self.settings.lc_engrave_speed * 60 * self.settings.lc_hpgl_speed_factor


        for line in fi.readlines():
            for action in line.split(";"):
                action = action.strip()
                if action != "":
                    cmd = action[:2]
                    if cmd == "PD":
                        LASER_STATE = 1
                    elif cmd == "PU":
                        LASER_STATE = 0
                        if last_cmd == "PD":
                            OFFSET_X = coord[0] * -1
                            OFFSET_Y = coord[1] * -1
                            fo.write("; PD PU detected, set coord offset %.4f x %.4f mm\n" % (
                                OFFSET_X, OFFSET_Y))
                    elif cmd == "PA" or cmd == "PR":
                        # TODO: convert relative coordinates to absolute here!
                        coord = action[2:].split(",")
                        coord[0] = (float(coord[0]) + OFFSET_X) * SCALE_FACTOR
                        coord[1] = (float(coord[1]) + OFFSET_Y) * SCALE_FACTOR
                        if LASER_STATE:
                            EN = " E%.4f F%.4f" % (
                              E_FACTOR *  math.hypot(coord[0] - last_coord[0], coord[1] - last_coord[1]),
                              engrave_speed)
                        else:
                            EN = " F%.4f" % travel_speed
                            
                        fo.write("G%d X%.4f Y%.4f%s\n" % (
                            LASER_STATE, coord[0], coord[1], EN) )
                        last_coord = coord
                    elif cmd == "IN":
                        pass
                    elif cmd == "PT":
                        print "Ignoring pen thickness"                
                    else:
                        print "UNKNOWN: %s" % action
                    last_cmd = cmd            
    
        if self.settings.lc_melzi_hack:
            fo.write(GCODE_FOOT_MELZI)
        else:
            fo.write(GCODE_FOOT)
        fi.close()
        fo.close()    

        if self.pronterwindow:
            self.log("")        
            self.pronterwindow.load_gcode_async(filename + '.g')

    def svg2gcode(self, filename):
        # Imports for SVG
        import xml.etree.ElementTree as ET
        from svg2gcode import shapes as shapes_pkg
        from svg2gcode.shapes import point_generator
        from svg2gcode import simplepath, cspsubdiv, cubicsuperpath, simpletransform

        bed_max_x = float(self.settings.lc_svg_width)
        bed_max_y = float(self.settings.lc_svg_height)

        self.log("Generating paths from SVG (outlines only)...")        
        svg_shapes = set(['rect', 'circle', 'ellipse', 'line', 'polyline', 'polygon', 'path'])
        tree = ET.parse(filename)
        root = tree.getroot()
    
        # Todo: force viewbox values configurable?
        #width = root.get('width')
        #height = root.get('height')
        # if width == None or height == None:
        viewbox = root.get('viewBox')
        if viewbox:
            _, _, width, height = viewbox.split()
        else:
            # no viewbox element, try to get from svg root element
            try:
                width = root.attrib["width"]
                height = root.attrib["height"]
                self.log("No ViewBox, got dimensions from root element)")
            except:
                width = None
                height = None

        if width == None or height == None:
            self.log("Unable to get width and height for the svg!")
            return False
        else:
            self.log("SVG Dimensions are %s x %s" % (width, height))

        # TODO: use cm or mm as absolute dimensions!
        width = float(width.replace("px", "").replace("pt", "").replace("mm", ""))
        height = float(height.replace("px", "").replace("pt", "").replace("mm", ""))

        smoothness = self.settings.lc_svg_smoothness
        if smoothness < 0.1: smoothness = 0.1

        # get the minimum x and y values to get an offset to 0,0
        ofs_x = 99999999999.0
        ofs_y = 99999999999.0
        max_x = -99999999999.0
        max_y = -99999999999.0
        for elem in root.iter():
            try:
                _, tag_suffix = elem.tag.split('}')
            except ValueError:
                continue
            if tag_suffix in svg_shapes:
                shape_class = getattr(shapes_pkg, tag_suffix)
                shape_obj = shape_class(elem)
                d = shape_obj.d_path()
                m = shape_obj.transformation_matrix()

                if d:
                    p = point_generator(d, m, smoothness)
                    start = True
                    for x,y,pen in p:
                        if x < ofs_x:
                            ofs_x = x
                        if y < ofs_y:
                            ofs_y = y
                        if x > max_x:
                            max_x = x
                        if y > max_y:
                            max_y = y
        
        if self.settings.lc_svg_offset:
            ofs_x *= -1
            ofs_y *= -1
            max_x += ofs_x
            max_y += ofs_y
            self.log("Calculated Offset to 0,0 is %f,%f" % (ofs_x, ofs_y))
        else:
            ofs_x = 0
            ofs_y = 0

        """
        self.log("Calculated Dimension is %f,%f" % (max_x, max_y))
        width = max_x
        height = max_y
        """

        if self.settings.lc_svg_scalemode == "original":
            scale_x = 1.0
            scale_y = 1.0
        elif self.settings.lc_svg_scalemode == "scale":
            scale_x = bed_max_x / width
            scale_y = bed_max_y / height
            if (scale_x * height) > bed_max_y:
                # use y scale
                scale_x = scale_y
            elif (scale_y * width) > bed_max_x:
                # use x scale
                scale_y = scale_x
            # double-check
            if (scale_x * width > bed_max_x) or (scale_y * height > bed_max_y):
                scale_x = scale_y = min(bed_max_x, bed_max_y) / max(width, height)
        else:
            scale_x = bed_max_x / width
            scale_y = bed_max_y / height
        
        self.log("Scaling factor: %.2f, %.2f" % (scale_x,scale_y))

        fo = open(filename + ".g", "w")
        if self.settings.lc_melzi_hack:
            fo.write("; Filename: %s\n%s" % (filename, GCODE_HEAD_MELZI))
            fo.write("M571 S0 E1 ; On SVG we control the laser by ourself\n")
        else:
            fo.write("; Filename: %s\n%s" % (filename, GCODE_HEAD))

        travel_speed = self.settings.lc_travel_speed * 60
        engrave_speed = self.settings.lc_engrave_speed * 60 * self.settings.lc_svg_speed_factor

        errors = 0
        elemidx = 0

        for elem in root.iter():
            elemidx += 1
            try:
                _, tag_suffix = elem.tag.split('}')
            except ValueError:
                continue

            if tag_suffix in svg_shapes:
                try:
                    styles = elem.attrib['style'].split(';')
                except KeyError:
                    styles = []
                skip = False
                for style in styles:
                    style = style.split(':')
                    if style[0] == 'stroke':
                        # ignore all stroke colors which are not #000000
                        if style[1] != "#000000":
                            self.log("Ignoring shape %s (%i) by stroke color" % (tag_suffix, elemidx))
                            skip = True
                            break
                # TODO: do a config option to enter the CUT color or something
                if skip:
                    continue

                self.log("Parsing shape: %s (%i)" % (tag_suffix, elemidx))
                shape_class = getattr(shapes_pkg, tag_suffix)
                shape_obj = shape_class(elem)
                d = shape_obj.d_path()
                mat = shape_obj.transformation_matrix()

                if d:
                    if self.settings.lc_melzi_hack:
                        fo.write("M400 ; start %s\n" % (tag_suffix))
                        fo.write("G92 E0\n") 
                    E = 0
                    xo = 0
                    yo = 0
                    idxo = None
                    #p = point_generator(d, mat, smoothness)

                    simple_path = simplepath.parsePath(d)
                    if len(simple_path) == 0:
                            self.log("Path length zero!")
                            continue
                   
                    p = cubicsuperpath.parsePath(d)
                    
                    if mat:
                        simpletransform.applyTransformToPath(mat, p)

                    for sp in p:
                            cspsubdiv.subdiv( sp, smoothness)
                            #self.log("Laser ON at: " + repr(sp[0][0]))
                            x = sp[0][0][0] + ofs_x
                            y = sp[0][0][1] + ofs_y
                            y = height - y # invert the bed
                            xs = scale_x * x
                            ys = scale_y * y
                            if self.settings.lc_melzi_hack:
                                fo.write("M400 ; Wait for all moves to finish\n")
                                fo.write("M42 P28 S0 ; Turn off laser\n")
                                fo.write("G0 X%0.4f Y%0.4f F%.4f ; Move to start of shape\n" % (
                                    xs, ys, travel_speed))
                                fo.write("M400 ; Wait for all moves to finish\n")
                                fo.write("M42 P28 S255 ; Turn on laser\n")
                            else:
                                fo.write("M5 ; Turn off laser\n")
                                fo.write("G0 X%0.4f Y%0.4f F%.4f ; Move to start of shape\n" % (
                                    xs, ys, travel_speed))
                                # todo: laser power as parameter?
                                fo.write("M3 S100 ; Turn on laser\n")

                            xo = xs
                            yo = ys 
                            object_xs = xs
                            object_ys = ys

                            for csp in sp:
                                ctrl_pt1 = csp[0]
                                ctrl_pt2 = csp[1]
                                end_pt = csp[2]

                                x = end_pt[0] + ofs_x
                                y = end_pt[1] + ofs_y

                                y = height - y # invert the bed
                                xs = round(scale_x * x, 4)
                                ys = round(scale_y * y, 4)
                                if xo == xs and yo == ys: continue

                                #self.log(" Point " + repr(end_pt))
                                e_distance = math.hypot(xs - xo, ys - yo)
                                xo = xs
                                yo = ys                                
                                E = E + (e_distance)

                                if xs >= 0 and xs <= bed_max_x+0.1 and ys >= 0 and ys <= bed_max_y+0.1:
                                    if self.settings.lc_melzi_hack:
                                        fo.write("G1 X%0.4f Y%0.4f E%.4f F%.4f\n" % (
                                            xs, ys, E * E_FACTOR, engrave_speed))
                                    else:
                                        fo.write("G1 X%0.4f Y%0.4f F%.4f\n" % (
                                            xs, ys, engrave_speed))
                                else:
                                    if self.settings.lc_melzi_hack:
                                        fo.write("G0 X%0.4f Y%0.4f F%.4f\n" % (
                                            xs, ys, travel_speed))
                                    else:
                                        fo.write("M5 ; Turn off laser\n")
                                        fo.write("G0 X%0.4f Y%0.4f F%.4f\n" % (
                                            xs, ys, travel_speed))
                                        # todo: laser power as parameter?
                                        fo.write("M3 S100 ; Turn on laser\n")
                                    errors += 1
                                    if errors < 10:
                                        self.log("Position outside print dimension: %d, %d" % (xs, ys)) 

                                #print "   Point: ", end_pt[0], end_pt[1], pen
                            #self.log("Laser OFF at: " + repr(sp[-1][-1]))

                    #if shape_obj.xml_node.get('fill'):
                    if tag_suffix == "polygon":
                        # Close the polygon
                        if self.settings.lc_melzi_hack:
                            e_distance = math.hypot(object_xs - xo, object_ys - yo)
                            E = E + (e_distance)
                            fo.write("G1 X%0.4f Y%0.4f E%.4f F%.4f ; Close the object polygon\n" % (
                                object_xs, object_ys, E * E_FACTOR, engrave_speed))
                        else:
                            fo.write("G1 X%0.4f Y%0.4f F%.4f ; Close the object polygon\n" % (
                                object_xs, object_ys, engrave_speed))
                        print "connecting filled polygon path end to start"

        if self.settings.lc_melzi_hack:
            fo.write(GCODE_FOOT_MELZI)
        else:
            fo.write(GCODE_FOOT)
        fo.close()

        if errors > 0:
            self.log("%i errors while generating gcode" % errors)
                     
        if self.pronterwindow:
            self.log("")        
            self.pronterwindow.load_gcode_async(filename + '.g')
                        

mercurial