In 2021 I've started working on a concept: A small Arduino abser MSLA printer and you have that post here. It was small, very small, but it worked. Last year I've made a first prototype of a bigger version. To increase the speed of the new 4 inch TFT display, I'm now using an ESP32. And in that last prototype I've made some errors which I will fix today so let's check the new update.
by: ELECTRONOOBS on 2026-06-23
Btw, now all hardawre is working!!! So in the next few days I will create a new PCB where everythign is working and not just that, I want to place everything on the same PCB indtead of 4 separate PCBs. So let's get started.
Let's see what parts I've used first for the prototype and once the new PCB is ready I will share a different part list. The new PCB might use a SMD ESP32 version. Then we can see the scheamtic, the PCB and finally the code. Check below all parts listed in the update video and some components might be different if you want. Together with the part list you will need obviouslly some screws and nuts, female and male pins, screw connectors, wires, 1/4W resistors, etc. For any value, check the schematic.

I had to change the schematic from the previous PCB. I had o use different pins that could be output as well, I have to use a buck converter for 5V and a few more changes for the push buttons, stepper driver, a new buzzer and so on...

Download my PCBs from below. You have 4 PCBs, the main one, the TFT PCB, the OLED and buttons PCB and the LED matrix PCB. In the video I've made the main controller PCB on prototyping PCBs but now I've fixed it so just download it and solder all the parts.

Download my 3D files from below. You have the main case and the Z axis support. More to come soon!

