Skip to content

geomotif.io.plotter

Preparing a design for a pen plotter.

A plotter cares about three things this library otherwise does not: how big the paper is, how far the pen travels while it is up, and which pen is drawing. This module answers all three.

A real size. :func:to_plotter_svg writes the drawing at a named paper size in millimeters -- width="210mm", not width="210" -- so what comes out of the plotter is the size you asked for rather than whatever the receiving software guessed.

Fewer wasted moves. :func:optimize joins strokes that end where another begins and then orders them so the pen travels as little as possible between them -- which on a plotted mandala is most of the drawing time. :func:pen_up_distance measures the difference rather than asserting it.

One pen at a time. Everything here works layer by layer (:mod:geomotif.core.style) and never joins or reorders across layers: two strokes on different layers are drawn by different pens, and merging them would mean drawing one of them in the wrong color.

::

from geomotif.io.plotter import optimize, pen_up_distance, save_plotter_svg

design = optimize(mandala.build())
save_plotter_svg(design, "mandala.svg", paper="a4", margin=15.0)

For anything beyond this -- occlusion, hatching, HPGL -- reach for vpype, which does all of it and which :func:to_vpype hands a design to directly.

Functions:

Name Description
page_size

Return a named paper's size in millimeters.

on_page

Return design fitted to a sheet of paper, in millimeters, y-down.

to_plotter_svg

Render a design as an SVG measured in real millimeters.

save_plotter_svg

Write a plotter-ready SVG and return the path written.

pen_up_distance

Return how far the pen travels while it is not drawing.

optimize

Return design with its strokes joined up and put in a sensible order.

to_vpype

Return this design as a vpype document, fitted to a page.

page_size

page_size(paper: str = 'a4', *, landscape: bool = False) -> tuple[float, float]

Return a named paper's size in millimeters.

Raises:

Type Description
KeyError

If the name is not one of :data:PAPER. The message lists what is.

Source code in src/geomotif/io/plotter.py
def page_size(paper: str = "a4", *, landscape: bool = False) -> tuple[float, float]:
    """Return a named paper's size in millimeters.

    Raises
    ------
    KeyError
        If the name is not one of :data:`PAPER`. The message lists what is.
    """
    key = paper.strip().lower()
    if key not in PAPER:
        raise KeyError(f"no paper size called {paper!r}; try one of {sorted(PAPER)}")
    width, height = PAPER[key]
    return (height, width) if landscape else (width, height)

on_page

on_page(design: Design, *, paper: str = 'a4', margin: float = 10.0, landscape: bool = False) -> Design

Return design fitted to a sheet of paper, in millimeters, y-down.

Scaling is uniform and the drawing is centered, so nothing is distorted and the margin is honored on all four sides. The result is in the coordinate space a plotter and an SVG both use -- y growing downward -- which is why it is worth doing once here rather than in each writer.

Source code in src/geomotif/io/plotter.py
def on_page(
    design: Design,
    *,
    paper: str = "a4",
    margin: float = 10.0,
    landscape: bool = False,
) -> Design:
    """Return ``design`` fitted to a sheet of paper, in millimeters, y-down.

    Scaling is uniform and the drawing is centered, so nothing is distorted and
    the margin is honored on all four sides. The result is in the coordinate
    space a plotter and an SVG both use -- y growing downward -- which is why
    it is worth doing once here rather than in each writer.
    """
    width, height = page_size(paper, landscape=landscape)
    return design.fit(width, height, padding=margin, flip_y=True)

to_plotter_svg

to_plotter_svg(design: Design, *, paper: str = 'a4', margin: float = 10.0, landscape: bool = False, stroke_width: float = DEFAULT_PEN, **kwargs: Any) -> str

Render a design as an SVG measured in real millimeters.

Parameters:

Name Type Description Default
design Design

What to plot.

required
paper str

A name from :data:PAPER.

'a4'
margin float

Border to leave unplotted, in millimeters. Worth more than you would think: most plotters cannot reach the last few millimeters of a sheet.

10.0
landscape bool

Turn the paper on its side.

False
stroke_width float

Pen width in millimeters. Only affects how the file looks -- a plotter draws with the pen it has -- but getting it right makes the preview honest.

DEFAULT_PEN
**kwargs Any

Passed to :func:~geomotif.io.svg.to_svg.

{}

Returns:

Type Description
str

An SVG whose width and height carry mm, whose viewBox is the same numbers, and whose layers are the groups vpype and Inkscape read.

