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 :-).
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 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?
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??".
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".
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.
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.
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.
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