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()