Source code in src/geomotif/io/plotter.py
def to_plotter_svg(
    design: Design,
    *,
    paper: str = "a4",
    margin: float = 10.0,
    landscape: bool = False,
    stroke_width: float = DEFAULT_PEN,
    **kwargs: Any,
) -> str:
    """Render a design as an SVG measured in real millimeters.

    Parameters
    ----------
    design : Design
        What to plot.
    paper : str
        A name from :data:`PAPER`.
    margin : float
        Border to leave unplotted, in millimeters. Worth more than you would
        think: most plotters cannot reach the last few millimeters of a sheet.
    landscape : bool
        Turn the paper on its side.
    stroke_width : float
        Pen width in millimeters. Only affects how the file *looks* -- a
        plotter draws with the pen it has -- but getting it right makes the
        preview honest.
    **kwargs
        Passed to :func:`~geomotif.io.svg.to_svg`.

    Returns
    -------
    str
        An SVG whose ``width`` and ``height`` carry ``mm``, whose ``viewBox``
        is the same numbers, and whose layers are the groups ``vpype`` and
        Inkscape read.
    """
    width, height = page_size(paper, landscape=landscape)
    # The writer does the placing. Fitting to the page here as well would only
    # be undone: to_svg fits whatever it is given into the canvas it is given,
    # so a margin applied first is scaled straight back out to the paper edge.
    return to_svg(
        design,
        width=width,
        height=height,
        padding=margin,
        stroke_width=stroke_width,
        units="mm",
        **kwargs,
    )

save_plotter_svg

save_plotter_svg(design: Design, path: str | PathLike[str], **kwargs: Any) -> Path

Write a plotter-ready SVG and return the path written.

Keyword arguments are passed straight through to :func:to_plotter_svg.

Source code in src/geomotif/io/plotter.py
def save_plotter_svg(design: Design, path: str | PathLike[str], **kwargs: Any) -> pathlib.Path:
    """Write a plotter-ready SVG and return the path written.

    Keyword arguments are passed straight through to :func:`to_plotter_svg`.
    """
    target = pathlib.Path(path)
    target.write_text(to_plotter_svg(design, **kwargs))
    return target

pen_up_distance

pen_up_distance(design: Design, *, start: Point = (0.0, 0.0)) -> float

Return how far the pen travels while it is not drawing.

Counted from start to the first stroke, then between the end of each stroke and the beginning of the next. This is the number :func:optimize exists to reduce, and the one to measure it by.

Source code in src/geomotif/io/plotter.py
def pen_up_distance(design: Design, *, start: Point = (0.0, 0.0)) -> float:
    """Return how far the pen travels while it is *not* drawing.

    Counted from ``start`` to the first stroke, then between the end of each
    stroke and the beginning of the next. This is the number :func:`optimize`
    exists to reduce, and the one to measure it by.
    """
    pen = start
    total = 0.0
    for path in design.paths:
        if not path.points:
            continue
        total += math.dist(pen, path.points[0])
        pen = path.points[0] if path.closed else path.points[-1]
    return total

optimize

optimize(design: Design, *, tolerance: float = 0.1, merge: bool = True, sort: bool = True, start: Point = (0.0, 0.0)) -> Design

Return design with its strokes joined up and put in a sensible order.

Two passes, both of which only ever move whole strokes about:

  1. Merge. Two open strokes whose ends meet within tolerance become one, reversing either if that is what makes them meet. A stroke whose own ends then meet is closed. This is where a tiling built cell by cell stops being four thousand separate edges.
  2. Sort. The strokes are ordered greedily from start, always taking whichever begins nearest to where the pen just finished, and reversing an open stroke when its far end is the nearer one.

Neither pass crosses a layer, for the reason this module opens with, and layers come out in the order they went in.

Parameters:

Name Type Description Default
design Design

What to optimize.

required
tolerance float

How close two ends must be to count as touching, in the design's own units. Must be >= 0; zero demands exactly equal coordinates.

0.1
merge bool

Run each pass. Both on by default.

True
sort bool

Run each pass. Both on by default.

True
start (float, float)

Where the pen begins, for the sort.

(0.0, 0.0)

Returns:

Type Description
Design

The same ink in the same colors, drawn in a better order. Loose points and metadata are carried through untouched -- a dot is a dot wherever it is in the list.

Notes

Both passes are O(n²) in the number of strokes, which is nothing at the thousands a plotted design runs to and would matter at a million. Neither is optimal, and neither is trying to be: this is nearest-neighbour applied to the travelling salesman, which carries no bound on how far from the best answer it lands but is what vpype linesort does too. The suite measures the two against each other rather than taking either on trust.

