Fork this with Git

Reviving an old Sparkmaker SLA printer

Fixing the hardware and creating a free-software slicing workflow
Project started on January 03, 2023.

...back to 3D Printing overview

Many years ago someone donated his Sparkmaker SLA printer to our local makerspace, the Toolbox Bodensee. Nobody has touched it for a long time, mostly because we were not keen to work with the poisonous and stinky UV resin required for the printer. But recently my friend Philipp and I took the plunge. Here I describe what we had to do to get it working.

Sparkmaker SLA printer

Table Of Contents

Endstop Fix

The printer only has a single axis, Z, with an optical endstop switch at the bottom. This switch is triggered by a large plastic shim screwed to the Z carriage. This shim was not centerd properly on this machine. So when trying to home, the shim collided with the housing of the switch, blocking the axis and threatening to rip off the switch. To fix this, just loosen the two screws in the shim and adjust the position. Both the shim and the print bed have some range of adjustment in the Z axis, so take care to adjust the shim in a way to still allow the bed to be properly leveled.

Endstop trigger shim

Encoder Replacement

After only a couple of hours of use, the encoder at the front of the machine stopped working properly. You can see this in the video below.

It stopped moving up, mostly only moving down, regardless of the direction it was turned. This meant the bed could no longer be lifted to easily remove it. I decided to replace it with an ALPS encoder (STEC12E) I still had lying around from repairing my oscilloscope a while back.

Removing resin basin
Mainboard with LCD connector
Motor connector
UV LED board

This requires removing the base of the housing by unscrewing the outer four hex screws from the top, as well as the three hex screws on the bottom below the Z axis arm. Now we are allowed access to the encoder board.

Encoder board
Encoder board backside
New encoder next to board
Fixed encoder board

The new encoder apparently does not have the same number of pulses per indent, so adjusting it still feels slightly weird. But it can now be turned normally in both directions, so it is usable again.

Closer view of MCU on mainboard
Replacing the screws required a tool to push the nuts in
Showing some example image on the LCD

With the hardware back in working order I could now focus on the PC software side of things.

First Slicing Experiments

After getting the hardware back running the next step is generating some sliced files. Unfortunately the official Sparkmaker website no longer exists and the downloads from there are hard to find. They included a slicing software of course, but it seems to only run on Windows and Macs and it not open source. So I had to find some alternative. Most suggest using ChituBox or Lychee Slicer, both can be used for free and seem to include presets for the Sparkmaker.

Cleaning the LCD screen
The Anycubic resin we have been using

Philipp installed ChituBox on his machine and tried it out. It includes presets for the Sparkmaker that seem to work more or less. And the software also allows comfortable placement of support structures. But it didn't work perfectly all the time, we got at least one corrupted file where each slice contained the same garbage picture.

Front view of first print
Side view of first print
Bottom view of first print

Even though one corner was warping strongly, the results of the first print attempt were promising. It is an SA-profile keycap, sliced with ChituBox with 0.1mm layer height.

SL1 to WOW File Format Converter Script

But there is one open-source alternative, PrusaSlicer, and it has support for SLA slicing for the Prusa SL1 printer. But it only produces .sl1 files, their custom file format. The Sparkmaker, on the other hand, expects .wow files. Fortunately someone already documented the file format well. And there's also a small GUI utility to visualize and modify pre-sliced .wow files. The Prusa file format was easy to "reverse-engineer", it's just a zip archive containing each slice as a .png file, and some settings in .ini files.

The route to take was therefore obvious: write a script that takes .sl1 files and converts them to .wow.

#!/usr/bin/env python

# convert_sparkmaker.py
# Copyright 2023 Thomas Buck 
# Convert PrusaSlicer .sl1 files to Sparkmaker .wow format
# Lift settings can only be customized below, not in the slicer.
# Should also work for Sparkmaker FHD, adjust parameters below.

import sys
import os
from zipfile import ZipFile
import re
import pprint
import png

###############################################################################

screen_size = (854, 480)
max_height = 120
initial_params = {
    'layer_height': 0.1,
    'first_layer_height': 0.1,
    'exposure_time': 15.0,
    'first_exposure_time': 120.0,
    'lift_height': 5.0,
    'lift_speed': 30.0,
    'sink_speed': 100.0
}
final_move_speed = 100.0

###############################################################################

def read_prusaslicer_ini(f, params):
    lines = f.decode("utf-8").split("\n")

    for l in lines:
        if l.startswith("exposure_time"):
            params["exposure_time"] = float(l.split(" = ")[1])
        elif l.startswith("initial_exposure_time"):
            params["first_exposure_time"] = float(l.split(" = ")[1])
        elif l.startswith("layer_height"):
            params["layer_height"] = float(l.split(" = ")[1])
        elif l.startswith("initial_layer_height"):
            params["first_layer_height"] = float(l.split(" = ")[1])

