_images/onCodeberg.png

Source Code#

./src/svg2plot/tui.py#
  1# SPDX-FileCopyrightText: 2026 Julien Rippinger
  2#
  3# SPDX-License-Identifier: GPL-3.0-or-later
  4
  5"""Terminal user interface (TUI) that allows users to select SVG files, convert them to G-Code, and send the G-Code to the plotter.
  6
  7Key features include:
  8
  91. **File Browser**: Allows users to navigate through directories and select files.
 102. **SVG to G-Code Conversion**: Converts SVG graphics into G-Code commands using the `vpype` library, with options for path optimization, movement speed and pen pressure settings.
 113. **Plotting**: Connects to the plotter via serial interface, performs a homing cycle if necessary, and sends G-Code commands to start plotting.
 124. **Error Handling**: Includes checks for path length limits and SVG file properties (e.g., dimensions, respect of dpi conventions).
 13"""  # noqa: E501
 14
 15from __future__ import annotations
 16
 17import os
 18import sys
 19from tempfile import NamedTemporaryFile
 20from time import sleep
 21
 22from rich import box
 23from rich.console import Console
 24from rich.panel import Panel
 25from rich.progress import (
 26    BarColumn,
 27    MofNCompleteColumn,
 28    Progress,
 29    TextColumn,
 30    TimeElapsedColumn,
 31)
 32from rich.prompt import Confirm, IntPrompt, Prompt
 33from rich.table import Table
 34from serial import Serial
 35from tomli_w import dumps
 36from vpype import Document, read_multilayer_svg
 37from vpype_cli import execute
 38
 39from . import GcodeRegexHighlighter as GcRGX
 40
 41# --- CLI Arguments: set max path length
 42
 43config = {"path_limit": sys.argv[1]} if len(sys.argv) > 1 else {"path_limit": 75}
 44
 45# --- Main
 46
 47
 48class Controller(Serial):
 49    """Class to communicate with the plotter's controller."""
 50
 51    def __init__(self, path: str = "/dev/ttyUSB0", baudrate: int = 115200) -> None:
 52        super().__init__(path, baudrate)
 53
 54    def send(self, console: Console, commands: str | list[str]) -> str:
 55        # convert single command to list
 56        commands = [commands] if isinstance(commands, str) else commands
 57        # send commands
 58        for line in commands:
 59            data = line.strip()
 60            self.write((data + "\n").encode())  # Send g-code block to grbl
 61            # Wait for grbl response with carriage return
 62            grbl_out = self.readline().strip().decode()
 63            console.print(r"\[" + data + "]:[RTN:" + grbl_out + "]")
 64
 65        return grbl_out
 66
 67    def stream(self, console: Console, commands: str | list[str]) -> None:
 68        # convert single command to list
 69        commands = [commands] if isinstance(commands, str) else commands
 70        # Define custom progress bar
 71        progress_bar = Progress(
 72            TextColumn("[progress.percentage]{task.percentage:>3.0f}%"),
 73            BarColumn(bar_width=None),
 74            MofNCompleteColumn(),
 75            TimeElapsedColumn(),
 76            console=console,
 77            expand=True,
 78        )
 79
 80        # Use custom progress bar
 81        with progress_bar as p:
 82            for line in p.track(commands):
 83                # send command
 84                self.send(console, line)
 85
 86    def connect(self, console: Console) -> None:
 87        tasks = ["connection to plotter"]
 88
 89        with console.status("[yellow]connecting..."):
 90            while tasks:
 91                task = tasks.pop(0)
 92                # Wake up grbl
 93                self.write(b"\r\n\r\n")
 94                sleep(2)  # Wait for grbl to initialize
 95                self.flushInput()  # Flush startup text in serial input
 96                console.print(
 97                    Panel(
 98                        f":zap: {task} established\nlong text explaining how to setup the plotter",
 99                        title="[magenta]setup plotter",
100                        box=box.ASCII,
101                    ),
102                )
103
104    def disconnect(self, console: Console) -> None:
105        tasks = ["disconnecting plotter"]
106
107        with console.status("[yellow]disconnecting..."):
108            while tasks:
109                _ = tasks.pop(0)
110                # Wake up grbl
111                self.close()
112                console.print(Panel(":zap: plotter disconnected", box=box.ASCII))
113
114
115def get_files_in_directory(directory: str) -> tuple[Table, dict[int, str]]:
116    # Function to get the files in a directory
117    # Initialize table
118    file_table = Table(show_header=True, expand=True, box=box.ASCII2, header_style="")
119    file_table.add_column("#", ratio=3, style="cyan", justify="right")
120    file_table.add_column(directory + "/", ratio=21, style="green")
121    file_table.add_column("type", ratio=13, style="yellow")
122    # file_table.add_column("Path", style="magenta")
123
124    # Add files to the table
125    item_dict = {}
126
127    # Collect files and directories
128    file_items = []
129    dir_items = []
130
131    if directory != os.getenv("HOME"):
132        # Add parent direcory
133        file_table.add_row("[yellow]0", "[yellow]BACK")
134        item_dict[0] = os.path.abspath(os.path.join(directory, os.pardir))
135
136    for item in sorted(os.listdir(directory)):
137        if item.startswith("."):
138            continue
139        item_path = os.path.join(directory, item)
140        if os.path.isdir(item_path):
141            item = f"[blue]{item.upper()}"
142            dir_items.append((item, item_path))
143        if os.path.isfile(item_path) and (item.endswith(".gcode") or item.endswith(".svg")):
144            file_items.append((item, item_path))
145
146    for index, (item_name, item_path) in enumerate(dir_items + file_items):
147        item_type = "folder" if (item_name, item_path) in dir_items else "file"
148        file_table.add_row(str(index + 1), item_name, item_type)
149        item_dict[index + 1] = item_path
150
151    return file_table, item_dict
152
153
154def header(console: Console) -> None:
155    """TODO: add some parameters (/dev/ttyUSB0, baudrate, etc.)"""
156    title_text = """
1571. Select folder and files to plot
1582. Convert SVG to GCODE
1593. Setup plotter
1604. Send GCODE to plotter
1615. Repeat
162"""
163    # Flush terminal
164    os.system("cls" if os.name == "nt" else "clear")
165    # Display header
166    console.print(
167        Panel(title_text, title="[magenta]<svg>-2-<plotter>", box=box.HORIZONTALS),
168    )
169
170
171def file_browser(console: Console, current_directory: str) -> str:
172    file_prompt_text = "[yellow]#[/yellow] [cyan]index[/cyan] of the [blue]directory[/blue] or [green]file[/green] you want to navigate/select.\n[yellow]0[/yellow] to return to previous directory.\n[yellow]D[/yellow] key to return to desktop.\n[yellow]Q[/yellow] key to quit.\n[yellow]?[/yellow] switch directories to update file list."
173
174    # Display files in the current directory
175    console.print(
176        Panel(
177            file_prompt_text,
178            title="[magenta]file browsing commands",
179            subtitle="[magenta]file browser",
180            box=box.ASCII,
181        ),
182    )
183
184    file_table, file_dict = get_files_in_directory(current_directory)
185    console.print(file_table)
186
187    # Loop for selecting a subdirectory
188    while True:
189        # Prompt for user input
190
191        selected_index = Prompt.ask("[yellow]prompt")
192        selected_path = None
193        # Check for quit command
194        if selected_index.lower() == "q":
195            sys.exit(1)
196
197        # Find the selected directory path
198        elif selected_index.lower() == "d":
199            selected_path = os.getenv("HOME", "") + "/Desktop"
200
201        else:
202            try:
203                selected_path = file_dict.get(int(selected_index))
204            except Exception as e:
205                console.print(f"[red]invalid index selected: {e}")
206                continue
207
208            if not selected_path:
209                console.print("[red]invalid index selected.")
210                continue
211
212        # If a directory was selected, display its files
213        if os.path.isdir(selected_path):
214            header(console)
215
216            console.print(
217                Panel(
218                    file_prompt_text,
219                    title="[magenta]file browsing commands",
220                    box=box.ASCII,
221                    subtitle="[magenta]file browser",
222                ),
223            )
224
225            file_table, file_dict = get_files_in_directory(selected_path)
226            console.print(file_table)
227        else:
228            console.print(
229                Panel(
230                    f"[green]{os.path.split(selected_path)[1]}",
231                    title="[magenta]selected file",
232                    box=box.ASCII,
233                ),
234            )
235            if Confirm.ask("[yellow]confirm ?", default=True):
236                break
237            continue
238
239    return selected_path
240
241
242def too_many_lines(drawing: Document, console: Console) -> bool:
243    """Get metadate from SVG file, particularly the number of segments."""
244    check = False
245    path_count = sum(len(layer) for layer in drawing.layers.values())
246
247    if path_count > config["path_limit"]:
248        check = True
249        console.print(
250            Panel(
251                f":astonished: What? {path_count} paths! That's more than the max. recommended {config['path_limit']} paths.\nFor each path the pen has to be lifted in order to reach a new location. That's what hurts the plotter most. Please make more extenisve use of continous lines or split your file into several layers before plotting. Check the \"Multilayer\" how-to section of the documentation for help.",
252                title="WARNING",
253                style="bright_red",
254                box=box.DOUBLE,
255            ),
256        )
257
258    return check
259
260
261def no_size(svg: str) -> bool:
262    """Check in header if svg file has a width/height value.
263
264    Adobe Illustrator tends not to.
265    """
266    check = False
267    with open(svg) as f:
268        for line in f:
269            if line.startswith("<svg"):
270                line = line.split(">")[0]  # get single line svg
271                check = False if "width=" in line or "height=" in line else True
272                break
273    return check
274
275
276def is_adobe_svg(file: str, head: int = 10) -> bool:
277    """Check in header if svg file has been generated by Adobe Illustrator."""
278    check = False
279    # Check if svg is generated from Adobe Illustrator
280    with open(file) as f:
281        # parse first 10 lines
282        for line in f.readlines()[0:head]:
283            if "Adobe" in line or "Illustrator" in line:
284                check = True
285                break
286    return check
287
288
289def select_plot_pression() -> str:
290    """Prompt selection of pressure value."""
291    pressures = [f"{i * 0.1:.1f}" for i in range(1, 31)] + [str(i) for i in range(1, 4)]
292
293    return Prompt.ask(
294        r"[yellow]define pen pressure [bold magenta]\[min:0.1mm/max:3.0mm]",
295        choices=pressures,
296        show_choices=False,
297        default="0.7",
298    )
299
300
301def create_temporary_file(data: str, suffix: str = "") -> str:
302    """Creates a tempfile.NamedTemporaryFile object for data.
303
304    + source:https://programtalk.com/vs4/python/Aguila-team/aguila_nlu/rasa_nlu/utils/__init__.py/
305    """
306    f = NamedTemporaryFile("w+", suffix=suffix, delete=False, encoding="utf-8")
307    f.write(data)
308    f.close()
309
310    return f.name
311
312
313def make_toml(p: str) -> str:
314    # convert input to relative z value
315    z_value = f"{(5.0 + float(p)):.4f}"
316
317    # insert z_value into custom gcode profile
318    toml_dict = {
319        "gwrite": {
320            "chelonograph": {
321                "document_start": "G92 X0 Y0 Z0 (set origin)\nG21 (unit is mm)\nG17 (work in XY plane)\nG91 Z-5.0000 (pen up)\n",
322                "segment_first": f"G90 X{{x:.4f}} Y{{y:.4f}} (travel)\nG91 Z+{z_value} (pen down)\nG90 (absolute position)\n",
323                "segment": "G01 X{x:.4f} Y{y:.4f} (draw)\n",
324                "segment_last": f"G01 X{{x:.4f}} Y{{y:.4f}} (draw)\nG91 Z-{z_value} (pen up)\n",
325                "document_end": "G90 X0 Y0 (travel)\nM2 (end)\n",
326                "unit": "mm",
327            },
328        },
329    }
330
331    return create_temporary_file(dumps(toml_dict), suffix=".toml")
332
333
334def convert_svg_gcode(console: Console, svg_file: str) -> str | None:
335    # Load svg as vpype document
336    drawing = read_multilayer_svg(svg_file, 0.4)
337
338    # TODO: I do not remember why this is here
339    # if no_size(svg_file):
340    #     console.print("no width/height")
341
342    # Check scale of svg file
343    if is_adobe_svg(svg_file):
344        # propose to change scale
345        console.print(
346            Panel(
347                ":astonished: seems like the SVG has been saved with illustrator which bluntly ignores SVG conventions: your drawing is 25% too small.\n",
348                title="WARNING",
349                style="bright_red",
350                box=box.DOUBLE,
351            ),
352        )
353
354        if Confirm.ask(
355            "[yellow]do you want to scale the document to 96 ppi?",
356            default=True,
357        ):
358            svg_file = f"{svg_file[:-4]}_scaled.svg"
359            drawing = execute(f"scale 1.333 1.333 write {svg_file}", drawing)
360
361            console.print(
362                Panel(
363                    f"[green]{os.path.split(svg_file)[1]}",
364                    title="[magenta]output",
365                    box=box.ASCII,
366                ),
367            )
368
369    """
370    2. Convert svg to gcode with vpype
371    """
372    # Optional: optimize geometry and paths
373    path_count = sum(len(layer) for layer in drawing.layers.values())
374    if "_optimized" not in svg_file and Confirm.ask(
375        f"[yellow]file contains {path_count} paths, optimize SVG before converting to gcode?",
376        default=True,
377    ):
378        # create new svg file
379        svg_file = f"{svg_file[:-4]}_optimized.svg"
380        drawing = execute(
381            f"linesimplify reloop linemerge linesort write {svg_file}",
382            drawing,
383        )
384
385    # Caution: check number of line segments
386    if too_many_lines(drawing, console):
387        gcode_file = None
388    else:
389        # get pression i.e. gwrite profile
390        pression: str = select_plot_pression()
391
392        gcode_file = f"{svg_file[:-4]}_p{pression.replace('.', ''):2}.gcode"
393
394        # make vpype gcode profile for custom pressure
395        tmp_file = make_toml(pression)
396
397        # convert svg to gcode
398        drawing = execute(
399            f"gwrite -p {'chelonograph'} {gcode_file}",
400            document=drawing,
401            global_opt=f"--config {tmp_file}",
402        )
403        console.print(
404            Panel(
405                f"[green]{os.path.split(gcode_file)[1]}",
406                title="[magenta]output",
407                box=box.ASCII,
408            ),
409        )
410
411        # Show file before print
412
413        if Confirm.ask("[yellow]preview drawing file?", default=True):
414            # with suppress_stdout():
415            # show(drawing)
416            execute("show --classic --show-pen-up --colorful", document=drawing)
417
418    return gcode_file
419
420
421def setup_plotter(console: Console, gcode_file: str) -> None:
422    plotter = Controller("/dev/ttyUSB0", 115200)
423    # plotter.set_rich_output(console)
424    plotter.connect(console)
425
426    plotter_lock = True
427    while plotter_lock:
428        if Confirm.ask("[yellow]unlock with homing cycle?", default=True):
429            tasks = ["homing"]
430            with console.status("[yellow]homing initiated..."):
431                while tasks:
432                    _ = tasks.pop(0)
433                    homing = plotter.send(console, "$H")
434
435            if homing != "ok":
436                console.print(
437                    Panel(
438                        "some text saying FAIL\nCheck if the controller is powered?\nTry again choosing the previously generated gcode file.",
439                        title="WARNING",
440                        style="bright_red",
441                        box=box.DOUBLE,
442                    ),
443                )
444            else:
445                # console.log(f"[yellow]{task} complete")
446                break
447
448        elif Confirm.ask("[yellow]unlock manually?", default=True):
449            plotter.send(console, "$X")
450            break
451
452        else:
453            console.print(
454                Panel(
455                    "plotter is still locked",
456                    title="INFO",
457                    style="bright_red",
458                    box=box.DOUBLE,
459                ),
460            )
461            continue
462
463    # choose plotting speed
464    speed = IntPrompt.ask(
465        r"[yellow]move pen to origin ([bright_red]0[/bright_red],[bright_green]0[/bright_green],[bright_blue]0[/bright_blue]) and enter speed value [bold magenta]\[min:1%/max:100%]",
466        default="75",
467        choices=[str(n) for n in range(1, 101)],
468        show_choices=False,
469    )
470
471    plotter.send(console, f"F{25000 * (int(speed) / 100)}")
472
473    # send file
474
475    console.print(
476        Panel(
477            f":zap: hitting enter will send [green]{os.path.split(gcode_file)[1]}[/green] to the plotter.",
478            title="[magenta]plotter launch pad",
479            box=box.ASCII,
480        ),
481    )
482
483    if Confirm.ask("[yellow]start plotting?", default=True):
484        # Stream g-code to grbl
485        with open(gcode_file) as f:
486            plotter.stream(console, f.readlines())
487
488        console.print(Panel("SUCCESS! :smiley: looks like it worked.", box=box.ASCII))
489
490    else:
491        # console.print('ABORT')
492        console.print(
493            Panel(
494                f"operation aborted. tip: return to file browser and select the previously generated GCODE file [green]{os.path.split(gcode_file)[1]}",
495                title="INFO",
496                style="bright_red",
497                box=box.DOUBLE,
498            ),
499        )
500
501    plotter.disconnect(console)
502
503
504def main():
505    current_directory = os.getenv("HOME", "") + "/Desktop"
506
507    # Initialize console
508    console = Console(
509        highlighter=GcRGX.GcodeHighlighter(),
510        theme=GcRGX.GcodeHighlighter.theme,
511    )
512
513    while True:
514        # Make heading
515        header(console)
516
517        # Choose a file
518        file = file_browser(console, current_directory)
519
520        # Check if selection is SVG or GCODE
521        svg_file = file if file.endswith(".svg") else False
522        gcode_file = file if file.endswith(".gcode") else False
523
524        # Convert SVG to GCODE
525        if svg_file:
526            gcode_file = convert_svg_gcode(
527                console,
528                svg_file,
529            )  # None if too many segments
530
531        if gcode_file:
532            # Setup plotter
533            setup_plotter(console, gcode_file)
534
535        if Confirm.ask(
536            "[yellow]return to file browser to plot another file?",
537            default=True,
538        ):
539            current_directory = os.path.split(file)[0]
540            os.system("cls" if os.name == "nt" else "clear")
541            continue
542        # exit program
543        break
544
545
546if __name__ == "__main__":
547    main()
./src/svg2plot/GcodeRegexHighlighter.py#
 1# SPDX-FileCopyrightText: 2026 Julien Rippinger
 2#
 3# SPDX-License-Identifier: GPL-3.0-or-later
 4
 5"""Implementation of a G-Code highlighter.
 6
 7The GcodeHighlighter class defines regular expressions to match different elements in G-code files and applies specific styles to them, making the stream
 8of commands sent to the plotter nicer to look at and easier to read.
 9"""  # noqa: E501
