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() and vsk.circle(), in the spirit of Processing.
  • vsk run opens 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:

  • TenPrintSketch is a class that inherits from vsketch.SketchClass, which supplies the viewer and the saving; you only write draw() and finalize(). The if __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, so cell = 10.0 means 10 mm. Without it the numbers would be pixels.
  • The two vsketch.Param lines 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.

The finished maze with seed 7: a grid of 25 by 17 cells of 10 millimeters, each with one diagonal line, centered on an A4 landscape page. The diagonals join into winding paths.
The maze with seed 7 on an A4 landscape page, as saved and converted to millimeters.

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:

  1. Make. In the sketch folder, vsk run, play until you like it, press LIKE!. The SVG is in output.
  2. Convert to millimeters. Run the script with vsketch's Python, which has vpype:
    & "$env:LOCALAPPDATA\pipx\pipx\venvs\vsketch\Scripts\python.exe" svg_to_mm.py output\ten_print_liked_1.svg ten_print_mm.svg
    That is the location on Windows with a recent pipx; pipx list shows where your pipx keeps its environments (on Linux and macOS it is bin/python inside the vsketch folder there). It worked when it prints 297 x 210 mm.
  3. Make the G-code. Open the _mm.svg in Inkscape and run the LY Drawbot extension as in the previous note. The file already has paths only, in millimeters.
  4. 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.

The maze being plotted (hyperlapse, 24× faster, no sound).

Sources