def read_config_ini(f, params):
    lines = f.decode("utf-8").split("\n")

    for l in lines:
        if l.startswith("expTimeFirst"):
            params["first_exposure_time"] = float(l.split(" = ")[1])
        elif l.startswith("expTime"):
            params["exposure_time"] = float(l.split(" = ")[1])
        elif l.startswith("layerHeight"):
            params["layer_height"] = float(l.split(" = ")[1])

def read_sl1(f):
    params = initial_params

    files = f.namelist()
    if "prusaslicer.ini" in files:
        read_prusaslicer_ini(f.read("prusaslicer.ini"), params)
    else:
        print("No prusaslicer.ini found in file")
        if "config.ini" in files:
            read_config_ini(f.read("config.ini"), params)
        else:
            print("No config.ini found in file")
            sys.exit(1)

    imgs = [x for x in files if x.endswith(".png")]
    imgs = [x for x in imgs if not x.startswith("thumbnail/")]

    if len(imgs) <= 0:
        print("No slices found in file. Aborting.")
        sys.exit(1)

    res = re.findall('(\D*)\d*.*', imgs[0])[0]
    print("Internal name: \"" + res + "\"")
    print("Found " + str(len(imgs)) + " slices")

    images = []
    for img in imgs:
        images.append(f.read(img))

    return params, images

###############################################################################

def write_image(f, img):
    p = png.Reader(bytes = img)
    width, height, rows, info = p.read()

    if width != screen_size[0]:
        print("Error: layer has wrong width " + str(width) + " != " + str(screen_size[0]))
        sys.exit(1)

    if height != screen_size[1]:
        print("Error: layer has wrong height " + str(height) + " != " + str(screen_size[1]))
        sys.exit(1)

    if (not info['greyscale']) or info['alpha']:
        print("Error: invalid image encoding")
        sys.exit(1)

    data = [0] * int(width * height / 8)

    y = 0 # width is 854
    x = 0 # height is 480
    for row in rows:
        for v in row:
            if v > 0x7F:
                data[int(height / 8) * y + int(x / 8)] |= (1 << x % 8)
            y += 1
        x += 1
        y = 0

    for d in data:
        f.write(d.to_bytes(1, 'big'))

def write_wow(f, params, imgs):
    def write(s):
        #print(s)
        f.write((s + "\n").encode())

    write("G21;")
    write("G91;")
    write("M17;")
    write("M106 S0;")
    write("G28 Z0;")

    first_layer = True

    for i in range(0, len(imgs)):
        write(";L:" + str(int(i)) + ";")
        write("M106 S0;")

        lift_up = params['lift_height']
        lift_down = -(lift_up - params['layer_height'])
        exposure_time = params['exposure_time']
        if first_layer:
            lift_down = -(lift_up - params['first_layer_height'])
            exposure_time = params['first_exposure_time']
            first_layer = False

        write("G1 Z" + str(lift_up) + " F" + str(params['lift_speed']) + ";")
        write("G1 Z" + str(lift_down) + " F" + str(params['sink_speed']) + ";")
        write("{{")
        write_image(f, imgs[i])
        write("}}")
        write("M106 S255;")
        write("G4 S" + str(exposure_time) + ";")

    write("M106 S0;")

    object_height = params['layer_height'] * (len(imgs) - 1) + params['first_layer_height']
    final_move = max_height - object_height
    if final_move >= 1.0:
        write("G1 Z1 F" + str(params['lift_speed']) + ";")
        write("G1 Z" + str(final_move - 1.0) + " F" + str(final_move_speed) + ";")

    write("M18;")

###############################################################################

def main():
    if len(sys.argv) < 2:
        print("Usage:")
        print("    " + sys.argv[0] + " input.sl1 [output.wow]")
        sys.exit(1)

    in_file_name = sys.argv[1]

    out_file_name = "print.wow"
    if len(sys.argv) >= 3:
        out_file_name = sys.argv[2]

    if os.path.exists(out_file_name):
        print("File already exists: \"" + out_file_name + "\". Aborting.")
        sys.exit(1)

    with ZipFile(in_file_name, 'r') as sl1:
        params, imgs = read_sl1(sl1)

    print("Using following parameters:")
    pprint.pprint(params)

    print("Height: {:.3f}".format(params['first_layer_height'] + (params['layer_height'] * (len(imgs) - 1))) + "mm")

    t = params['first_exposure_time']
    t += (len(imgs) - 1) * params['exposure_time']
    t += params['lift_height'] / params['lift_speed'] * 60 * len(imgs)
    t += (params['lift_height'] - params['layer_height']) / params['sink_speed'] * 60 * len(imgs)
    h = t / 3600
    m = (t / 60) % 60
    s = t % 60
    print("Estimated print time: " + str(int(h)) + "h " + str(int(m)) + "m " + str(int(s)) + "s")

    print("Writing output to \"" + out_file_name + "\"")

    with open(out_file_name, 'wb') as wow:
        write_wow(wow, params, imgs)

