↖️ Blog Archive

Sunrise PCB Art, Part Two

Bradley Gannon

2025-10-02

TL;DR: I finished the FR4 art piece I designed in part one and improved svg-kerf by adding cubic Bézier curve support and generally increasing compliance with the SVG path specification.

The sunrise design from part one, but assembled out of actual materials and resting on hooks hanging from my pegboard

Finishing the Art Piece

svg-kerf was still only partly working when I picked this project back up, so I needed to be a little careful with my input to make sure it didn’t break. This meant only using absolute coordinates and line segments, which I created by manually splitting curves in Inkscape. I managed to get it to hold together for the dozen or so paths I needed to make the artwork, and then I set the program aside until after the main part of the project was finished.

With the inflated paths in hand, I split them up into a few files and tried to pack them relatively efficiently into the size of the FR4 blanks I had on hand (nominally 6 by 8 inches) minus some clearance for work holding. I probably could have done a better job with this and saved some material, but not much. I used svg2gcode to convert the paths into G-code files with appropriate settings for my mill1 and sent them with Candle after electrically probing the work origin to establish a reasonable Z level. I didn’t use mesh leveling for these cuts because I didn’t need that level of accuracy in the vertical direction.

My CNC mill cutting some of the parts with the dust shoe installed

My initial intention was to use gctk to mirror the copper-down parts so I could always cut with the copper side up (for some reason?), but it choked on the input. Then I realized that the whole piece is symmetrical, so I could just cut them all “normally” and flip them afterward as needed. Soon I had them all cut out and ready for post-processing.

All the parts for the final design fresh off the CNC mill and laid out on a green table

After cutting, I washed all the parts in warm soapy water and dried them well. I read that a lemon juice and salt paste could brighten the copper, but I didn’t see any change. Maybe the parts were already bright enough that there wasn’t much for the mixture to do. If I understood chemistry better, then maybe I could say something about the expected outcome. Anyway, I continued by sanding the edges lightly to remove some sharp edges leftover from the cuts and roughly arranging the parts in their final positions.

I hadn’t really planned out how I was going to adhere the parts together. Originally I’d thought I’d cut some MDF and mount them on that, but the piece I had was warped and a little too big for the mill, so I decided to try cardstock. This went reasonably well, although I used two layers to improve rigidity slightly. The final product is still a little flexible, but this isn’t a load-bearing art piece, so I think that’s okay. I used double-sided tape to hold the parts onto the cardstock and to hold the layers together.

While the parts do fit together well for the most part, there are places where they stick out slightly (up to about one millimeter). I haven’t actually measured the parts to see how close they are to their nominal dimensions, but it seems likely that if I’d skipped the svg-kerf step then there would have been noticeable gaps. Maybe an improved approach would be to characterize the true kerf of this setup and use that value instead of using half the nominal tool width like I did this time.

I’m happy with how this came out, and it was fun to make some art. I think I’d forgotten that this is a vital and fundamental activity for humans.

Improvements to svg-kerf

Second Swing at SVG Paths

As a part of its internal pipeline, svg-kerf has to convert lists of SVG path commands into lists of points. This is called curve flattening, and it’s necessary in this case because svg-kerf uses the Clipper2 library for inflation, and Clipper2 only operates on polygonal paths.2 (That is, paths which are defined by line segments.) The SVG path commands are well-specified and relatively simple, but there are some syntax features that elide certain arguments to save space. My first pass at svg-kerf was a little hasty with respect to these details, so I scrapped it and made another attempt with much more attention to the spec.

The second attempt went well. The authors of the spec seem to have taken care to make the natural language easy to translate into code. Soon I had reimplemented lines and was at parity with my first attempt, except it was now more reliable and compliant. As usual, Rust made this a joyful exercise, and I think after getting it to compile I only had to correct one small logic bug. It’s amazing how making a throwaway attempt at solving a problem in code can sometimes be the best guide for the subsequent “real” solution.

Curve Flattening

At this point I was able to go further. The SVG path spec defines several curve commands, and among these is the cubic Bézier. Inkscape seems to use these all over, which I suppose may be because all the other curves can be viewed as special cases of the cubic Bézier. I wasn’t really sure how to achieve curve flattening here. A simple approach would be to choose some number of line segments NN and sample the curve at N+1N+1 points with equal spacing in the parameter tt. This fails to account for local curvature, though, so sharp turns aren’t represented faithfully and straight areas are encoded redundantly.

I sat down with pen and paper and tried to design something that might work. Here’s what I came up with:

Three copies of the same simple curve arranged in
a row. A single line segment connects the start and end points of the left-most curve. In the middle
curve, the line segment has been split into two and is a better approximation of the true curve. In
the right-most curve, the segments have been split again.
  1. Let CC be some curve parameterized by tt on the interval [0,1][0, 1].
  2. Create a line segment from C(0)C(0) to C(1)C(1).
  3. Find tt' such that C(t)C(t') is furthest from the line segment.
  4. Split the line segment at that tt'.
  5. Repeat recursively for the new line segments until the segment deviations are within some threshold.

This description is sloppy, but I hope it gives the general shape of the algorithm. The output is guaranteed not to deviate from the original curve by more than the threshold, and areas of the curve that are straight get fewer segments. I think of it as sticking a tack in the string that forms a line segment and pulling it onto the curve, which seems like the fastest way to minimize its error. In my implementation, I just sweep tt over the segment’s start and end points with a small Δt\Delta t to find something close to the maxmimum deviation, but this is probably slower than it could be. I imagine with some clever math it could be shown that in certain cases binary search is sufficient, or maybe there’s even an analytic solution if the curve is known. One advantage of this algorithm is that it works for any single-parameter function on [0,1][0, 1] (I think).

I did some reading after building this solution and found the Ramer-Douglas-Peucker algorithm, which is similar to my idea except it operates explicitly on line segments in the input. I think my solution is equivalent to the RDP algorithm if you consider the input curves as being discrete maps over the floating point values of tt. I was pretty sure my approach wasn’t novel because it seems like an important operation that would have been solved by now.

My solution could definitely be faster, and it also generates probably too many points, but for now it’s good enough. It was easy enough to build, and it was also pretty easy to integrate it with the cubic Bézier path handler in my existing code. I don’t think it would be too hard to do the same thing for the other path commands, but I didn’t bother because I didn’t need them for my use case. Over the past few projects, I’ve had fun building things with the expectation that they won’t be complete or ideal by some external metric. Paying more attention to the ends than the means helps balance out my neuroticism a little, and it’s soothing.


  1. In case you need or want them, here are the arguments I passed: --begin "G21 G90 G17 G0 Z2 F100 M3 S10000 G4 P1.5" --on "G1 Z-2 F60" --feedrate 120 --off "G0 Z2" --end "M5 G4 P1.5 M30"↩︎

  2. It’s possible to compute offset curves directly from the path commands, but it seemed easier to take the curve flattening approach instead. Maybe this would be an interesting thing to explore in the future.↩︎