Source code in src/geomotif/io/plotter.py
def optimize(
    design: Design,
    *,
    tolerance: float = 0.1,
    merge: bool = True,
    sort: bool = True,
    start: Point = (0.0, 0.0),
) -> Design:
    """Return ``design`` with its strokes joined up and put in a sensible order.

    Two passes, both of which only ever move whole strokes about:

    1. **Merge.** Two open strokes whose ends meet within ``tolerance`` become
       one, reversing either if that is what makes them meet. A stroke whose
       own ends then meet is closed. This is where a tiling built cell by cell
       stops being four thousand separate edges.
    2. **Sort.** The strokes are ordered greedily from ``start``, always taking
       whichever begins nearest to where the pen just finished, and reversing
       an open stroke when its far end is the nearer one.

    Neither pass crosses a layer, for the reason this module opens with, and
    layers come out in the order they went in.

    Parameters
    ----------
    design : Design
        What to optimize.
    tolerance : float
        How close two ends must be to count as touching, in the design's own
        units. Must be >= 0; zero demands exactly equal coordinates.
    merge, sort : bool
        Run each pass. Both on by default.
    start : (float, float)
        Where the pen begins, for the sort.

    Returns
    -------
    Design
        The same ink in the same colors, drawn in a better order. Loose
        points and metadata are carried through untouched -- a dot is a dot
        wherever it is in the list.

    Notes
    -----
    Both passes are O(n²) in the number of strokes, which is nothing at the
    thousands a plotted design runs to and would matter at a million. Neither
    is optimal, and neither is trying to be: this is nearest-neighbour applied
    to the travelling salesman, which carries no bound on how far from the best
    answer it lands but is what ``vpype linesort`` does too. The suite measures
    the two against each other rather than taking either on trust.
    """
    if tolerance < 0:
        raise ValueError(f"tolerance must be >= 0, got {tolerance}")

    ordered: list[_Stroke] = []
    pen = start
    for part in by_layer(design).values():
        strokes = [
            _Stroke(list(path.points), path.closed, style)
            for path, style in zip(part.paths, styles_of(part), strict=True)
            if path.points
        ]
        if merge:
            strokes = _merged(strokes, tolerance)
        if sort:
            strokes, pen = _ordered(strokes, pen)
        ordered.extend(strokes)

    return _assembled(ordered, design)

to_vpype

to_vpype(design: Design, *, paper: str = 'a4', margin: float = 10.0, landscape: bool = False) -> Any

Return this design as a vpype document, fitted to a page.

vpype is the pen-plotter toolchain -- occlusion, hatching, HPGL output, and a plotting pipeline this library is deliberately not trying to be. This is the door to it: one layer per :mod:geomotif.core.style layer, named the same, on a page of the size asked for.

::

import vpype_cli

document = to_vpype(design, paper="a3")
vpype_cli.execute("linemerge linesort write out.svg", document)

Returns:

Type Description
Document

Typed loosely because vpype is not a dependency and this module must stay importable without it.

Raises:

Type Description
ImportError

If vpype is not installed, with the command that installs it.

Source code in src/geomotif/io/plotter.py
def to_vpype(
    design: Design,
    *,
    paper: str = "a4",
    margin: float = 10.0,
    landscape: bool = False,
) -> Any:
    """Return this design as a ``vpype`` document, fitted to a page.

    ``vpype`` is the pen-plotter toolchain -- occlusion, hatching, HPGL output,
    and a plotting pipeline this library is deliberately not trying to be. This
    is the door to it: one layer per :mod:`geomotif.core.style` layer, named the
    same, on a page of the size asked for.

    ::

        import vpype_cli

        document = to_vpype(design, paper="a3")
        vpype_cli.execute("linemerge linesort write out.svg", document)

    Returns
    -------
    vpype.Document
        Typed loosely because ``vpype`` is not a dependency and this module
        must stay importable without it.

    Raises
    ------
    ImportError
        If ``vpype`` is not installed, with the command that installs it.
    """
    try:
        import vpype
    except ImportError:
        raise ImportError(
            "geomotif.io.plotter.to_vpype requires vpype. Install it with: pip install vpype"
        ) from None

    width, height = page_size(paper, landscape=landscape)
    placed = on_page(design, paper=paper, margin=margin, landscape=landscape)
    # vpype measures everything in CSS pixels, so millimeters have to be said
    # in its units rather than assumed to be its units.
    per_mm = vpype.convert_length("1mm")

    document = vpype.Document()
    document.page_size = (width * per_mm, height * per_mm)
    for index, (name, part) in enumerate(by_layer(placed).items(), start=1):
        lines = vpype.LineCollection(
            [
                [complex(x * per_mm, y * per_mm) for x, y in _closed_points(path)]
                for path in part.paths
                if len(path.points) > 1
            ]
        )
        document.add(lines, layer_id=index)
        if name is not None:
            document.layers[index].set_property(vpype.METADATA_FIELD_NAME, name)
    return document