Skip to content

Making Art

ElkPlot assumes that you are creating your plotter art using the Shapely Python library. This documentation assumes that you are already comfortable/familiar with Shapely. ElkPlot primarily deals with LineString, MultiLineString, and GeometryCollection, so you should try to compose your art in terms of these types.

  • A LineString will be plotted as a single stroke of the pen - the plotter will go to the first coordinate in the LineString, put the pen down, and then travel to each subsequent coordinate until it reaches the end of the LineString at which point the pen will be lifted again. (A LinearRing will be plotted as though it were a LineString with the first coordinate duplicated at the end.)
  • A MultiLineString will be plotted as multiple discrete strokes. The plotter draws each LineString contained within in the order that they are given, lifting the pen between each LineString in order to travel to the next.
  • A GeometryCollection will be treated as multiple passes with multiple pens. If you have a drawing that uses two colors, you should compose a MultiLineString containing all the lines to be drawn in the first color, and a second MultiLineString containing all the lines to be drawn in the second color. Then create a GeometryCollection containing both.
import shapely

first_color = shapely.MultiLineString([...])
second_color = shapely.MultiLineString([...])
drawing = shapely.GeometryCollection([first_color, second_color])

Helpful Functions for Creating Plottable Shapely Geometry

The following functions can be imported directly from elkplot and help manipulate Shapely geometry in specific ways that are useful for plotting.

center(drawing, width, height, x=0, y=0)

Return a copy of a drawing that has been translated (but not scaled) to the center point of a given rectangle Args: drawing: The drawing to translate width: The width of the rectangle in inches (or any other unit if you pass in a pint.Quantity.) height: The height of the rectangle in inches (or any other unit if you pass in a pint.Quantity.) x: x-coordinate of the upper-left corner of the rectangle in inches (or any other unit if you pass in a pint.Quantity.) y: y-coordinate of the upper-left corner of the rectangle in inches (or any other unit if you pass in a pint.Quantity.)

Returns:

Type Description
Geometry

A copy of the drawing having been translated to the center of the rectangle

Source code in elkplot/shape_utils.py
@UNITS.wraps(None, (None, "inch", "inch", "inch", "inch"), False)
def center(
    drawing: shapely.Geometry,
    width: float,
    height: float,
    x: float = 0,
    y: float = 0,
) -> shapely.Geometry:
    """
    Return a copy of a drawing that has been translated (but not scaled) to the center point of a given rectangle
    Args:
        drawing: The drawing to translate
        width: The width of the rectangle in inches (or any other unit if you pass in a `pint.Quantity`.)
        height: The height of the rectangle in inches (or any other unit if you pass in a `pint.Quantity`.)
        x: x-coordinate of the upper-left corner of the rectangle in inches (or any other unit if you pass in a `pint.Quantity`.)
        y: y-coordinate of the upper-left corner of the rectangle in inches (or any other unit if you pass in a `pint.Quantity`.)

    Returns:
        A copy of the drawing having been translated to the center of the rectangle
    """
    x_min, y_min, x_max, y_max = drawing.bounds
    center_point = shapely.Point((x_min + x_max) / 2, (y_min + y_max) / 2)
    dx, dy = x + width / 2 - center_point.x, y + height / 2 - center_point.y
    return affinity.translate(drawing, dx, dy)

flatten_geometry(geom)

Given any arbitrary shapely Geometry, flattens it down to a single MultiLineString that will be rendered as a single color-pass if sent to the plotter. Also converts Polygons to their outlines - if you want to render a filled in Polygon, use the shade function. Args: geom: The geometry to be flattened down. Most often this will be a GeometryCollection or a MultiPolygon.

Returns:

Type Description
MultiLineString

The flattened geometry

Source code in elkplot/shape_utils.py
def flatten_geometry(geom: shapely.Geometry) -> shapely.MultiLineString:
    """
    Given any arbitrary shapely Geometry, flattens it down to a single MultiLineString that will be rendered as a
    single color-pass if sent to the plotter. Also converts Polygons to their outlines - if you want to render a filled
    in Polygon, use the `shade` function.
    Args:
        geom: The geometry to be flattened down. Most often this will be a GeometryCollection or a MultiPolygon.

    Returns:
        The flattened geometry
    """
    if isinstance(geom, shapely.MultiLineString):
        return geom
    if isinstance(geom, (shapely.LineString, shapely.LinearRing)):
        return shapely.multilinestrings([geom])
    elif isinstance(geom, shapely.Polygon):
        shapes = [geom.exterior] + list(geom.interiors)
        return shapely.union_all([flatten_geometry(shape) for shape in shapes])
    elif isinstance(geom, (shapely.GeometryCollection, shapely.MultiPolygon)):
        parts = [flatten_geometry(sub_geom) for sub_geom in shapely.get_parts(geom)]
        return shapely.union_all(parts)
    return shapely.MultiLineString()

