diff options
Diffstat (limited to 'sl1-to-tinymaker.py')
| -rwxr-xr-x | sl1-to-tinymaker.py | 273 |
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()) |
