vsketch: Drawing for a Pen Plotter with Python
Generating plotter drawings with code: a first vsketch sketch, what finalizing does for the plot, and the step-by-step route from a sketch to the LY Drawbot, including the conversion from pixels to millimeters.
What vsketch is
The previous note drew a plot by hand in Inkscape. The other way is to let a program compute the lines: generative art. vsketch, by Antoine Beyeler, is an open-source Python tool made for exactly that, with pen plotters in mind. It is built on his vpype, a toolkit that reads, optimizes and writes plotter drawings.
-
You write a sketch: a Python file with drawing
commands such as
vsk.line(),vsk.rect()andvsk.circle(), in the spirit of Processing. -
vsk runopens a viewer that redraws the sketch every time you save the file, with sliders for its parameters and a button for a new random seed. - Everything is lines, never pixels, so what you see is what the plotter will draw.
Installing
vsketch is installed as a stand-alone program with pipx, which gives it its own Python environment so it does not mix with your other projects. Version 1.2.0 needs Python 3.11, 3.12 or 3.13:
pipx install vsketch
vsk --help
The viewer is a desktop window, so vsk run needs a
computer with a screen; the drawing and saving parts also work
without one.
A first sketch
vsk init ten_print creates a folder
ten_print with a file
sketch_ten_print.py from vsketch's template; it asks
for the paper size and orientation (A4, landscape). Replace the
contents with this sketch: a maze of short diagonal lines, after a
famous one-line program for the Commodore 64,
10 PRINT CHR$(205.5+RND(1)); : GOTO 10, which prints a
random / or \ forever.
import vsketch
class TenPrintSketch(vsketch.SketchClass):
cell = vsketch.Param(10.0, 4.0, 30.0) # size of one cell, in mm
chance = vsketch.Param(0.5, 0.0, 1.0) # chance of "/" instead of "\"
def draw(self, vsk: vsketch.Vsketch) -> None:
vsk.size("a4", landscape=True)
vsk.scale("mm")
cols = int(250 // self.cell)
rows = int(170 // self.cell)
for row in range(rows):
for col in range(cols):
x = col * self.cell
y = row * self.cell
if vsk.random(1) < self.chance:
vsk.line(x, y + self.cell, x + self.cell, y) # /
else:
vsk.line(x, y, x + self.cell, y + self.cell) # \
def finalize(self, vsk: vsketch.Vsketch) -> None:
vsk.vpype("linemerge linesimplify reloop linesort")
if __name__ == "__main__":
TenPrintSketch.display()
Download it as sketch_ten_print.py. What it does:
-
TenPrintSketchis a class that inherits fromvsketch.SketchClass, which supplies the viewer and the saving; you only writedraw()andfinalize(). Theif __name__ == "__main__":block opens the viewer when you run the file directly. -
vsk.size("a4", landscape=True)sets the page: A4 landscape, the plotter's paper. -
vsk.scale("mm")makes every number in the code a millimeter, socell = 10.0means 10 mm. Without it the numbers would be pixels. -
The two
vsketch.Paramlines become sliders in the viewer: cell size from 4 to 30 mm, and the chance of a/. Change them and the drawing updates. -
vsk.random(1)gives a random number between 0 and 1. The seed decides which maze you get; the viewer shows it, so a maze you like can be made again. -
The grid starts at 0,0, yet the result sits in the middle of the
page:
vsk.size()centers the drawing on the page by default.
What finalize does
draw() is what you see; finalize() runs
once more when the sketch is saved, to make it good to plot. The
four vpype commands:
| Command | Does |
|---|---|
linemerge |
Joins lines whose ends touch into one stroke, so the pen does not lift in between. |
linesimplify |
Drops points that make no visible difference (within 0.05 mm by default). |
reloop |
Moves the start of closed shapes to a random spot, so the small mark where the pen lands does not line up. |
linesort |
Puts the strokes in an order that keeps the pen-up travel short. |
For this maze (seed 7) it makes a real difference. The 425 single lines become 122 strokes; the pen travels 1.7 meters lifted instead of 6.2, for the same 6 meters of drawn line; and the plot takes about 4½ minutes instead of 6, before counting the 300 pen lifts saved.
Running and saving
cd ten_print
vsk run
The viewer opens with the drawing and the parameters. Change the
code and save the file: the drawing updates. When you like what you
see, press LIKE! (or the S key): vsketch runs
finalize() and saves the SVG in a folder
output next to the sketch, as
ten_print_liked_1.svg, then _2 and so on.
Without the viewer, vsk save does the same from the
command line, for one seed or for many at once:
vsk save --seed 7 . # one maze
vsk save --seed 0..9 . # ten mazes, one SVG per seed
Units: pixels and millimeters
Here is the catch for the LY Drawbot. vsketch writes the page size in centimeters but every coordinate in pixels, at 96 pixels per inch. The start of the SVG it saves:
<svg … width="29.7cm" height="21.0cm"
viewBox="0 0 1122.5196850393702 793.7007874015749">
A browser or Inkscape scales that correctly. The LY Drawbot's extension does not: it takes the numbers as millimeters, and 1122.5 "millimeters" is 3.78 times A4 (96 ÷ 25.4 = 3.78). With the soft limits off, the plotter would try to drive far beyond its frame.
The fix is to rewrite the file so that one unit is one millimeter. This script does it with vpype, which comes with vsketch, so it runs in vsketch's own Python:
"""Rewrite a vsketch or vpype SVG so that its coordinates are millimeters.
vsketch saves its coordinates in CSS pixels (96 per inch) and only states the
page size in centimeters. Plotter tools that read the numbers as millimeters
then draw 3.78 times too large. This script writes the same lines with one
user unit per millimeter.
Usage: python svg_to_mm.py input.svg output.svg
Needs vpype, which comes with vsketch.
"""
import sys
from pathlib import Path
import vpype as vp
PX_PER_MM = vp.convert_length("1mm") # 3.7795...: CSS pixels per millimeter
def to_mm(source: str, target: str) -> None:
doc = vp.read_multilayer_svg(source, quantization=0.1) # in pixels
if doc.page_size is None:
sys.exit(f"{source}: no page size found")
width = doc.page_size[0] / PX_PER_MM
height = doc.page_size[1] / PX_PER_MM
paths = []
for layer in doc.layers.values():
for line in layer:
points = [f"{p.real / PX_PER_MM:.3f},{p.imag / PX_PER_MM:.3f}" for p in line]
paths.append(f' <path d="M {points[0]} L {" ".join(points[1:])}"/>')
svg = [
'<?xml version="1.0" encoding="UTF-8"?>',
f'<svg xmlns="http://www.w3.org/2000/svg" width="{width:.1f}mm" '
f'height="{height:.1f}mm" viewBox="0 0 {width:.1f} {height:.1f}">',
'<g fill="none" stroke="black" stroke-width="0.3">',
*paths,
"</g>",
"</svg>",
]
Path(target).write_text("\n".join(svg) + "\n", encoding="utf-8")
print(f"{target}: {width:.0f} x {height:.0f} mm, {len(paths)} lines")
if __name__ == "__main__":
if len(sys.argv) != 3:
sys.exit("usage: python svg_to_mm.py input.svg output.svg")
to_mm(sys.argv[1], sys.argv[2])
Download it as
svg_to_mm.py. On the maze
it prints ten_print_mm.svg: 297 x 210 mm, 122 lines,
and the new file starts with
width="297.0mm" height="210.0mm" viewBox="0 0 297.0 210.0"
— the same lines, now in millimeters, and already paths.
From sketch to plot, step by step
How I go from a sketch to paper on the LY Drawbot:
-
Make. In the sketch folder,
vsk run, play until you like it, press LIKE!. The SVG is inoutput. -
Convert to millimeters. Run the script with
vsketch's Python, which has vpype:
That is the location on Windows with a recent pipx;& "$env:LOCALAPPDATA\pipx\pipx\venvs\vsketch\Scripts\python.exe" svg_to_mm.py output\ten_print_liked_1.svg ten_print_mm.svgpipx listshows where your pipx keeps its environments (on Linux and macOS it isbin/pythoninside thevsketchfolder there). It worked when it prints297 x 210 mm. -
Make the G-code. Open the
_mm.svgin Inkscape and run the LY Drawbot extension as in the previous note. The file already has paths only, in millimeters. - Check and plot. Open the G-code in UGS or Grbl Controller. In the visualizer the drawing must stay within 310 by 210 mm; the long straight line from the zero point is pen-up travel and is not drawn. Set the zero point and start.
function vsk-python { & "$env:LOCALAPPDATA\pipx\pipx\venvs\vsketch\Scripts\python.exe" @args }
The plot
For the plot I used a white gel pen, a Gelly Roll 10 (0.5 mm), on black paper: Canson Graduate, 240 g/m², A4, smooth. The video is a hyperlapse, 24 times faster than real time, so the whole maze took about 22 minutes.
Sources
- vsketch documentation and source
- vpype documentation
- 10 PRINT CHR$(205.5+RND(1)); : GOTO 10 (the book about the one-line program, 2012)
- pipx