layer_wise_merge(*drawings)

Combines two or more multi-layer drawings while keeping the layers separate. That is, creates a new drawing where the first layer is the union of all the input drawings' first layers, and the second layer is the union of all the input drawings' second layers, and so on. Args: *drawings: All the drawings to be merged

Returns:

Type Description
GeometryCollection

The merged drawing

Source code in elkplot/shape_utils.py
def layer_wise_merge(
    *drawings: shapely.GeometryCollection,
) -> shapely.GeometryCollection:
    """
    Combines two or more multi-layer drawings while keeping the layers separate. That is, creates a new drawing where
    the first layer is the union of all the input drawings' first layers, and the second layer is the union of all the
    input drawings' second layers, and so on.
    Args:
        *drawings: All the drawings to be merged

    Returns:
        The merged drawing
    """
    layers = []
    for drawing in drawings:
        for i, layer in enumerate(shapely.get_parts(drawing)):
            if i >= len(layers):
                layers.append([])
            layers[i].append(layer)
    return shapely.GeometryCollection([shapely.union_all(layer) for layer in layers])

metrics(drawing)

Calculate the pen down distance, pen up distance, and number of discrete paths (requiring penlifts between) in a given drawing. Args: drawing:

Returns:

Type Description
DrawingMetrics

A DrawingMetrics object containing fields for pen_down_dist, pen_up_dist, and path_count

Source code in elkplot/shape_utils.py
def metrics(drawing: shapely.Geometry) -> DrawingMetrics:
    """
    Calculate the pen down distance, pen up distance, and number of discrete paths (requiring penlifts between) in a
    given drawing.
    Args:
        drawing:

    Returns:
        A `DrawingMetrics` object containing fields for `pen_down_dist`, `pen_up_dist`, and `path_count`

    """
    if isinstance(drawing, shapely.GeometryCollection):
        out = DrawingMetrics(0 * UNITS.inch, 0 * UNITS.inch, 0)
        for layer in shapely.get_parts(drawing):
            out += metrics(flatten_geometry(layer))
        return out
    else:
        return DrawingMetrics(
            drawing.length * UNITS.inch,
            up_length(drawing),
            shapely.get_num_geometries(drawing),
        )

optimize(geometry, tolerance=0, sort=True, reloop=True, delete_small=True, pbar=True)

Optimize a shapely geometry for plotting by combining paths, re-ordering paths, and/or deleting short paths. Always merges paths whose ends are closer together than a given tolerance. Can also randomize the starting point for closed loops to help hide the dots that appear at the moment the pen hits the page. Args: geometry: The shapely geometry to be optimized. Usually this is either a MultiLineString or a GeometryCollection depending on if you are optimizing a single layer or a multi-layer plot. tolerance: The largest gap that should be merged/the longest line that should be deleted in inches (or any other unit if you pass in a pint.Quantity.) sort: Should the paths be re-ordered to minimize pen-up travel distance? reloop: Should closed loop paths have their starting point randomized? delete_small: Should paths shorter than tolerance be deleted? pbar: Should progress bars be displayed to keep the user updated on the progress of the process?

Returns:

Type Description
Geometry

The optimized geometry

Source code in elkplot/shape_utils.py
@UNITS.wraps(None, (None, "inch", None, None, None, None), False)
def optimize(
    geometry: shapely.Geometry,
    tolerance: float = 0,
    sort: bool = True,
    reloop: bool = True,
    delete_small: bool = True,
    pbar: bool = True,
) -> shapely.Geometry:
    """
    Optimize a shapely geometry for plotting by combining paths, re-ordering paths, and/or deleting short paths.
    Always merges paths whose ends are closer together than a given tolerance.
    Can also randomize the starting point for closed loops to help hide the dots that appear at the moment the pen hits
    the page.
    Args:
        geometry: The shapely geometry to be optimized. Usually this is either a `MultiLineString` or a
            `GeometryCollection` depending on if you are optimizing a single layer or a multi-layer plot.
        tolerance: The largest gap that should be merged/the longest line that should be deleted in inches (or any other unit if you pass in a `pint.Quantity`.)
        sort: Should the paths be re-ordered to minimize pen-up travel distance?
        reloop: Should closed loop paths have their starting point randomized?
        delete_small: Should paths shorter than `tolerance` be deleted?
        pbar: Should progress bars be displayed to keep the user updated on the progress of the process?

    Returns:
        The optimized geometry

    """
    if reloop:
        geometry = _reloop_paths(geometry)
    geometry = _join_paths(geometry, tolerance, pbar)
    if delete_small:
        geometry = _delete_short_paths(geometry, tolerance, pbar)
    if sort:
        geometry = _sort_paths(geometry, pbar)
    return geometry

