summaryrefslogtreecommitdiff
path: root/sl1-to-tinymaker.py
blob: d2f959a55dd3f7adf6d568a649f322ac313a47f8 (plain)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
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())