#!/usr/bin/env python3 """ Copyright (c) 2026 Sophia Pearson 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())