10
11from rich.highlighter import RegexHighlighter
12from rich.theme import Theme
13
14
15class GcodeHighlighter(RegexHighlighter):
16    """Apply highlight style to anything that looks like gcode."""
17
18    base_style = "gcode."
19    highlights = [  # noqa: RUF012
20        r"(?P<g>([Gg][0-9][0-9]))",
21        r"(?P<m2>(M2))",
22        r"(?P<setting>(\$[A-Z]))",
23        r"(?P<speed>(F\d+.0))",
24        r"(?P<ok>(ok))",
25        r"(?P<close>(\]))",
26        r"(?P<in>(\[))",
27        r"(?P<out>(:\[RTN:))",
28        r"(?P<error>(error:[0-9]))",
29        r"(?P<end>(MSG:Pgm End))",
30        r"(?P<comment>(\(([^]]+)\)))",
31        r"(?P<x>(([Xx]) *(-?\d+.?\d*)))",
32        r"(?P<y>(([Yy]) *(-?\d+.?\d*)))",
33        r"(?P<z>(([Zz]) *([-+]?\d+.?\d*)))",
34    ]
35    theme = Theme(
36        {
37            "gcode.g": "bright_yellow",
38            "gcode.m2": "bright_yellow",
39            "gcode.setting": "bright_yellow",
40            "gcode.speed": "bright_yellow",
41            "gcode.ok": "bright_black",
42            "gcode.close": "deep_pink4",
43            "gcode.in": "deep_pink4",
44            "gcode.out": "deep_pink4",
45            "gcode.error": "blink",
46            "gcode.end": "bright_black",
47            "gcode.comment": "light_slate_grey",
48            "gcode.x": "bright_red",
49            "gcode.y": "bright_green",
50            "gcode.z": "bright_blue",
51        },
52    )