Skip to content

Plotting and Previewing

Most of the time, plotting is as simple as sending an appropriate shapely object to the draw function. See Making Art for more on how to create appropriate shapely geometry, but you are strongly encouraged to call scale_to_fit() and optimize() before plotting to make sure that your plot is the correct size for your page and the lines will be drawn in an order that doesn't waste a bunch of time with the pen in the air.

draw(drawing, width=elkplot.sizes.A3[0], height=elkplot.sizes.A3[1], layer_labels=None, preview=True, preview_dpi=128, plot=True, retrace=1, device=None)

Visualize and/or plot a given drawing. Automatically pauses the plotter between layers to allow for changing pens. Geometry can be given as a GeometryCollection (which will be treated as a multi-pen drawing), a list of geometries (in which case again, each will be treated as a separate pen), or any other shapely Geometry (which will be treated as a single layer.

Parameters:

Name Type Description Default
drawing Geometry | list[Geometry]

The shapely geometry to plot.

required
width float

The width of the page in inches (or any other unit if you pass in a pint.Quantity.) Used only for the preview.

A3[0]
height float

The height of the page in inches (or any other unit if you pass in a pint.Quantity.) Used only for the preview.

A3[1]
layer_labels Optional[list[str]]

An ordered list of labels for each pen-layer. Used only to remind you what pen you should use when swapping pens between layers. If excluded, layers will just be numbered.

None
preview bool

Should an on-screen preview of the plot be displayed?

True
preview_dpi float

How big should the preview be? (Enter the DPI of your monitor to get an actual-size preview.)

128
plot bool

Should the AxiDraw actually plot this? (If preview is True, plotting will only begin after the preview window is closed.)

True
retrace int

How many times should the AxiDraw draw each line? If this is set to 2, it will draw a whole layer, then draw that layer a second time, then either finish or prompt you to change pens.

1
device Optional[Device]

The AxiDraw config to which the plot should be sent. If excluded, a Device with all default settings will be used.

None
Source code in elkplot/util.py
@elkplot.UNITS.wraps(
    None, (None, "inch", "inch", None, None, None, None, None, None), False
)
def draw(
    drawing: shapely.Geometry | list[shapely.Geometry],
    width: float = elkplot.sizes.A3[0],
    height: float = elkplot.sizes.A3[1],
    layer_labels: Optional[list[str]] = None,
    preview: bool = True,
    preview_dpi: float = 128,
    plot: bool = True,
    retrace: int = 1,
    device: Optional[elkplot.Device] = None,
) -> None:
    """
    Visualize and/or plot a given drawing. Automatically pauses the plotter between layers to allow for changing pens.
    Geometry can be given as a GeometryCollection (which will be treated as a multi-pen drawing), a list of geometries
    (in which case again, each will be treated as a separate pen), or any other shapely Geometry (which will be treated
    as a single layer.

    Args:
        drawing: The shapely geometry to plot.
        width: The width of the page in inches (or any other unit if you pass in a `pint.Quantity`.) Used only for the
            preview.
        height: The height of the page in inches (or any other unit if you pass in a `pint.Quantity`.) Used only for the
            preview.
        layer_labels: An ordered list of labels for each pen-layer. Used only to remind you what pen you should use when
            swapping pens between layers. If excluded, layers will just be numbered.
        preview: Should an on-screen preview of the plot be displayed?
        preview_dpi: How big should the preview be? (Enter the DPI of your monitor to get an actual-size preview.)
        plot: Should the AxiDraw actually plot this? (If `preview` is `True`, plotting will only begin after the preview
            window is closed.)
        retrace: How many times should the AxiDraw draw each line? If this is set to 2, it will draw a whole layer, then
            draw that layer a second time, then either finish or prompt you to change pens.
        device: The AxiDraw config to which the plot should be sent. If excluded, a `Device` with all default settings
            will be used.
    """
    if isinstance(drawing, shapely.GeometryCollection):
        layers = [
            elkplot.flatten_geometry(layer) for layer in shapely.get_parts(drawing)
        ]
    elif isinstance(drawing, list):
        layers = [elkplot.flatten_geometry(layer) for layer in drawing]
    else:
        layers = [elkplot.flatten_geometry(drawing)]
    if layer_labels is None:
        layer_labels = [f"Layer #{i}" for i in range(len(layers))]
    else:
        assert len(layer_labels) == len(layers)

    min_x = min([layer.bounds[0] for layer in layers])
    min_y = min([layer.bounds[1] for layer in layers])
    max_x = max([layer.bounds[2] for layer in layers])
    max_y = max([layer.bounds[3] for layer in layers])
    out_of_bounds = min_x < 0 or min_y < 0 or max_x > width or max_y > height
    if out_of_bounds:
        warnings.warn("THIS DRAWING GOES OUT OF BOUNDS!")

    if preview:
        elkplot.render(layers, width, height, preview_dpi)
    if not plot:
        return
    min_x = min([layer.bounds[0] for layer in layers])
    min_y = min([layer.bounds[1] for layer in layers])
    max_x = max([layer.bounds[2] for layer in layers])
    max_y = max([layer.bounds[3] for layer in layers])
    if out_of_bounds:
        raise DrawingOutOfBoundsError(
            f"Drawing has bounds ({min_x}, {min_y}) to ({max_x}, {max_y}), which extends outside the plottable bounds (0, 0) to ({width}, {height})"
        )
    if not elkplot.device.axidraw_available():
        raise AxidrawNotFoundError()
    device = elkplot.Device() if device is None else device
    device.zero_position()
    device.enable_motors()
    for layer, label in zip(layers, layer_labels):
        input(f"Press enter when you're ready to draw {label}")
        for _ in range(retrace):
            device.run_layer(layer, label)
    device.disable_motors()

Managing the AxiDraw

The draw function above takes an optional Device as input. The device contains all the settings for how the AxiDraw actually goes about the business of plotting. This code was largely borrowed from the axi python library by Michael Fogleman. Generally speaking, I only pass Device objects to the draw function, but it also contains functions for directly controlling the AxiDraw.

A common use case is slowing down the AxiDraw for pens that don't leave behind enough ink at the current speed. That would look something like this:

import shapely
import elkplot

drawing = shapely.MultiLineString([...])
device = elkplot.Device(max_velocity=2)
elkplot.draw(drawing, device=device)

Device

Source code in elkplot/device.py
class Device:
    def __init__(
        self,
        pen_up_position: float = -50,
        pen_down_position: float = -120,
        pen_up_speed: float = 150,
        pen_down_speed: float = 150,
        pen_up_delay: int = 50,
        pen_down_delay: int = 50,
        acceleration: float = 16,
        max_velocity: float = 4,
        corner_factor: float = 0.001,
        jog_acceleration: float = 16,
        jog_max_velocity: float = 8,
        pen_lift_pin: int = 2,
        brushless: bool = True,
    ):
        """
        Construct a Device object that contains all the settings for the AxiDraw itself. The default values are chosen
        based on what works for me and my AxiDraw with the upgraded brushless penlift motor - you may need to change
        these for your AxiDraw.

        Args:
            pen_up_position: To what level should the pen be lifted? (I found this value by trial and error.)
            pen_down_position: To what level should the pen be lowered? (I found this value by trial and error.)
            pen_up_speed: How fast should the pen be lifted?
            pen_down_speed: How fast should the pen be lowered?
            pen_up_delay: How long (in ms) should the AxiDraw wait after starting to raise the pen before taking the
                next action? (Lower is faster, but can lead to unwanted lines being drawn.)
            pen_down_delay: How long (in ms) should the AxiDraw wait after starting to lower the pen before taking the
                next action? (Lower is faster, but can lead to wanted lines not being drawn.)
            acceleration: How aggressively should the AxiDraw accelerate up to `max_velocity`?
            max_velocity: How fast should the AxiDraw move when traveling at top speed?
            corner_factor: What is the radius of the corner when making a sharp turn? Larger values can
                maintain higher speeds around corners, but will round off sharp edges. Smaller values are more accurate
                to the original drawing but have to slow down more at sharp corners.
            jog_acceleration: How aggressively should the AxiDraw accelerate up to `jog_max_velocity` when moving
                while the pen is lifted?
            jog_max_velocity: How fast should the AxiDraw move when traveling at top speed while the pen is lifted?
            pen_lift_pin: To which pin on the driver board is the penlift motor connected? (Pin 0 is the bottom pin.)
            brushless: Is the connected motor the upgraded brushless motor?
        """
        self.timeslice_ms = 10
        self.microstepping_mode = 1  # Maybe this will need to change someday? 🤷‍♀️
        self.step_divider = 2 ** (self.microstepping_mode - 1)
        self.steps_per_unit = 2032 / self.step_divider
        self.steps_per_mm = 80 / self.step_divider
        self.vid_pid = "04d8:fd92"  # ID common to all AxiDraws
        self.pen_lift_pin = pen_lift_pin
        self.brushless = brushless

        self.pen_up_position = pen_up_position
        self.pen_down_position = pen_down_position
        self.pen_up_speed = pen_up_speed
        self.pen_down_speed = pen_down_speed
        self.pen_up_delay = pen_up_delay
        self.pen_down_delay = pen_down_delay
        self.acceleration = acceleration
        self.max_velocity = max_velocity
        self.corner_factor = corner_factor
        self.jog_acceleration = jog_acceleration
        self.jog_max_velocity = jog_max_velocity

        self.error = (0, 0)  # accumulated step error

        port = _find_port()
        if port is None:
            raise IOError("Could not connect to AxiDraw over USB")
        self.serial = Serial(port, timeout=1)
        self._configure()

    def _configure(self):
        servo_max = 12600 if self.brushless else 27831  # Up at "100%" position.
        servo_min = 5400 if not self.brushless else 9855  # Down at "0%" position

        pen_up_position = self.pen_up_position / 100
        pen_up_position = int(servo_min + (servo_max - servo_min) * pen_up_position)
        pen_down_position = self.pen_down_position / 100
        pen_down_position = int(servo_min + (servo_max - servo_min) * pen_down_position)
        self._command("SC", 4, pen_up_position)
        self._command("SC", 5, pen_down_position)
        self._command("SC", 11, int(self.pen_up_speed * 5))
        self._command("SC", 12, int(self.pen_down_speed * 5))

    def close(self):
        """When you create a Device() object, it monopolizes access to that AxiDraw. Call this to free it up so other
        programs can talk to it again."""
        self.serial.close()

    def _make_planner(self, jog: bool = False) -> Planner:
        a = self.acceleration if not jog else self.jog_acceleration
        vmax = self.max_velocity if not jog else self.jog_max_velocity
        cf = self.corner_factor
        return Planner(a, vmax, cf)

    def _readline(self) -> str:
        return self.serial.readline().decode("utf-8").strip()

    def _command(self, *args) -> str:
        line = ",".join(map(str, args))
        self.serial.write((line + "\r").encode("utf-8"))
        return self._readline()

    # higher level functions
    def move(self, dx: float, dy: float):
        """
        Offset the current pen position.
        Args:
            dx: The offset in the x direction in inches
            dy: The offset in the y direction in inches
        """
        self.run_path(shapely.linestrings([(0, 0), (dx, dy)]))

    def goto(self, x: float, y: float, jog: bool = True):
        """
        Move the pen directly to a given point on the canvas. Points are measured in inches from the origin
        Args:
            x: The x-coordinate of the desired point
            y: The y-coordinate of the desired point
            jog: Should it travel at jog-speed or regular?
        """
        # TODO: jog if pen up
        px, py = self.read_position()
        self.run_path(shapely.linestrings([(px, py), (x, y)]), jog=jog)

    def home(self):
        """Send the pen back to (0, 0)"""
        self.goto(0, 0, True)

    # misc commands
    def version(self):
        return self._command("V")

    # motor functions
    def enable_motors(self):
        """Turn the motors on"""
        m = self.microstepping_mode
        return self._command("EM", m, m)

    def disable_motors(self):
        """Turn the motors off"""
        return self._command("EM", 0, 0)

    def motor_status(self):
        return self._command("QM")

    def zero_position(self):
        """Set the current position of the pen as (0, 0). Called automatically when connecting to the device. For best
        results, always start and end with the motor in home position. If necessary, though, you can disable motors,
        manually reset the pen back home, and call this function."""
        return self._command("CS")

    def read_position(self) -> tuple[float, float]:
        """Get the xy coordinates of the pen"""
        response = self._command("QS")
        self._readline()
        a, b = map(float, response.split(","))
        a /= self.steps_per_unit
        b /= self.steps_per_unit
        y = (a - b) / 2
        x = y + b
        return x, y

    def stepper_move(self, duration: float, a, b):
        return self._command("XM", duration, a, b)

    def wait(self):
        while "1" in self.motor_status():
            time.sleep(0.01)

    def run_plan(self, plan: Plan):
        step_s = self.timeslice_ms / 1000
        t = 0
        while t < plan.t:
            i1 = plan.instant(t)
            i2 = plan.instant(t + step_s)
            d = i2.p.sub(i1.p)
            ex, ey = self.error
            ex, sx = modf(d.x * self.steps_per_unit + ex)
            ey, sy = modf(d.y * self.steps_per_unit + ey)
            self.error = ex, ey
            self.stepper_move(self.timeslice_ms, int(sx), int(sy))
            t += step_s
        # self.wait()

    def run_path(self, path: shapely.LineString, draw: bool = False, jog: bool = False):
        planner = self._make_planner(jog)
        plan = planner.plan(list(path.coords))
        if draw:
            self.pen_down()
            self.run_plan(plan)
            self.pen_up()
        else:
            self.run_plan(plan)

    def run_layer(self, layer: shapely.MultiLineString, label: str = None):
        jog_planner = self._make_planner(True)
        draw_planner = self._make_planner(False)
        queue = mp.Queue()
        layer_coord_list = [list(line.coords) for line in shapely.get_parts(layer)]
        p = mp.Process(
            target=plan_layer_proc,
            args=(queue, layer_coord_list, jog_planner, draw_planner),
        )
        p.start()
        bar = tqdm(total=layer.length + elkplot.up_length(layer).m, desc=label)
        idx = 0
        while True:
            jog_plan, length = queue.get()
            if jog_plan == "DONE":
                break
            if idx % 2 == 0:
                self.pen_up()
            else:
                self.pen_down()
            self.run_plan(jog_plan)
            bar.update(length)
            idx += 1
        bar.close()
        self.pen_up()
        self.home()

    # pen functions
    def pen_up(self):
        """Lift the pen"""
        return self._command("SP", 1, self.pen_up_delay, self.pen_lift_pin)

    def pen_down(self):
        """Lower the pen"""
        return self._command("SP", 0, self.pen_down_delay, self.pen_lift_pin)

__init__(pen_up_position=-50, pen_down_position=-120, pen_up_speed=150, pen_down_speed=150, pen_up_delay=50, pen_down_delay=50, acceleration=16, max_velocity=4, corner_factor=0.001, jog_acceleration=16, jog_max_velocity=8, pen_lift_pin=2, brushless=True)

Construct a Device object that contains all the settings for the AxiDraw itself. The default values are chosen based on what works for me and my AxiDraw with the upgraded brushless penlift motor - you may need to change these for your AxiDraw.

Parameters:

Name Type Description Default
pen_up_position float

To what level should the pen be lifted? (I found this value by trial and error.)

-50
pen_down_position float

To what level should the pen be lowered? (I found this value by trial and error.)

-120
pen_up_speed float

How fast should the pen be lifted?

150
pen_down_speed float

How fast should the pen be lowered?

150
pen_up_delay int

How long (in ms) should the AxiDraw wait after starting to raise the pen before taking the next action? (Lower is faster, but can lead to unwanted lines being drawn.)

50
pen_down_delay int

How long (in ms) should the AxiDraw wait after starting to lower the pen before taking the next action? (Lower is faster, but can lead to wanted lines not being drawn.)

50
acceleration float

How aggressively should the AxiDraw accelerate up to max_velocity?

16
max_velocity float

How fast should the AxiDraw move when traveling at top speed?

4
corner_factor float

What is the radius of the corner when making a sharp turn? Larger values can maintain higher speeds around corners, but will round off sharp edges. Smaller values are more accurate to the original drawing but have to slow down more at sharp corners.

0.001
jog_acceleration float

How aggressively should the AxiDraw accelerate up to jog_max_velocity when moving while the pen is lifted?

16
jog_max_velocity float

How fast should the AxiDraw move when traveling at top speed while the pen is lifted?

8
pen_lift_pin int

To which pin on the driver board is the penlift motor connected? (Pin 0 is the bottom pin.)

2
brushless bool

Is the connected motor the upgraded brushless motor?

True
Source code in elkplot/device.py
def __init__(
    self,
    pen_up_position: float = -50,
    pen_down_position: float = -120,
    pen_up_speed: float = 150,
    pen_down_speed: float = 150,
    pen_up_delay: int = 50,
    pen_down_delay: int = 50,
    acceleration: float = 16,
    max_velocity: float = 4,
    corner_factor: float = 0.001,
    jog_acceleration: float = 16,
    jog_max_velocity: float = 8,
    pen_lift_pin: int = 2,
    brushless: bool = True,
):
    """
    Construct a Device object that contains all the settings for the AxiDraw itself. The default values are chosen
    based on what works for me and my AxiDraw with the upgraded brushless penlift motor - you may need to change
    these for your AxiDraw.

    Args:
        pen_up_position: To what level should the pen be lifted? (I found this value by trial and error.)
        pen_down_position: To what level should the pen be lowered? (I found this value by trial and error.)
        pen_up_speed: How fast should the pen be lifted?
        pen_down_speed: How fast should the pen be lowered?
        pen_up_delay: How long (in ms) should the AxiDraw wait after starting to raise the pen before taking the
            next action? (Lower is faster, but can lead to unwanted lines being drawn.)
        pen_down_delay: How long (in ms) should the AxiDraw wait after starting to lower the pen before taking the
            next action? (Lower is faster, but can lead to wanted lines not being drawn.)
        acceleration: How aggressively should the AxiDraw accelerate up to `max_velocity`?
        max_velocity: How fast should the AxiDraw move when traveling at top speed?
        corner_factor: What is the radius of the corner when making a sharp turn? Larger values can
            maintain higher speeds around corners, but will round off sharp edges. Smaller values are more accurate
            to the original drawing but have to slow down more at sharp corners.
        jog_acceleration: How aggressively should the AxiDraw accelerate up to `jog_max_velocity` when moving
            while the pen is lifted?
        jog_max_velocity: How fast should the AxiDraw move when traveling at top speed while the pen is lifted?
        pen_lift_pin: To which pin on the driver board is the penlift motor connected? (Pin 0 is the bottom pin.)
        brushless: Is the connected motor the upgraded brushless motor?
    """
    self.timeslice_ms = 10
    self.microstepping_mode = 1  # Maybe this will need to change someday? 🤷‍♀️
    self.step_divider = 2 ** (self.microstepping_mode - 1)
    self.steps_per_unit = 2032 / self.step_divider
    self.steps_per_mm = 80 / self.step_divider
    self.vid_pid = "04d8:fd92"  # ID common to all AxiDraws
    self.pen_lift_pin = pen_lift_pin
    self.brushless = brushless

    self.pen_up_position = pen_up_position
    self.pen_down_position = pen_down_position
    self.pen_up_speed = pen_up_speed
    self.pen_down_speed = pen_down_speed
    self.pen_up_delay = pen_up_delay
    self.pen_down_delay = pen_down_delay
    self.acceleration = acceleration
    self.max_velocity = max_velocity
    self.corner_factor = corner_factor
    self.jog_acceleration = jog_acceleration
    self.jog_max_velocity = jog_max_velocity

    self.error = (0, 0)  # accumulated step error

    port = _find_port()
    if port is None:
        raise IOError("Could not connect to AxiDraw over USB")
    self.serial = Serial(port, timeout=1)
    self._configure()

close()

When you create a Device() object, it monopolizes access to that AxiDraw. Call this to free it up so other programs can talk to it again.

Source code in elkplot/device.py
def close(self):
    """When you create a Device() object, it monopolizes access to that AxiDraw. Call this to free it up so other
    programs can talk to it again."""
    self.serial.close()

disable_motors()

Turn the motors off

Source code in elkplot/device.py
def disable_motors(self):
    """Turn the motors off"""
    return self._command("EM", 0, 0)

enable_motors()

Turn the motors on

Source code in elkplot/device.py
def enable_motors(self):
    """Turn the motors on"""
    m = self.microstepping_mode
    return self._command("EM", m, m)

goto(x, y, jog=True)

Move the pen directly to a given point on the canvas. Points are measured in inches from the origin Args: x: The x-coordinate of the desired point y: The y-coordinate of the desired point jog: Should it travel at jog-speed or regular?

Source code in elkplot/device.py
def goto(self, x: float, y: float, jog: bool = True):
    """
    Move the pen directly to a given point on the canvas. Points are measured in inches from the origin
    Args:
        x: The x-coordinate of the desired point
        y: The y-coordinate of the desired point
        jog: Should it travel at jog-speed or regular?
    """
    # TODO: jog if pen up
    px, py = self.read_position()
    self.run_path(shapely.linestrings([(px, py), (x, y)]), jog=jog)

home()

Send the pen back to (0, 0)

Source code in elkplot/device.py
def home(self):
    """Send the pen back to (0, 0)"""
    self.goto(0, 0, True)

move(dx, dy)

Offset the current pen position. Args: dx: The offset in the x direction in inches dy: The offset in the y direction in inches

Source code in elkplot/device.py
def move(self, dx: float, dy: float):
    """
    Offset the current pen position.
    Args:
        dx: The offset in the x direction in inches
        dy: The offset in the y direction in inches
    """
    self.run_path(shapely.linestrings([(0, 0), (dx, dy)]))

pen_down()

Lower the pen

Source code in elkplot/device.py
def pen_down(self):
    """Lower the pen"""
    return self._command("SP", 0, self.pen_down_delay, self.pen_lift_pin)

pen_up()

Lift the pen

Source code in elkplot/device.py
def pen_up(self):
    """Lift the pen"""
    return self._command("SP", 1, self.pen_up_delay, self.pen_lift_pin)

read_position()

Get the xy coordinates of the pen

Source code in elkplot/device.py
def read_position(self) -> tuple[float, float]:
    """Get the xy coordinates of the pen"""
    response = self._command("QS")
    self._readline()
    a, b = map(float, response.split(","))
    a /= self.steps_per_unit
    b /= self.steps_per_unit
    y = (a - b) / 2
    x = y + b
    return x, y

zero_position()

Set the current position of the pen as (0, 0). Called automatically when connecting to the device. For best results, always start and end with the motor in home position. If necessary, though, you can disable motors, manually reset the pen back home, and call this function.

Source code in elkplot/device.py
def zero_position(self):
    """Set the current position of the pen as (0, 0). Called automatically when connecting to the device. For best
    results, always start and end with the motor in home position. If necessary, though, you can disable motors,
    manually reset the pen back home, and call this function."""
    return self._command("CS")