In 2022 I decided to send holiday cards to friends and family. The easy way to do this would have been to go to the store and buy a few boxes of season appropriate cards. This of course makes sense, is inexpensive, and super easy. Of course this didn't happen...
Setting the Scene
Professionally I spend a lot of time working on projects where there are few tangible outputs, and even fewer where there are visually interesting outputs that I can share with anyone. For these cards I decided that they should be computer generated, and should be visually interesting. Computer generated doesn't really narrow things down, so I decided to merge hobbies and have a robot draw the cards. In this case the robot in question was a CNC engraver that's been modified to serve as a janky pen plotter.
The spindle of the engraver was swapped out for a pen holder that was made from parts of the world's poorest quality bench grinder.
On the software front the CNC machine is running GRBL and is controlled via LaserWeb4 running on a Raspberry Pi.
Coding the Card
The card needed to capture the holiday spirit, and what better way to do that than have some pointy trees? The trees were generated by identifying points along the base of the tree and the trunk, then drawing a line from one side the base to the trunk and back to the other side of the trunk. Numpy for most of the calculations and Matplotlib was used to render the card during development.
The tree is fairly simple, generating a pleasing design:
def string_art(width: float = 1, height: float = 1,
n_steps: float = 20,
base_x: float = 0., base_y: float = 0.,
rotation=0) -> tuple[list[list[float]], list[list[float]]]:
"""
Construct a string art tree.
The height of the tree is measured from the top of the trunk, with the
trunk extending 0.1 * height below the tree.
The output is a two-element tuple with the first element containing a
list of lists of the x positions for the drawing segments. The second
element is the positions.
:param width: The width of the tree in millimeters
:param height: The height of the tree in millimeters above the trunk.
:param n_steps: The number of lines making up the tree.
:param base_x: The x location of the tree's origin.
:param base_y: The y location of the tree's origin.
:param rotation: The rotation of the tree in radians.
:return: The line segments representing the tree.
"""
segments_x = []
segments_y = []
thetas = list(np.linspace(0, np.pi / 2, num=n_steps, endpoint=True))
# Construct the base tree lines
# The drawing alternates direction. This reduces motion in the final drawing.
even = True
for theta in thetas:
if even:
segments_x.append([np.cos(theta), 0, -np.cos(theta)])
else:
segments_x.append([-np.cos(theta), 0, np.cos(theta)])
even = not even
segments_y.append([0, np.sin(theta), 0])
# The Trunk
trunk_depth = -0.1
trunk_width = 0.1
segments_x.append([-trunk_width, -trunk_width, trunk_width])
segments_y.append([0, trunk_depth, trunk_depth])
segments_x.append([trunk_width, trunk_width, -trunk_width])
segments_y.append([trunk_depth, 0, 0])
return transform_strokes(segments_x, segments_y,
width=width / 2.0, height=height,
base_x=base_x, base_y=base_y,
rotation=rotation)
Feeding the tree generator a range of values for n_steps
makes a family of trees.
The other big element required for a card is words. In this case we can take a look at the history of technology, and we'll come across Hershey Fonts described in DTIC AD0662398: CALLIGRAPHY FOR COMPUTERS by Dr. Allen Vincent Hershey. While the Hershey fonts were originally aimed at vector cathode ray tube displays, they're well-suited for other vector display technologies. Since the plotter draws straight line segments it's really just a vector display so we can use these fonts.
Putting the string art and fonts together we can start to compose a card. The planned card is 114 millimeters wide by 152 millimeters tall, since that fits nicely into a 4x6 inch envelope. The code that generates the outside of the card is:
def make_outside_of_card(script_font, block_font):
# pylint: disable=too-many-locals
"""
Generate the outside of the card.
:param script_font: The font definition for the script font
:param block_font: The font definition for the block font.
:return: The segments that define the card
"""
x_segments = []
y_segments = []
# Front
trees = ((100, 90, 45, 171, 46), # width, height,n_lines, base_x, base_y
(20, 18, 9, 140, 114),
(10, 12, 5, 210, 95),
(10, 25, 13, 130, 58),
)
for tree in trees:
tree_x, tree_y = string_art(*tree)
x_segments.extend(tree_x)
y_segments.extend(tree_y)
x_message, y_message = construct_message("Happy Holidays", script_font, center=True)
x_message, y_message = transform_message(x_message, y_message, height=15, base_x=171, base_y=30)
x_segments.extend(x_message)
y_segments.extend(y_message)
x_message, y_message = construct_message("202012", script_font, center=True)
x_bbox, y_bbox = construct_letter_bboxes("202012", script_font, center=True)
for cross_out in (3, 4):
x_squiggle, y_squiggle = cross_out_bbox(x_bbox[cross_out], y_bbox[cross_out], cycles=3)
x_message.extend(x_squiggle)
y_message.extend(y_squiggle)
x_message, y_message = transform_message(x_message, y_message, height=15, base_x=171, base_y=15)
x_segments.extend(x_message)
y_segments.extend(y_message)
# Back
x_url, y_url = construct_message("github.com/palbee/holiday_card_2022", block_font, center=True)
x_url, y_url = transform_message(x_url, y_url, height=6, base_x=105, base_y=76,
rotation=np.deg2rad(90))
x_segments.extend(x_url)
y_segments.extend(y_url)
return x_segments, y_segments
The actual code is fairly direct, with an attempt to make the code reasonably conversational. The card outside was rendered using MatPlotLib to make sure the design was within bounds. Note that the front and back of the card are plotted together.
The inside of the card was constructed in a similar manner, and is shown below.
After the card designs were finalized they needed to be plotted. This was done by converting the line segments to gcode and sending them off to the plotter. The gcode for a small portion of the outside of the card is:
G0 X221.0 Y46.0 Z-14.5
G0 Z-16.25
G1 X171.0 Y46.0 F1000
G1 X121.0 Y46.0 F1000
G0 Z-14.5
G0 X121.03185871715041 Y46.0 Z-14.5
G0 Z-16.25
G1 X171.0 Y49.21231004550824 F1000
G1 X220.9681412828496 Y46.0 F1000
G0 Z-14.5
G0 X220.87260573051267 Y46.0 Z-14.5
G0 Z-16.25
G1 X171.0 Y52.420526487930914 F1000
G1 X121.12739426948733 Y46.0 F1000
G0 Z-14.5
G0 X121.28648491140513 Y46.0 Z-14.5
G0 Z-16.25
G1 X171.0 Y55.62056094086015 F1000
G1 X220.71351508859487 Y46.0 F1000
G0 Z-14.5
G0 X220.49107209404664 Y46.0 Z-14.5
G0 Z-16.25
G1 X171.0 Y58.80833544459566 F1000
G1 X121.50892790595336 Y46.0 F1000
G0 Z-14.5
The code above draws a total of 14 line segments, and takes about 12 minutes to complete the outside of the card. The inside of the card takes an additional 14 minutes, in part because the row of trees is fairly detailed. The text also adds significant complexity and time to the drawing.
Once all the cards were drawn, it was time to send them. This required addressing envelopes, and since plotting is so much fun the envelopes were also plotted. The envelopes took an additional 18 minuted each to plot on average, due mostly to plotting text being time consuming.
All of the code for this project is available on GitHub.