rotate_and_scale_to_fit(drawing, width, height, padding=0, increment=0.02)

Fits a drawing into a bounding box of a given width and height, but unlike scale_to_fit also rotates the shape to make it take up as much of that area as possible. Also centers the object in that bounding box with the bounding box's upper-left corner at the origin. Args: drawing: The shapely geometry to rescale width: The width of the bounding box in inches (or any other unit if you pass in a pint.Quantity.) height: The height of the bounding box in inches (or any other unit if you pass in a pint.Quantity.) padding: How much space to leave empty on all sides in inches (or any other unit if you pass in a pint.Quantity.) increment: The gap between different rotation angles attempted in radians. (smaller value gives better results, but larger values run faster.)

Returns:

Type Description
Geometry

A copy of the drawing having been rotated, rescaled, and moved such that the new upper-left corner of the bounding box (including the padding) is at the origin

Source code in elkplot/shape_utils.py
@UNITS.wraps(None, (None, "inch", "inch", "inch", "rad"), False)
def rotate_and_scale_to_fit(
    drawing: shapely.Geometry,
    width: float,
    height: float,
    padding: float = 0,
    increment: float = 0.02,
) -> shapely.Geometry:
    """
    Fits a drawing into a bounding box of a given width and height, but unlike `scale_to_fit` also rotates the shape to
    make it take up as much of that area as possible. Also centers the object in that bounding box
    with the bounding box's upper-left corner at the origin.
    Args:
        drawing: The shapely geometry to rescale
        width: The width of the bounding box in inches (or any other unit if you pass in a `pint.Quantity`.)
        height: The height of the bounding box in inches (or any other unit if you pass in a `pint.Quantity`.)
        padding: How much space to leave empty on all sides in inches (or any other unit if you pass in a `pint.Quantity`.)
        increment: The gap between different rotation angles attempted in radians. (smaller value gives better results,
            but larger values run faster.)

    Returns:
        A copy of the drawing having been rotated, rescaled, and moved such that the new upper-left corner of the
            bounding box (including the padding) is at the origin

    """
    best_geom: shapely.Geometry = drawing
    biggest = 0
    for angle in np.arange(0, np.pi, increment):
        rotated = affinity.rotate(drawing, angle, use_radians=True)
        scaled = scale_to_fit(rotated, width, height, padding)
        w, h = size(scaled)
        area = w * h
        if area > biggest:
            best_geom = scaled
            biggest = area
    return best_geom

scale_to_fit(drawing, width=0, height=0, padding=0)

Scales a drawing up or down to perfectly fit into a given bounding box. Also centers the object in that bounding box with the bounding box's upper-left corner at the origin. Args: drawing: The shapely geometry to rescale width: The width of the bounding box in inches (or any other unit if you pass in a pint.Quantity.) If this is 0, the drawing will be scaled to fit into the given height with arbitrary width. height: The height of the bounding box in inches (or any other unit if you pass in a pint.Quantity.) If this is 0, the drawing will be scaled to fit into the given width with arbitrary height. padding: How much space to leave empty on all sides in inches (or any other unit if you pass in a pint.Quantity.)

Returns:

Type Description
Geometry

A copy of the drawing having been rescaled and moved such that the new upper-left corner of the bounding

Geometry

box (including the padding) is at the origin

