Despite the cheeky title, I'm thoroughly impressed with the Perfect Freehand library, and never would have attempted digging this deep into solving this on my own. There are two separate bits of work here, and they don't depend on one another. First, improvements to the Perfect Freehand library itself. Second, Catmull-Rom quadratic bezier paths for the rendering function (Perfect Freehand leaves rendering as an application detail, but helpfully provides a cubic bezier example).
The differences can be quite subtle, so the UI below renders via the original Perfect Freehand library in red, with a slider to overlay my adjustments in blue. The raw path data is in black, and the adjusted (streamline, smoothing) path data is in white.
For the most part, you won't see much difference. This is a Good Thing™! Pay particular attention to the spots where the path bends with a moderately sharp curvature. You should see that the original algorithm (in red) occasionally kinks inward in a way the improved algorithm (in blue) does not. Try increasing the stroke size if these areas are difficult to locate.
One of my initial motivating use cases was ensuring the path actually passes through the drawn points. The Catmull-Rom algorithm improves on this vs the cubic implementation, but most of the issue actually lies in the streamlining routine (which I left unchanged for now). The improvements are definitely worthwhile at very low streamline values.
Here's my fork on GitHub
/*
Handle sharp corners
Find the angle (dot product) between the current and next vector.
If the next vector is at more than a right angle to the current vector,
OR the projected radius will be bent back more than the distance away from the current point
(causing those projections to cross) draw a cap.
*/
const nextPt = (!isLastPoint ? points[i + 1] : points[i]);
const nextVector = nextPt.vector
const nextDpr = !isLastPoint ? dpr(vector, nextVector) : 1.0
const crossPr = cpr(vector, nextVector);
const isSharpCorner = nextDpr <= 0 || radius * Math.abs(crossPr) > nextDpr * nextPt.distance;
if (isSharpCorner) {
// It's a sharp corner. Draw a rounded cap and move on to the next point
// Considering saving these and drawing them later? So that we can avoid
// crossing future points.
const cornerCapRot = Math.atan2(crossPr, nextDpr);
// Use mutable operations for the offset calculation
perInto(_offset, vector)
mulInto(_offset, _offset, radius)
const step = 1 / CORNER_CAP_SEGMENTS
for (let t = 0; t <= 1; t += step) {
// Calculate left point: rotate (point - offset) around point
subInto(_tl, point, _offset)
rotAroundInto(_tl, _tl, point, (cornerCapRot>0 ? cornerCapRot : (2*Math.PI+cornerCapRot)) * t)
tempLeftPoint = [_tl[0], _tl[1]]
leftPts.push(tempLeftPoint)
// Calculate right point: rotate (point + offset) around point
addInto(_tr, point, _offset)
rotAroundInto(_tr, _tr, point, (cornerCapRot>0 ? (2*Math.PI-cornerCapRot) : -cornerCapRot) * -t)
tempRightPoint = [_tr[0], _tr[1]]
rightPts.push(tempRightPoint)
}
prevLeftPoint = tempLeftPoint
prevRightPoint = tempRightPoint
continue
}
The most important changes happen here, where the code detects "sharp corners" and draws rotation segments around them. The new isSharpCorner calculation takes stroke width vs the distance to the next point into account whereas the old version relied strictly on the bend angle being greater than 90 degrees (here, nextDpr <= 0). This is why you'll often need to set a large stroke size to see any issues with the original algorithm.
Once we decide to draw a sharp bend, the angle of the bend is slightly different for inner and outer paths. Thus the (cornerCapRot>0 ? ...) checks in the calls to rotAroundInto.
Most of the other changes to the branch have to do with normalizing some bugs I discovered with vectors pointing the wrong way. It all balanced out in the original (-1 * -1 == 1), but I adjusted my version because I found it confusing.
Shouts out to Steve for Perfect Freehand
and @dotproto for this handy head-start and teaching me a new trick for calculating the angle between vectors.
the Catmull-Rom routine I'm using is based on this gist by Nicholas Kyriakides
Enjoy!