Download the V3 firmware from below. Is not finished yet but it works for now...
#include <SPI.h>
#include <Wire.h>
#include <Adafruit_GFX.h>
#include <Adafruit_SSD1306.h>
#include <AccelStepper.h>
#include <FS.h>
#include <SD.h>
#include <TFT_eSPI.h>
TFT_eSPI tft = TFT_eSPI();
// JPEG decoder library
#include <JPEGDecoder.h>
File root;
#define X_MAX_PIXEL 320
#define Y_MAX_PIXEL 480
#define OLED_RESET -1 // Reset pin # (or -1 if sharing Arduino reset pin)
#define SCREEN_ADDRESS 0x3C ///< See datasheet for Address; 0x3D for 128x64, 0x3C for 128x32
Adafruit_SSD1306 display(128, 64, &Wire, OLED_RESET);
//Input/Outputs
//#define TFT_DC 2 // TFT Data Command control pin
//#define TFT_RST 4 // TFT Reset pin (could connect to RST pin)
//#define SD_CS 5 // SD card module CS
#define NONE_1 12 //
#define endstop 13 // End stop for homming
#define NONE_2 14 //
//#define TFT_CS 15 // TFT Chip select control pin
#define NONE_3 16 // RX2
#define NONE_4 17 // TX2
//#define TFT_SCLK 18 // TFT + SD card module SPI clock
//#define TFT_MISO 19 // TFT + SD card module SPI MISO
//#define SDA 21 // SDA pin for i2c por for OLED screen
//#define SCL 22 // SCL pin for i2c por for OLED screen
//#define TFT_MOSI 23 // TFT + SD card module SPI MOSI
#define dirPin 25 // Direction pin for stepper driver
#define UV_LED 26 // Pinn connected to the MOSFET of the UV LEDs (activated with HIGH)
#define buzzer 27 // Buzzer for sound notificatios
#define EN_PIN 32 // Enable pin for stepper driver
#define stepPin 33 // Step pin for stepper driver
#define Button_Top 34 // Input only, from top button
#define Button_Bottom 35 // Input only, from bottom button
#define Button_Right 36 // Input only, from right button
#define Button_Left 39 // Input only, from left button
//Variables
bool Button_Right_state = true; //Right
bool Button_Left_state = true; //Left
bool Button_Top_state = true; //Top
bool Button_Bottom_state = true; //Bottom
bool first_file = true;
int layer_count = 1;
int total_layer_count = 0;
int menu_sellection = 0;
int menu_level = 0;
int list_folder_count = 0;
int total_folder_count = 0;
bool print_yes = true;
bool printing = false;
int homming_feedrate = 2000;
int printing_feedrate = 3000;
bool DOWN = LOW;
bool UP = HIGH;
float actual_height = 0;
int actual_layer = 0;
String print_dir = "";
int uv_time = 3000;
bool hommed = false;
float steps_per_mm = 180;
float layer_height = 0.05;
float rise_height = 5.0;
float max_height = 150;
AccelStepper stepper(1, stepPin, dirPin);
void setup()
{
pinMode(EN_PIN, OUTPUT);
digitalWrite(EN_PIN, LOW);
pinMode(UV_LED, OUTPUT);
digitalWrite(UV_LED, LOW);
pinMode(buzzer, OUTPUT);
digitalWrite(buzzer, LOW);
pinMode(stepPin, OUTPUT);
pinMode(dirPin, OUTPUT);
pinMode(Button_Right, INPUT);
pinMode(Button_Left, INPUT);
pinMode(Button_Top, INPUT);
pinMode(Button_Bottom, INPUT);
pinMode(endstop, INPUT);
stepper.setMaxSpeed(150);
stepper.setAcceleration(100);
if(!display.begin(SSD1306_SWITCHCAPVCC, SCREEN_ADDRESS)) {
Serial.println(F("SSD1306 allocation failed"));
for(;;); // Don't proceed, loop forever
}
Serial.begin(115200);
if (!SD.begin()) {
Serial.println("Card Mount Failed");
return;
}
uint8_t cardType = SD.cardType();
if (cardType == CARD_NONE) {
Serial.println("No SD card attached");
return;
}
Serial.print("SD Card Type: ");
if (cardType == CARD_MMC) {
Serial.println("MMC");
} else if (cardType == CARD_SD) {
Serial.println("SDSC");
} else if (cardType == CARD_SDHC) {
Serial.println("SDHC");
} else {
Serial.println("UNKNOWN");
}
uint64_t cardSize = SD.cardSize() / (1024 * 1024);
Serial.printf("SD Card Size: %lluMB\n", cardSize);
Serial.println("initialisation done.");
root = SD.open("/");
delay(100);
tft.begin();
delay(100);
tft.setRotation(2); // portrait
delay(100);
tft.fillScreen(0x0000);
delay(100);
display.clearDisplay();
display.setTextSize(2);
display.setCursor(0,0);
display.setTextColor(BLACK,WHITE);
display.println("Print");
display.setTextColor(WHITE);
display.println("Settings");
display.println("Move");
display.display();
delay(100);
}
void loop()
{
if(!digitalRead(Button_Right) && Button_Right_state){
Button_Right_state = false;
if(menu_level == 0){
if(menu_sellection == 0){
menu_level = 1;
display.clearDisplay();
display.setTextSize(1);
display.setCursor(30,22);
display.setTextColor(WHITE);
display.println("Loading...");
display.display();
get_total_folder_count();
show_file_list(menu_sellection);
}
}//end of menu_level == 0
else if(menu_level == 1){
menu_level = 99;
display.clearDisplay();
display.setTextSize(1);
display.setCursor(46,15);
display.setTextColor(WHITE);
display.println("Print?");
display.print(" ");
display.setTextColor(BLACK, WHITE);
display.print("Yes");
display.setTextColor(WHITE);
display.print(" No");
display.display();
}//end of menu_level == 1
else if(menu_level == 99){
if(print_yes){
menu_level = 100;
printing = true;
layer_count = 1;
display.clearDisplay();
display.setTextSize(1);
display.setCursor(35,22);
display.setTextColor(WHITE);
display.println("homming...");
display.display();
}
else{
menu_level = 1;
print_yes = true;
display.clearDisplay();
display.setTextSize(1);
display.setCursor(30,22);
display.setTextColor(WHITE);
display.println("Loading...");
display.display();
get_total_folder_count();
show_file_list(menu_sellection);
}
}//end of menu_level == 1
}
else if(digitalRead(Button_Right) && !Button_Right_state){
Button_Right_state = true;
}
if(!digitalRead(Button_Left) && Button_Left_state){
if(!printing){
Button_Left_state = false;
menu_sellection = 0;
menu_level = 0;
print_yes = true;
display.clearDisplay();
display.setTextSize(2);
display.setCursor(0,0);
display.setTextColor(BLACK,WHITE);
display.println("Print");
display.setTextColor(WHITE);
display.println("Settings");
display.println("Move");
display.display();
}
else{
display.clearDisplay();
display.setTextSize(1);
display.setCursor(46,15);
display.setTextColor(WHITE);
display.println("");
display.print(" ");
display.setTextColor(BLACK, WHITE);
display.print("STOP");
display.setTextColor(WHITE);
display.print(" PAUSE");
display.display();
}
}
else if(digitalRead(Button_Left) && !Button_Left_state){
Button_Left_state = true;
}
if(!digitalRead(Button_Top) && Button_Top_state){
Button_Top_state = false;
}
else if(digitalRead(Button_Top) && !Button_Top_state){
Button_Top_state = true;
}
if(!digitalRead(Button_Bottom) && Button_Bottom_state){
Button_Bottom_state = false;
if(menu_level == 0){
menu_sellection++;
if(menu_sellection>2){
menu_sellection = 0;
}
if(menu_sellection == 0){
display.clearDisplay();
display.setTextSize(2);
display.setCursor(0,0);
display.setTextColor(BLACK,WHITE);
display.println("Print");
display.setTextColor(WHITE);
display.println("Settings");
display.println("Move");
display.display();
}
else if(menu_sellection == 1){
display.clearDisplay();
display.setTextSize(2);
display.setCursor(0,0);
display.setTextColor(WHITE);
display.println("Print");
display.setTextColor(BLACK,WHITE);
display.println("Settings");
display.setTextColor(WHITE);
display.println("Move");
display.display();
}
else if(menu_sellection == 2){
display.clearDisplay();
display.setTextSize(2);
display.setCursor(0,0);
display.setTextColor(WHITE);
display.println("Print");
display.println("Settings");
display.setTextColor(BLACK,WHITE);
display.println("Move");
display.display();
}
}//end menu_level = 0
if(menu_level == 1){
menu_sellection++;
if(menu_sellection >= total_folder_count){
menu_sellection = 0;
}
show_file_list(menu_sellection);
}//End of menu_leve == 1
if(menu_level == 99){
if(print_yes == false){
print_yes = true;
display.clearDisplay();
display.setTextSize(1);
display.setCursor(46,15);
display.setTextColor(WHITE);
display.println("Print?");
display.print(" ");
display.setTextColor(BLACK, WHITE);
display.print("Yes");
display.setTextColor(WHITE);
display.print(" No");
display.display();
}
else if(print_yes == true){
print_yes = false;
display.clearDisplay();
display.setTextSize(1);
display.setCursor(46,15);
display.setTextColor(WHITE);
display.println("Print?");
display.print(" ");
display.print("Yes ");
display.setTextColor(BLACK, WHITE);
display.print("No");
display.display();
}
}//End of menu_leve == 99
}
else if(digitalRead(Button_Bottom) && !Button_Bottom_state){
Button_Bottom_state = true;
}
/////////////////////////////////////////////////////////////////////////////////////////
/////////////////////////////////////////PRINTING////////////////////////////////////////
/////////////////////////////////////////////////////////////////////////////////////////
if(printing){
digitalWrite(UV_LED, LOW); //First of all make sure the UV light is OFF
String print_path = "/"+print_dir; //Save the dir of the selected folder
if(!hommed){ //Start by homing the machine
total_layer_count = get_total_files(print_path); //Also get once the total amount of pictures to print
homming(); //Call the homming function
while(!hommed){
}
//Machine is hommed
display.clearDisplay();
display.setTextSize(2);
display.setCursor(30,15);
display.setTextColor(BLACK, WHITE);
display.println(" HOME ");
display.display();
delay(2000);
}
File root = SD.open(print_path); //Define the root to the new selected folder
File entry;
String layer_to_print = print_dir + "/" + String(layer_count)+".jpg"; //Create the name of the new layer to be printed
uint8_t nameSize = String(layer_to_print).length(); //get file name size
String str1 = String(layer_to_print).substring(nameSize - 4); //Save last 4 characters (file extension)
Serial.print("Printing "); Serial.println(layer_to_print); //Print to serial...
//Create the buf to be sent to the drawSdJpeg decoder
char buf[nameSize+1];
buf[0] = '/';
for (int i=0; i<=nameSize; i++){
buf[i+1] = layer_to_print[i];
}
const char *toprint = buf;
drawSdJpeg(toprint, 0, 0); // This draws a jpeg pulled off the SD Card
entry.close(); //Close the entry
delay(1000); //Wait a moment and then turn on the UV LED
digitalWrite(UV_LED, HIGH);
delay(uv_time);
digitalWrite(UV_LED, LOW);
tft.fillScreen(0x0000);
delay(10);
if(layer_count > total_layer_count){
display.clearDisplay();
display.setTextSize(2);
display.setCursor(13,15);
display.setTextColor(BLACK, WHITE);
display.println("FINISHED");
display.display();
tone(buzzer, 2000, 200);
delay(5000);
printing = 0;
hommed = false;
Button_Left_state = false;
menu_sellection = 0;
menu_level = 0;
print_yes = true;
display.clearDisplay();
display.setTextSize(2);
display.setCursor(0,0);
display.setTextColor(BLACK,WHITE);
display.println("Print");
display.setTextColor(WHITE);
display.println("Settings");
display.println("Move");
display.display();
tone(buzzer, 2000, 100);
delay(10);
lift_finished_print();
tone(buzzer, 2000, 1000);
delay(10);
}
else{
display.clearDisplay();
display.setTextSize(1);
display.setCursor(30,0); //x,y
display.setTextColor(BLACK,WHITE);
display.println(" Printing ");
display.setTextColor(WHITE);
display.setCursor(0,22);
display.print(" ");
display.print(layer_to_print);
int percentage = (float (layer_count)/float (total_layer_count))*100.0;
display.setTextSize(2);
display.setCursor(0,32);
display.print(" ");
display.print(percentage);
display.println("%");
display.setTextSize(1);
display.println(" ");
display.setTextColor(BLACK,WHITE);
display.println("< STOP? ");
display.display();
}
next_layer();
}
}//End of void loop
void lift_finished_print(){
while(actual_height < max_height){
digitalWrite(EN_PIN, LOW);
digitalWrite(dirPin, UP);
digitalWrite(stepPin, HIGH);
delayMicroseconds(1);
digitalWrite(stepPin, LOW);
delayMicroseconds(printing_feedrate);
actual_height = actual_height + (2.0/steps_per_mm);
}
}
void next_layer(){
//Lift up
float steps_to_make = steps_per_mm * rise_height;
int made_steps = 0;
while(made_steps < steps_to_make){
digitalWrite(EN_PIN, LOW);
digitalWrite(dirPin, UP);
digitalWrite(stepPin, HIGH);
delayMicroseconds(1);
digitalWrite(stepPin, LOW);
if(made_steps<180){
delayMicroseconds(printing_feedrate*3);
}
else{
delayMicroseconds(printing_feedrate);
}
made_steps++;
}
delay(300);
//get back down
steps_to_make = steps_to_make - (steps_per_mm * layer_height);
made_steps = 0;
while(made_steps < steps_to_make){
digitalWrite(EN_PIN, LOW);
digitalWrite(dirPin, DOWN);
digitalWrite(stepPin, HIGH);
delayMicroseconds(1);
digitalWrite(stepPin, LOW);
if(made_steps < (steps_to_make - 180)){
delayMicroseconds(printing_feedrate);
}
else{
delayMicroseconds(printing_feedrate*3);
}
made_steps++;
}
actual_height = actual_height + layer_height;
Serial.print("height: ");
Serial.println(actual_height);
layer_count++;
}
int get_total_files(String path){
Serial.print("selected folder: ");
Serial.print(path);
File root = SD.open(path);
if(!root){
Serial.println("Failed to open directory");
return 0;
}
if(!root.isDirectory()){
Serial.println("Not a directory");
return 0;
}
File file = root.openNextFile();
int files_count = 0;
while(file){
if(file.isDirectory()){
}
else{
Serial.print(file.path());
Serial.print(": ");
Serial.println(file.name());
files_count++;
}
file = root.openNextFile();
}
return files_count;
}
void homming(){
while(digitalRead(endstop)){
digitalWrite(EN_PIN, LOW);
digitalWrite(dirPin, DOWN);
digitalWrite(stepPin, HIGH);
delayMicroseconds(1);
digitalWrite(stepPin, LOW);
delayMicroseconds(homming_feedrate);
}
int made_steps=0;
while(made_steps < 180){
digitalWrite(EN_PIN, LOW);
digitalWrite(dirPin, UP);
digitalWrite(stepPin, HIGH);
delayMicroseconds(1);
digitalWrite(stepPin, LOW);
delayMicroseconds(homming_feedrate*4);
made_steps++;
}
while(digitalRead(endstop)){
digitalWrite(EN_PIN, LOW);
digitalWrite(dirPin, DOWN);
digitalWrite(stepPin, HIGH);
delayMicroseconds(1);
digitalWrite(stepPin, LOW);
delayMicroseconds(homming_feedrate*4);
}
actual_height = 0;
actual_layer = 0;
hommed = true;
}
void get_total_folder_count(){
File root = SD.open("/");
if(!root){
Serial.println("Failed to open directory");
return;
}
if(!root.isDirectory()){
Serial.println("Not a directory");
return;
}
File file = root.openNextFile();
list_folder_count = 0;
while(file){
if(file.isDirectory()){
list_folder_count++;
}
file = root.openNextFile();
}
total_folder_count = list_folder_count;
}
void show_file_list(int selected){
File root = SD.open("/");
if(!root){
Serial.println("Failed to open directory");
return;
}
if(!root.isDirectory()){
Serial.println("Not a directory");
return;
}
File file = root.openNextFile();
display.clearDisplay();
display.setTextSize(1);
display.setCursor(0,0);
list_folder_count = 0;
while(file && (list_folder_count < (selected+4))){
if(file.isDirectory()){
if(list_folder_count >= selected) {
if(list_folder_count == (selected+1)){
display.setTextColor(BLACK,WHITE);
print_dir = file.name();
}
else{
display.setTextColor(WHITE);
}
display.println(file.name());
}
list_folder_count++;
}
file = root.openNextFile();
}
display.display();
}
//####################################################################################################
// Draw a JPEG on the TFT pulled from SD Card
//####################################################################################################
// xpos, ypos is top left corner of plotted image
void drawSdJpeg(const char *filename, int xpos, int ypos) {
// Open the named file (the Jpeg decoder library will close it)
File jpegFile = SD.open( filename, FILE_READ); // or, file handle reference for SD library
if ( !jpegFile ) {
Serial.print("ERROR: File \""); Serial.print(filename); Serial.println ("\" not found!");
return;
}
Serial.println("===========================");
Serial.print("Drawing file: "); Serial.println(filename);
Serial.println("===========================");
// Use one of the following methods to initialise the decoder:
bool decoded = JpegDec.decodeSdFile(jpegFile); // Pass the SD file handle to the decoder,
//bool decoded = JpegDec.decodeSdFile(filename); // or pass the filename (String or character array)
if (decoded) {
// print information about the image to the serial port
jpegInfo();
// render the image onto the screen at given coordinates
jpegRender(xpos, ypos);
}
else {
Serial.println("Jpeg file format not supported!");
}
}
//####################################################################################################
// Draw a JPEG on the TFT, images will be cropped on the right/bottom sides if they do not fit
//####################################################################################################
// This function assumes xpos,ypos is a valid screen coordinate. For convenience images that do not
// fit totally on the screen are cropped to the nearest MCU size and may leave right/bottom borders.
void jpegRender(int xpos, int ypos) {
//jpegInfo(); // Print information from the JPEG file (could comment this line out)
uint16_t *pImg;
uint16_t mcu_w = JpegDec.MCUWidth;
uint16_t mcu_h = JpegDec.MCUHeight;
uint32_t max_x = JpegDec.width;
uint32_t max_y = JpegDec.height;
bool swapBytes = tft.getSwapBytes();
tft.setSwapBytes(true);
// Jpeg images are draw as a set of image block (tiles) called Minimum Coding Units (MCUs)
// Typically these MCUs are 16x16 pixel blocks
// Determine the width and height of the right and bottom edge image blocks
uint32_t min_w = jpg_min(mcu_w, max_x % mcu_w);
uint32_t min_h = jpg_min(mcu_h, max_y % mcu_h);
// save the current image block size
uint32_t win_w = mcu_w;
uint32_t win_h = mcu_h;
// record the current time so we can measure how long it takes to draw an image
uint32_t drawTime = millis();
// save the coordinate of the right and bottom edges to assist image cropping
// to the screen size
max_x += xpos;
max_y += ypos;
// Fetch data from the file, decode and display
while (JpegDec.read()) { // While there is more data in the file
pImg = JpegDec.pImage ; // Decode a MCU (Minimum Coding Unit, typically a 8x8 or 16x16 pixel block)
// Calculate coordinates of top left corner of current MCU
int mcu_x = JpegDec.MCUx * mcu_w + xpos;
int mcu_y = JpegDec.MCUy * mcu_h + ypos;
// check if the image block size needs to be changed for the right edge
if (mcu_x + mcu_w <= max_x) win_w = mcu_w;
else win_w = min_w;
// check if the image block size needs to be changed for the bottom edge
if (mcu_y + mcu_h <= max_y) win_h = mcu_h;
else win_h = min_h;
// copy pixels into a contiguous block
if (win_w != mcu_w)
{
uint16_t *cImg;
int p = 0;
cImg = pImg + win_w;
for (int h = 1; h < win_h; h++)
{
p += mcu_w;
for (int w = 0; w < win_w; w++)
{
*cImg = *(pImg + w + p);
cImg++;
}
}
}
// calculate how many pixels must be drawn
uint32_t mcu_pixels = win_w * win_h;
// draw image MCU block only if it will fit on the screen
if (( mcu_x + win_w ) <= tft.width() && ( mcu_y + win_h ) <= tft.height())
tft.pushImage(mcu_x, mcu_y, win_w, win_h, pImg);
else if ( (mcu_y + win_h) >= tft.height())
JpegDec.abort(); // Image has run off bottom of screen so abort decoding
}
tft.setSwapBytes(swapBytes);
showTime(millis() - drawTime); // These lines are for sketch testing only
}
//####################################################################################################
// Print image information to the serial port (optional)
//####################################################################################################
// JpegDec.decodeFile(...) or JpegDec.decodeArray(...) must be called before this info is available!
void jpegInfo() {
// Print information extracted from the JPEG file
Serial.println("JPEG image info");
Serial.println("===============");
Serial.print("Width :");
Serial.println(JpegDec.width);
Serial.print("Height :");
Serial.println(JpegDec.height);
Serial.print("Components :");
Serial.println(JpegDec.comps);
Serial.print("MCU / row :");
Serial.println(JpegDec.MCUSPerRow);
Serial.print("MCU / col :");
Serial.println(JpegDec.MCUSPerCol);
Serial.print("Scan type :");
Serial.println(JpegDec.scanType);
Serial.print("MCU width :");
Serial.println(JpegDec.MCUWidth);
Serial.print("MCU height :");
Serial.println(JpegDec.MCUHeight);
Serial.println("===============");
Serial.println("");
}
//####################################################################################################
// Show the execution time (optional)
//####################################################################################################
// WARNING: for UNO/AVR legacy reasons printing text to the screen with the Mega might not work for
// sketch sizes greater than ~70KBytes because 16 bit address pointers are used in some libraries.
// The Due will work fine with the HX8357_Due library.
void showTime(uint32_t msTime) {
//tft.setCursor(0, 0);
//tft.setTextFont(1);
//tft.setTextSize(2);
//tft.setTextColor(TFT_WHITE, TFT_BLACK);
//tft.print(F(" JPEG drawn in "));
//tft.print(msTime);
//tft.println(F(" ms "));
Serial.print(F(" JPEG drawn in "));
Serial.print(msTime);
Serial.println(F(" ms "));
}
Check the video for all the updates. All the parts you need are on this post above. Thanks for the support!
Leave a comment
Please login in order to comment.