Source code in elkplot/shape_utils.py
@UNITS.wraps(None, (None, "inch", "inch", "inch"), False)
def scale_to_fit(
    drawing: shapely.Geometry,
    width: float = 0,
    height: float = 0,
    padding: float = 0,
) -> shapely.Geometry:
    """
    Scales a drawing up or down to perfectly fit into a given bounding box. Also centers the object in that bounding box
    with the bounding box's upper-left corner at the origin.
    Args:
        drawing: The shapely geometry to rescale
        width: The width of the bounding box in inches (or any other unit if you pass in a `pint.Quantity`.)
            If this is 0, the drawing will be scaled to fit into the given height with arbitrary width.
        height: The height of the bounding box in inches (or any other unit if you pass in a `pint.Quantity`.)
            If this is 0, the drawing will be scaled to fit into the given width with arbitrary height.
        padding: How much space to leave empty on all sides in inches (or any other unit if you pass in a
            `pint.Quantity`.)

    Returns:
        A copy of the drawing having been rescaled and moved such that the new upper-left corner of the bounding
        box (including the padding) is at the origin

    """
    w, h = (dim.magnitude for dim in size(drawing))
    if w == 0 or width == 0:
        scale = (height - padding * 2) / h
    elif h == 0 or height == 0:
        scale = (width - padding * 2) / w
    else:
        scale = min((width - padding * 2) / w, (height - padding * 2) / h)
    return center(affinity.scale(drawing, scale, scale), width, height)

shade(polygon, angle, spacing, offset=0.5)

Fill in a shapely Polygon or MultiPolygon with parallel lines so that the plotter will fill in the shape with lines. Args: polygon: The shape to be filled in angle: The angle at which the parallel lines should travel in radians (or any other unit if you pass in a pint.Quantity.) spacing: The gap between parallel lines in inches (or any other unit if you pass in a pint.Quantity.) offset: How much should the parallel lines be shifted up or down as a percentage of the spacing?

Returns:

Type Description
MultiLineString

The MultiLineString of the shaded lines. (NOTE: Does not include the outline.)

Source code in elkplot/shape_utils.py
@UNITS.wraps(None, (None, "rad", "inch", None), False)
def shade(
    polygon: shapely.Polygon | shapely.MultiPolygon,
    angle: float,
    spacing: float,
    offset: float = 0.5,
) -> shapely.MultiLineString:
    """
    Fill in a shapely Polygon or MultiPolygon with parallel lines so that the plotter will fill in the shape with lines.
    Args:
        polygon: The shape to be filled in
        angle: The angle at which the parallel lines should travel in radians (or any other unit if you pass in a `pint.Quantity`.)
        spacing: The gap between parallel lines in inches (or any other unit if you pass in a `pint.Quantity`.)
        offset: How much should the parallel lines be shifted up or down as a percentage of the spacing?

    Returns:
        The MultiLineString of the shaded lines. (NOTE: Does not include the outline.)

    """
    polygon = affinity.rotate(
        polygon, -angle, use_radians=True, origin=polygon.centroid
    )
    x0, y0, x1, y1 = polygon.bounds
    shading = shapely.MultiLineString(
        [[(x0, y), (x1, y)] for y in np.arange(y0 + offset * spacing, y1, spacing)]
    )
    shading = polygon.intersection(shading)
    return affinity.rotate(shading, angle, use_radians=True, origin=polygon.centroid)

size(geom)

Calculate the width and height of the bounding box of a shapely geometry. Args: geom: The shapely Geometry object to be measured

Returns:

Type Description
Quantity

width in inches

Quantity

height in inches

Source code in elkplot/shape_utils.py
def size(geom: shapely.Geometry) -> tuple[pint.Quantity, pint.Quantity]:
    """
    Calculate the width and height of the bounding box of a shapely geometry.
    Args:
        geom: The shapely Geometry object to be measured

    Returns:
        width in inches
        height in inches

    """
    x_min, y_min, x_max, y_max = geom.bounds
    return (x_max - x_min) * UNITS.inch, (y_max - y_min) * UNITS.inch

up_length(drawing)

Calculate the total distance travelled by the pen while not in contact with the page. This can be improved by merging and/or reordering the paths using the optimize function. Args: drawing: A single layer of plotter art

Returns:

Type Description
Quantity

The total pen-up distance in inches

Source code in elkplot/shape_utils.py
def up_length(drawing: shapely.MultiLineString) -> pint.Quantity:
    """
    Calculate the total distance travelled by the pen while not in contact with the page.
    This can be improved by merging and/or reordering the paths using the `optimize` function.
    Args:
        drawing: A single layer of plotter art

    Returns:
        The total pen-up distance in inches

    """
    distance = 0
    origin = shapely.points((0, 0))
    pen_position = origin
    for path in shapely.get_parts(drawing):
        path_start, path_end = shapely.points(path.coords[0]), shapely.points(
            path.coords[-1]
        )
        distance += shapely.distance(pen_position, path_start)
        pen_position = path_end
    return distance * UNITS.inch