summaryrefslogtreecommitdiff
path: root/sl1-to-tinymaker.py
diff options
context:
space:
mode:
authorSophia Pearson <codergal89@gmail.com>2026-05-25 15:36:13 +0200
committerSophia Pearson <codergal89@gmail.com>2026-05-25 15:36:58 +0200
commite1164e980e2b417db7a9962e778c36c90c81c355 (patch)
tree89a62be63d8e4130ac8ab6412ee00a6799abf5ff /sl1-to-tinymaker.py
downloadsl1-to-tinymaker-main.tar.xz
sl1-to-tinymaker-main.zip
inital commitHEADmain
Diffstat (limited to 'sl1-to-tinymaker.py')
-rwxr-xr-xsl1-to-tinymaker.py273
1 files changed, 273 insertions, 0 deletions
diff --git a/sl1-to-tinymaker.py b/sl1-to-tinymaker.py
new file mode 100755
index 0000000..d2f959a
--- /dev/null
+++ b/sl1-to-tinymaker.py
@@ -0,0 +1,273 @@
+#!/usr/bin/env python3
+"""
+Copyright (c) 2026 Sophia Pearson <codergal89@gmail.com>
+
+Redistribution and use in source and binary forms, with or without
+modification, are permitted provided that the following conditions are met:
+
+1. Redistributions of source code must retain the above copyright notice, this
+ list of conditions and the following disclaimer.
+2. Redistributions in binary form must reproduce the above copyright notice,
+ this list of conditions and the following disclaimer in the documentation
+ and/or other materials provided with the distribution.
+3. Neither the name of the copyright holder nor the names of its contributors
+ may be used to endorse or promote products derived from this software
+ without specific prior written permission.
+
+THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND
+ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED
+WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
+DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE
+FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL
+DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR
+SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER
+CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY,
+OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
+OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
+"""
+
+from tkinter import filedialog
+from tkinter.ttk import *
+
+from argparse import ArgumentParser
+from configparser import ConfigParser, UNNAMED_SECTION
+from dataclasses import dataclass
+from glob import glob
+from zipfile import ZipFile
+from io import TextIOWrapper
+
+import logging
+import os.path
+import shutil
+import sys
+
+
+PROGRAM_NAME = "sl1-to-tinymaker"
+LOGGER = logging.getLogger(PROGRAM_NAME)
+
+STANDARD_PREABLE = """
+;START_GCODE_BEGIN
+G21; use metric units
+G90; perform absolute movements
+M106 S0; turn the light off
+G28 Z0; home to Z0
+
+; START_GCODE_END
+"""
+
+STANDARD_LAYER = """
+;LAYER_START:{layer}
+;currPos:{bottom_height}
+M6054 "{slice_name}"; show the slice
+G0 Z{top_height:.6f} F{speed_raise:.6f}; raise the platform
+G0 Z{bottom_height:.6f} F{speed_lower:.6f}; lower the platform into position
+G4 P200; dwell to stabilize
+M106 S255.000000; turn the light on
+G4 P{time_in_ms}; expose
+M106 S0; turn the light off
+
+;LAYER_END
+"""
+
+STANDARD_POSTAMBLE = """
+;END_GCODE_BEGIN
+M106 S0; turn the light off
+G1 Z60.000000 F25; raise the platform
+M18; disable all steppers
+
+;END_GCODE_END
+"""
+
+
+@dataclass
+class PrintConfiguration:
+ """A simple DTO to handle print configurations"""
+
+ exposure_time: float
+ first_layer_exposure_time: float
+ layer_height: float
+ initial_layers: int
+
+
+def select_file() -> str:
+ """
+ Ask the user to select an archive file
+
+ :return: The path to the selected file.
+ """
+
+ result = filedialog.askopenfilename(
+ title="Open sliced archive",
+ filetypes=(("sl1 archives", "*.zip *.sl1"),)
+ )
+ return result if result else None
+
+def read_print_configuration(archive: ZipFile) -> PrintConfiguration:
+ """
+ Read the configuration from an SL1 config.ini format
+
+ :param ZipFile archive: The SL1 arhcive
+ :return: The parsed print configuration.
+ :rtype: PrintConfiguration
+ """
+
+ with TextIOWrapper(archive.open("config.ini", "r"), encoding="utf-8") as config_file:
+ parser = ConfigParser(allow_unnamed_section=True)
+ parser.read_file(config_file)
+
+ layer_time = float(parser.get(UNNAMED_SECTION, "expTime"))
+ first_layer_time = float(parser.get(UNNAMED_SECTION, "expTimeFirst"))
+ layer_height = float(parser.get(UNNAMED_SECTION, "layerHeight"))
+ initial_layers = int(parser.get(UNNAMED_SECTION, "numFade"))
+
+ return PrintConfiguration(layer_time, first_layer_time, layer_height, initial_layers)
+
+
+def collect_slices(archive: ZipFile) -> list[str]:
+ """
+ Collect all layer slices.
+
+ :param ZipFile archive: The SL1 archive
+ :return: a list of slice filenames.
+ :rtype: list[str]
+ """
+
+ all_files = archive.namelist()
+ png_files = filter(lambda x : x.endswith(".png") , all_files)
+ slice_files = filter(lambda x : not x.startswith("thumbnail/"), png_files)
+
+ return list(slice_files)
+
+
+def write_gcode(root: str, config: PrintConfiguration, slice_count: int) -> None:
+ """
+ Write the required print gcode for the print.
+
+ :param str root: The path the root of converted print.
+ :param PrintConfiguration config: The configuration for this print.
+ :param int slice_count: The number of slices in this archive.
+ """
+
+ if not os.path.exists(root) or not os.path.isdir(root):
+ raise FileNotFoundError(f"'{root}' does not exist")
+
+ gcode_file_path = f"{root}/run.gcode"
+
+ with open(gcode_file_path, "w") as gcode_file:
+ gcode_file.write(STANDARD_PREABLE)
+
+ for layer in range(slice_count):
+ if layer < config.initial_layers:
+ layer_time = config.first_layer_exposure_time
+ speed_raise = 40.0
+ speed_lower = 50.0
+ else:
+ layer_time = config.exposure_time
+ speed_raise = 40.0
+ speed_lower = 150.0
+
+ gcode_file.write(STANDARD_LAYER.format(
+ bottom_height=(layer + 1) * config.layer_height,
+ layer=layer,
+ slice_name=f"{layer + 1}.png",
+ speed_lower = speed_lower,
+ speed_raise = speed_raise,
+ time_in_ms=int(1000 * layer_time),
+ top_height=2 + (layer + 1) * config.layer_height,
+ )
+ )
+
+ gcode_file.write(STANDARD_POSTAMBLE)
+
+
+def extract_slices(root: str, archive: ZipFile, slices: list[str]) -> None:
+ """
+ Copy the slice files from an extracted SL1 archive to a TinyMaker compatible folder.
+
+ :param str root: The path the root of converted print.
+ :param ZipFile arhive: The SL1 archive.
+ :param list[str] slices: The names of the slice images in the archive.
+ """
+
+ if not os.path.exists(root) or not os.path.isdir(root):
+ raise FileNotFoundError(f"'{root}' does not exist")
+
+ for x, slice_name in enumerate(slices, 1):
+ slice_info = archive.getinfo(slice_name)
+ slice_info.filename = f"{x}.png"
+ archive.extract(slice_info, root)
+
+
+def main():
+ """The main application"""
+
+ logging.basicConfig(
+ level=logging.INFO,
+ format="%(asctime)s %(message)s",
+ datefmt='%m/%d/%Y %I:%M:%S %p',
+ )
+
+ parser = ArgumentParser(
+ prog=PROGRAM_NAME,
+ description="Convert SL1 archives to TinyMaker compatible print folders.",
+ )
+ parser.add_argument("file", help="The SL1 archive to convert", nargs="?", type=str)
+ args = parser.parse_args()
+
+ selected_file = args.file if args.file else select_file()
+
+ if not selected_file:
+ LOGGER.error("No file was selected")
+ return 1
+
+ full_file_path = os.path.abspath(selected_file)
+
+ if not os.path.isfile(full_file_path):
+ LOGGER.error(f"File not found: {full_file_path}")
+ return 1
+
+ with ZipFile(full_file_path, "r") as archive:
+ LOGGER.info(f"Converting {full_file_path} to TinyMaker folder")
+
+ try:
+ config = read_print_configuration(archive)
+ LOGGER.info(f"Successfully read configuration in '{full_file_path}'")
+ LOGGER.info(f"exposure time is {config.exposure_time}s ({config.first_layer_exposure_time}s for the first layer)")
+ LOGGER.info(f"layer height is {config.layer_height}")
+ LOGGER.info(f"base layer count is {config.initial_layers}")
+ except Exception as e:
+ LOGGER.error(f"Failed to read configuration in '{print_root}': {e}")
+ return 1
+
+ try:
+ slices = collect_slices(archive)
+ LOGGER.info(f"Found {len(slices)} slices in '{full_file_path}'")
+ except Exception as e:
+ LOGGER.error(f"Failed to collect slices from '{full_file_path}': {e}")
+ return 2
+
+ try:
+ output_root = f"{os.path.splitext(full_file_path)[0]}-tinymaker"
+ os.mkdir(output_root)
+ LOGGER.info(f"Created output directory '{output_root}'")
+ except Exception as e:
+ LOGGER.error(f"Failed to create output directory '{output_root}': {e}")
+ return 3
+
+ try:
+ write_gcode(output_root, config, len(slices))
+ LOGGER.info(f"Generated gcode in '{output_root}'")
+ except Exception as e:
+ LOGGER.error(f"Failed to write gcode in '{output_root}': {e}")
+ return 4
+
+ try:
+ extract_slices(output_root, archive, slices)
+ LOGGER.info(f"Extracted slices to '{output_root}'")
+ except Exception as e:
+ LOGGER.error(f"Failed to extract slices to '{output_root}': {e}")
+ return 5
+
+
+if __name__ == "__main__":
+ sys.exit(main())