Learn Creative Coding (#106) - Algorithms for Pen Plotters
Learn Creative Coding (#106) - Algorithms for Pen Plotters
Last episode we got our code out from behind the glass and onto paper. We learned what a plotter actually is (a machine that drags one pen along strokes), we built a little SVG exporter by hand, we plotted a flow field, and we met the two big truths of plotting: you can't fill, you can only draw lines, and the order of those lines decides how long the whole thing takes. If you plotted (or hand-traced) that flow field, you already felt the medium pushing back a little.
Today we go one level deeper. Because here's the thing - once you can make a plotter draw, the interesting question stops being "how" and becomes "what". What do you feed a machine that has exactly one pen and no fill button? The answer is a whole family of algorithms that were basically invented for this constraint, and they are some of the most satisfying code I've ever written. We're going to turn a photograph into dots. We're going to draw an entire image with a single unbroken line. We're going to fill space with curves that never cross themselves. Allez, roll up your sleeves :-).
The constraint that started it all
Quick recap of the rule that shapes everything in this episode: a plotter has one pen, and it makes lines. No solid fills, no soft gradients, no "just set this pixel grey". If you want tone - light, dark, shadow - you have to build it out of lines and dots. We touched this last time with hatching. Now we get serious about it.
There are basically three ways to fake tone with a pen, and each one is its own little algorithm:
// three ways to make "darkness" with a single pen
const toneMethods = {
hatching: "parallel lines - closer together = darker (did this in ep105)",
stippling: "lots of dots - more dots packed in = darker",
lineDensity:"one long wiggly line - tighter wiggles = darker"
};
// every plotter portrait you've ever admired is one of these three,
// or a mix. that's the whole secret.
Notice none of these need a fill. They're all just "put the pen here, drag it there". That's the mindset for the whole episode. Now let's build them.
Stippling: drawing with dots
Stippling is the prettiest trick in the box. You represent an image as a cloud of dots - lots of dots where the image is dark, few dots where it's light - and your eye blends them back into a picture. Newspapers did a crude version of this for a century. We're going to do the smart version.
The naive approach is to throw down random dots and keep a dot only if the pixel under it is dark enough. It works, sort of, but it looks noisy and clumpy. The professional approach has a beautiful name: weighted Voronoi stippling. Don't let the name scare you, the idea is gorgeous and simple once you see it.
First, the pixel part. Remember episode 10, where we learned to read raw pixel brightness? That's the fuel here. We turn the image into a brightness lookup so any (x, y) tells us how dark the picture is there.
// load an image and let us ask "how dark is it at (x,y)?"
// brightness 0 = black (we want lots of dots), 255 = white (few dots)
let img;
function preload() { img = loadImage('portrait.jpg'); }
function darknessAt(x, y) {
img.loadPixels();
let ix = constrain(floor(x), 0, img.width - 1);
let iy = constrain(floor(y), 0, img.height - 1);
let i = (iy * img.width + ix) * 4; // 4 channels: r,g,b,a
let bright = (img.pixels[i] + img.pixels[i+1] + img.pixels[i+2]) / 3;
return 1 - bright / 255; // 0..1, where 1 = pitch black
}
Now, the Voronoi idea. Scatter a bunch of points on the page. For every pixel, ask "which point am I closest to?" - that assigns every pixel to its nearest point, carving the page into regions (called Voronoi cells). Each point owns the patch of page around it.
Here's the clever bit: we move each point to the weighted centroid of its cell, where the weight is the image darkness. Dark pixels pull harder. So points drift toward dark areas and spread out in light areas. Repeat that a few dozen times and the dots arrange themselves into a perfect representation of the image. This iterative relaxation is called Lloyd's algorithm.
// one relaxation step: move every point toward the dark-weighted
// centre of the pixels that are closest to it.
function relax(points) {
// accumulators: weighted sum of positions, and total weight, per point
let sumX = new Array(points.length).fill(0);
let sumY = new Array(points.length).fill(0);
let sumW = new Array(points.length).fill(0);
// sample the page on a grid (cheaper than every single pixel)
for (let y = 0; y < height; y += 2) {
for (let x = 0; x < width; x += 2) {
let w = darknessAt(x, y); // dark pixels weigh more
if (w <= 0) continue; // pure white pulls nobody
let nearest = nearestPoint(points, x, y);
sumX[nearest] += x * w;
sumY[nearest] += y * w;
sumW[nearest] += w;
}
}
// each point jumps to its weighted centre of mass
for (let i = 0; i < points.length; i++) {
if (sumW[i] > 0) {
points[i].x = sumX[i] / sumW[i];
points[i].y = sumY[i] / sumW[i];
}
}
}
The nearestPoint helper is just a brute-force closest-point search. For a few thousand points it's slow but totally fine to run a few times while you dial things in.
// which point is closest to (x,y)? plain old distance check.
function nearestPoint(points, x, y) {
let best = 0, bestD = Infinity;
for (let i = 0; i < points.length; i++) {
let dx = points[i].x - x;
let dy = points[i].y - y;
let d = dx*dx + dy*dy; // skip sqrt - we only compare
if (d < bestD) { bestD = d; best = i; }
}
return best;
}
Wire it together: scatter points, relax a bunch of times, export each point as a tiny circle. The picture emerges out of nowhere, and honestly the first time you watch it converge it feels like a magic trick.
// the full stipple pipeline
function makeStipple() {
let points = [];
for (let i = 0; i < 4000; i++) { // 4000 dots
points.push({ x: random(width), y: random(height) });
}
for (let step = 0; step < 40; step++) { // 40 relaxation passes
relax(points);
}
return points; // hand these to your SVG exporter as small circles
}
For the plotter, each dot becomes a <circle> in your SVG (or a teeny tiny cross if your pen likes that better). More dots and more relaxation steps = more detail, but also more plot time. I usually start at 3000 dots and 30 passes, look at the preview, then crank it up if it needs more bite. See how every step here is something we already knew - pixels from ep10, distance from ep13's trig arc - just pointed at a new goal?
TSP art: the whole image as one line
Okay, this next one broke my brain a little the first time I saw it, in the best way. You take all those stipple dots... and you connect them into a single continuous line that visits every dot exactly once. The plotter never lifts the pen. The entire portrait is drawn with one unbroken stroke. People call it TSP art, because finding the shortest route through a set of points is the famous Travelling Salesman Problem.
We met TSP in passing last episode when we sorted our strokes. Here it's not a speed optimisation - it is the artwork. The line weaving between the dots is the whole point.
Now, solving TSP perfectly is one of those problems that gets brutally hard fast - for a few thousand dots, the exact answer is out of reach. But we don't need perfect, we need pretty, and pretty is cheap. The same greedy nearest-neighbour trick from ep105 gives us a single tour that already looks great.
// build ONE path that visits every point, always hopping to the
// nearest unvisited one. greedy, fast, and it looks fantastic.
function tspTour(points) {
let remaining = points.slice();
let tour = [remaining.shift()]; // start anywhere
while (remaining.length > 0) {
let last = tour[tour.length - 1];
let bestI = 0, bestD = Infinity;
for (let i = 0; i < remaining.length; i++) {
let dx = remaining[i].x - last.x;
let dy = remaining[i].y - last.y;
let d = dx*dx + dy*dy;
if (d < bestD) { bestD = d; bestI = i; }
}
tour.push(remaining.splice(bestI, 1)[0]); // visit nearest, repeat
}
return tour; // one polyline = one continuous pen stroke
}
That greedy tour has one ugly habit, though: every now and then it paints itself into a corner and has to make one long ugly leap across the whole page to reach a dot it skipped. There's a famously simple fix called 2-opt: find two segments of the line that cross each other, and uncross them by reversing the bit in between. Do that repeatedly and the long jumps melt away.
// 2-opt cleanup: if reversing a chunk makes the tour shorter, do it.
// run a few passes - each one untangles more crossings.
function twoOpt(tour, passes) {
function d(a, b) { return dist(a.x, a.y, b.x, b.y); }
for (let p = 0; p < passes; p++) {
for (let i = 0; i < tour.length - 2; i++) {
for (let k = i + 1; k < tour.length - 1; k++) {
// current edges vs the swapped edges
let before = d(tour[i], tour[i+1]) + d(tour[k], tour[k+1]);
let after = d(tour[i], tour[k]) + d(tour[i+1], tour[k+1]);
if (after < before) {
// reverse the segment between i+1 and k -> uncrosses them
let seg = tour.slice(i + 1, k + 1).reverse();
tour.splice(i + 1, seg.length, ...seg);
}
}
}
}
return tour;
}
Run tspTour then a couple of twoOpt passes over your stipple points, export the result as one giant polyline, and you've got a print-ready TSP portrait. Watching a plotter draw one of these is genuinly hypnotic - the pen wanders around what looks like chaos, and a face slowly resolves out of the single line. It's the kind of thing that makes people lean over your shoulder and go "wait, that's ONE line??".
Space-filling curves: tone from a single wiggle
Here's a totally different way to get one continuous line, and it's my personal favourite for backgrounds. A space-filling curve is a path that, if you let it, visits every point in a region without ever crossing itself. The Hilbert curve is the classic - it folds into the page at finer and finer detail the deeper you recurse.
On its own a Hilbert curve is just a neat fractal. But here's the move: you modulate its detail by image brightness. Where the image is dark, you let the curve fold tight and dense (lots of line = dark tone). Where it's light, you keep it loose and sparse. The result is an image rendered as one impossibly long, never-lifting line. Plotters adore these because pen-up travel is literally zero.
Let me show the Hilbert generator first - it's a lovely little recursive function.
// generate a Hilbert curve of a given order into an array of points.
// order 1 = 4 points, order 2 = 16, order n = 4^n. it never self-crosses.
function hilbert(order) {
let pts = [];
let n = 1 << order; // grid size = 2^order
for (let d = 0; d < n * n; d++) {
pts.push(d2xy(n, d)); // map distance-along-curve to (x,y)
}
return pts;
}
// convert "distance d along the curve" into grid coordinates.
// this is the standard Hilbert d->(x,y) mapping with bit rotations.
function d2xy(n, d) {
let rx, ry, t = d, x = 0, y = 0;
for (let s = 1; s < n; s *= 2) {
rx = 1 & (t / 2);
ry = 1 & (t ^ rx);
// rotate the quadrant so the curve joins up correctly
if (ry === 0) {
if (rx === 1) { x = s - 1 - x; y = s - 1 - y; }
[x, y] = [y, x];
}
x += s * rx;
y += s * ry;
t = Math.floor(t / 4);
}
return { x, y };
}
That gives you a fixed grid of points. To make it represent an image, you don't draw the whole dense curve everywhere - you let the local order follow the darkness. A simple, very effective approximation: walk a coarse Hilbert curve, and in dark regions replace each step with a denser sub-curve. Even the cheap version - just drawing a uniform Hilbert and varying the line thickness idea by skipping segments in bright areas - reads beautifully.
// keep a Hilbert point only where the image is dark enough.
// bright areas drop out -> sparse line, dark areas stay -> dense line.
function brightnessFilter(points, cellSize) {
return points.filter(p => {
let px = p.x * cellSize;
let py = p.y * cellSize;
return darknessAt(px, py) > random(0.15); // probabilistic keep
});
}
The little random(0.15) threshold is a cheeky trick - it makes the cutoff fuzzy instead of a hard edge, so the tone fades smoothly instead of snapping. Small touches like that are the difference between "technically correct" and "actually nice to look at".
Hatching, properly this time
Last episode we did flat hatching - parallel lines at one angle, gap controlled by brightness. Let's level it up, because real engraving never uses a single flat angle. Two upgrades make it sing: cross-hatching (a second set of lines at a different angle for the darkest tones) and following the form (bending the hatch angle so it flows around the shape, the way a pen-and-ink artist would).
// cross-hatching: add a second layer of lines once a region is dark
// enough. light -> nothing, mid -> one direction, dark -> two directions.
function hatchTone(plot, x, y, w, h, darkness) {
if (darkness > 0.1) {
let gap = map(darkness, 0.1, 1, 4, 0.6); // darker -> tighter
hatchAngle(plot, x, y, w, h, gap, 0); // horizontal pass
}
if (darkness > 0.55) { // only the dark stuff
let gap = map(darkness, 0.55, 1, 4, 0.6);
hatchAngle(plot, x, y, w, h, gap, HALF_PI); // vertical cross pass
}
}
The angled-hatch helper is just our scan-lines from last time, rotated. A bit of trig (hello again, episode 13) lets us draw lines at any angle inside a box.
// draw parallel lines across a box at a given angle.
function hatchAngle(plot, x, y, w, h, gap, angle) {
let cx = x + w/2, cy = y + h/2;
let len = Math.sqrt(w*w + h*h); // long enough to cover the box
let dx = Math.cos(angle), dy = Math.sin(angle); // line direction
let nx = -dy, ny = dx; // perpendicular (where we step)
for (let off = -len/2; off < len/2; off += gap) {
let mx = cx + nx * off;
let my = cy + ny * off;
// a line through (mx,my) in the (dx,dy) direction, clipped roughly
let a = [mx - dx*len/2, my - dy*len/2];
let b = [mx + dx*len/2, my + dy*len/2];
plot.addPolyline([a, b]);
}
}
In a real piece you'd clip those lines to the actual region instead of a rough box, but the principle is all here: tone is layers of lines, and the angle is a creative choice, not just a technical one. Want it to feel hand-drawn? Wobble the angle a touch per region with a bit of noise (ep12). The machine is precise; you get to decide how precise it looks.
A quick word on choosing which algorithm
So you've got stippling, TSP, space-filling curves, hatching. Which do you reach for? After a lot of wasted paper, here's my rough rule of thumb:
// femdev's totally unscientific picker :-)
const whichAlgorithm = {
portraits: "stippling or TSP - faces love dots and single lines",
landscapes: "hatching - it gives you directional texture (sky vs ground)",
abstract: "space-filling curves - hypnotic, fills a page beautifully",
logos_text: "single-line fonts (next time!) - crisp and graphic",
showing_off: "TSP - nothing makes people gasp like one continuous line"
};
None of these is "correct" - they're flavours. The fun part of plotting is that the same photo run through stippling versus TSP versus hatching gives you three completely different artworks, all unmistakably from the same source. Try the same image three ways. You'll learn more in an afternoon of that than in any tutorial.
Your exercise: a stippled, then single-line, portrait
Two-parter, building on each other - and you can do all of it on screen even with no plotter.
Part one: take the stippling code and run it on a photo (a generic test image or something you shot yourself - no faces of real people without asking, you know the rules :-)). Start with 3000 dots and 30 relaxation passes. Render the dots on screen first. Tune the count until the image reads clearly. Then export it as SVG circles. That's a finished, plottable stipple portrait already.
Part two: take those exact same dots and run them through tspTour plus a couple of twoOpt passes. Export the result as one single polyline. Open both SVGs side by side. Same dots, but one is a dot cloud and the other is a single unbroken line drawing the same face. Feeling the connection between those two is the whole lesson.
Stretch goal: render the line being drawn progressively on screen (draw the first N points, increase N each frame) so you can watch the portrait appear stroke by stroke, exactly like a plotter would draw it. It is genuinely mesmerising and it'll teach you why people fall down the plotter rabbit hole.
And if you do have a machine - plot the TSP one. Trust me. There is nothing like watching a single pen line slowly become a face.
't Komt erop neer...
- A plotter has one pen and can't fill, so all tone has to be built from lines and dots. The three core methods are hatching (parallel lines), stippling (dots), and line density (one wiggly line) - every plotter image you admire is some mix of these
- Stippling represents an image as a dot cloud: dark areas get more dots. The smart version is weighted Voronoi stippling - scatter points, assign each pixel to its nearest point, then move each point to the darkness-weighted centre of its cell (Lloyd's algorithm). Repeat ~30-40 times and the dots arrange into the picture. The pixel-brightness reading is straight from episode 10
- TSP art connects all those stipple dots into ONE continuous line that visits each dot once - the whole portrait drawn without lifting the pen. A greedy nearest-neighbour tour gets you most of the way; a few 2-opt passes uncross the ugly long jumps. Perfect TSP is impossibly hard, but "pretty" is cheap
- Space-filling curves (the Hilbert curve) fill a region with one line that never crosses itself. Modulate its density by image brightness - tight folds in dark areas, sparse in light - and you render a whole image as one never-lifting line, with zero pen-up travel
- Cross-hatching adds a second set of lines at a different angle for the darkest tones, and bending the hatch angle (with a little trig from episode 13, or noise from episode 12) makes it flow around the form like real pen-and-ink work
- Different algorithms suit different subjects: stippling and TSP love faces, hatching gives landscapes directional texture, space-filling curves make hypnotic abstracts. The same photo through three algorithms gives three completely different artworks
- Almost nothing here is new technique - it's pixels, distance, trig, and noise we already had, just aimed at the one-pen constraint. That constraint is what makes the algorithms beautiful
That's the algorithmic heart of plotting. We can now turn any image into dots, into a single weaving line, or into flowing hatches, all from concepts we built earlier in the series. Notice the pattern: the plotter didn't make us learn new math, it made us use our old math in a tighter, more elegant way. Constraints do that - they squeeze better ideas out of you.
Next time we push past pure software and into the craft side of plotting - the techniques that separate a flat plot from one that looks like it was made by a person with real tools and real intent. There's a surprising amount of art hiding in how you actually run the machine. See you there :-).
Sallukes! Thanks for reading.
X
Leave Learn Creative Coding (#106) - Algorithms for Pen Plotters to:
Read more #stem posts
Best Posts From femdev
We have not curated any of femdev's posts yet. But you can encourage our curation team to review posts by visiting them regularly and by referring other readers. Because we give priority to frequently read content.
More Posts From femdev
- Learn Creative Coding (#111) - Creative Projection Mapping
- Learn Creative Coding (#110) - Projection Mapping Basics
- Learn Creative Coding (#109) - LED Art: Addressable Light
- Learn Creative Coding (#108) - Laser Cutting: Code to Material
- Learn Creative Coding (#107) - Advanced Plotter Techniques
- Learn Creative Coding (#106) - Algorithms for Pen Plotters
- Learn Creative Coding (#105) - Pen Plotters: Code Meets Paper
- Learn Creative Coding (#104) - AI and Art: Tools, Ethics, and Authorship
- Learn Creative Coding (#103) - Mini-Project: ML-Powered Interactive Installation
- Learn Creative Coding (#102) - Embeddings and Similarity
- Learn Creative Coding (#101) - ML Audio: Speech and Sound Recognition
- Learn Creative Coding (#100) - Training Custom Models with Teachable Machine
- Learn Creative Coding (#99) - GANs: Creating Images from Nothing
- Learn Creative Coding (#98) - Pix2Pix: Sketch to Image
- Learn Creative Coding (#97) - Style Transfer: Painting with Neural Networks
- Learn Creative Coding (#96) - Image Classification for Art
- Learn Creative Coding (#95) - Face Mesh and Expression
- Learn Creative Coding (#94) - Hand Tracking and Gesture
- Learn Creative Coding (#93) - Body as Input: Pose Detection
- Learn Creative Coding (#92) - ml5.js: Machine Learning in the Browser