if __name__ == '__main__':
    main()

You can find it on my Gitea server.

This is what running the script looks like.

$ convert_sparkmaker MGMKII_PrintOnePiece.sl1 mgmk2.wow
Internal name: "MGMKII_PrintOnePiece"
Found 414 slices
Using following parameters:
{'exposure_time': 15.0,
 'first_exposure_time': 120.0,
 'first_layer_height': 0.1,
 'layer_height': 0.1,
 'lift_height': 5.0,
 'lift_speed': 30.0,
 'sink_speed': 100.0}
Height: 41.400mm
Estimated print time: 3h 14m 32s
Writing output to "mgmk2.wow"

I only noticed after writing this script that someone already did the same thing two years ago.

First print sliced with PrusaSlicer
Post-Curing a printed object

Thanks to the documentation linked above, writing a .wow file was very easy. I'm taking all the settings I can from the included .ini files, which are layer height, initial layer height, exposure time and initial exposure time. The settings referring to the lift of the object after each slice are not present in the file, so my script has useful defaults hard-coded.

The script should also be usable for the Sparkmaker FHD, as it apparently uses the same file format. Of course the resolution and size need to be adjusted accordingly, both in my script and in the slicer.

I noticed some interesting quirks of the printer firmware. It does not allow the usual style of inline comments explaining what a line does. When I tried adding comments to the commands in the G-Code header, the corresponding commands simply were not executed at all, which is obviously very problematic.

Also the firmware does not hestitate to display both success messages at the end of a print, as well as error messages, on the LCD screen itself! When the LEDs are on this of course hardens the message into the resin. So G-Code always needs to take care to turn the LEDs off at the end of a print. For some reason we once managed to have the LEDs turned on with an error message showing. This requires draining the resin and scraping off any remaining bits.

Configuring PrusaSlicer for the Sparkmaker

To configure PrusaSlicer I recommend starting out with their built-in profile for the SL1. Selecting it as the machine automatically switches the program over into "SLA mode", where the support and pad generation work differently compared to the normal FDM mode. You also need to enable expert mode to see all required settings.

Printer Settings page in PrusaSlicer
Material Settings page in PrusaSlicer
Print Settings page in PrusaSlicer

The most important changes need to happen in the Printer Settings. There you need to adjust the display size to 854x480, the display size to 98.57x54.99mm, maximum height to 120mm, the orientation to landscape and disable all mirroring.

In the Material Settings I recommend setting the exposure time to 15s and the initial exposure time to 120s. But this may change depending on the resin you plan to use.

I'm not 100% sure about the display size, and of course this parameter is very important to ensure the printed objects have the correct size. I initially found this unfinished project where the display diagonal is given as 4.6 inch. With this, and the display size in pixels, I calculated the theoretical display size.

screen width = 854 * 4.6 / sqrt(854^2 + 480^2) = 4.0099996503 inch = 101.854mm
screen height = 480 * 4.6 / sqrt(854^2 + 480^2) = 2.25386397207 inch = 57.248mm

This is close, but the resulting objects were not quite perfect. So I printed a calibration cube at 5mm width, and one at 10mm width, to be able to calibrate the values. This gave similar correction factors in both axes.

101.854mm * 0.98 = 99.81692mm
57.248mm * 0.98 = 56.10304mm

With these I printed another calibration cube, this time with 20mm, to be able to measure it with more accuracy. Now I got these correction factors, as the cube was still a little bit too small.

99.8169mm * 0.9875 = 98.56918875mm
56.103mm * 0.99 = 55.54197mm

Now the X axis measured spot-on at 20mm, but the Y axis still only gave 19.8mm. So I corrected it again.

55.54197mm * 0.99 = 54.9865503mm

To be honest, I'm not sure why this calibration took so many attempts, or what I'm doing wrong. I'm also sure these numbers are still not totally exact, but they seem to be close enough for my purposes.

For the Z axis I arrived at a correction factor of 0.995, which can be set in PrusaSlicer as "Printer scaling correction Z".

You can download my PrusaSlicer configuration for the Sparkmaker or even for all of my printers, if you're so inclined.

As a test I printed this model, scaled down to 50% with a layer height of 0.1mm. This took about 3½ hours. For an early test I'm really happy with the results. Even scaled down the very fine connections between the body and head are strong enough to hold them together.

Calibration cubes in 5mm, 10mm and 20mm
Small Metal Gear Mk. II figurine

More Pictures

Some more photographs I didn't use above.
Badly lit look at endstop trigger
Badly lit look into printer body
Badly lit look at UV LEDs
Badly lit look at mainboard