Wednesday, January 19, 2022

Map Compasses (Part 10): Triangles, Diamonds and Wavy Compass Points

Welcome back to my blog series on implementing procedurally-generated map compasses!  So far I've implemented the language and interpreter for all the capabilities needed to draw compasses like these examples:

With the current capabilities, CDL can draw almost half of the compasses in my examples set (in some cases missing a decorative element), so I want to move on to procedural generation soon.  But I'll implement a few more capabilities first.

First, let me return to radial triangles and modify them so they can have a light side and a dark side like a compass point, as seen in this example:
And I'll take this opportunity to illustrate a grammar feature I haven't yet used:
# A radial triangle element
# RTRI(start, repeats, height, width, lineWidth, lineColor, fillColor)
# or
# RTRI(start, repeats, height, width, lineWidth, lineColor, fillColor, darkFill)
rtriElement -> "RTRI"i WS "(" WS decimal WS "," WS decimal WS "," WS decimal WS ","
                              WS decimal WS "," WS decimal WS "," WS dqstring WS ","
			      WS dqstring WS ")" WS
	      {% data => ({op: "RTRI", start: data[4], repeats: data[8], height: data[12],
	                   width: data[16], lwidth: data[20], color: data[24],
			   fill: data[28], darkfill: data[28]}) %}
rtriElement -> "RTRI"i WS "(" WS decimal WS "," WS decimal WS "," WS decimal WS "," WS decimal WS "," WS decimal WS "," WS dqstring WS "," WS dqstring WS "," WS dqstring WS")" WS {% data => ({op: "RTRI", start: data[4], repeats: data[8], height: data[12], width: data[16], lwidth: data[20], color: data[24], fill: data[28], darkfill: data[32]}) %}
I've defined RTRI twice:  once with 7 parameters and once with 8 parameters.  The optional 8th parameter specifies a dark fill color.  (The parser can tell the two commands apart because if it spots a comma after the fill parameters, it knows it needs to use the 8 parameter version of the rule.)  In either case the rule produces an object with 8 parameters, but if no dark fill color was provided, then the dark fill is set to be the same as the regular fill color.

In Draw.rtri(), I have to create the left and right halves of the triangle and fill them in before drawing the outline over the top, analogous to what I did with the compass points:
function rtri(svg, center, radius, start, repeats, angle, i, op) {    
    if (op.height == 0) op.height = radius;
    let left = [[center[0]-op.width/2, center[1]-radius],
		[center[0], center[1]-radius],
		[center[0], center[1]-radius+op.height]];
    left = left.map(pt => rotate(center, pt, angle));
    svg.append('path')
	.style('stroke-width', 0)
	.style('stroke', 'none')
	.style('fill', op.fill)
	.attr('d', lineFunc(left));
    let right = [[center[0]+op.width/2, center[1]-radius],
		 [center[0], center[1]-radius],
		 [center[0], center[1]-radius+op.height]];
    right = right.map(pt => rotate(center, pt, angle));
    svg.append('path')
	.style('stroke-width', 0)
	.style('stroke', 'none')
	.style('fill', op.darkFill)
	.attr('d', lineFunc(right));
    let triangle = [[center[0]-op.width/2, center[1]-radius], 
		    [center[0]+op.width/2, center[1]-radius],
		    [center[0], center[1]-radius+op.height]];
    triangle = triangle.map(pt => rotate(center, pt, angle));
    svg.append('path')
	.style('stroke-width', op.lwidth)
	.style('stroke', op.color)
	.style('fill', op.fill)
	.attr('d', lineFunc(triangle));
};
And now something like this:
RTRI(0, 8, -8, 8, 1, "black", "white", "black") SPACE(-1) CIRCLE(2, "black", "none")
produces

A very similar element is radial diamonds, as seen in this example:
As with the triangles, these can have a dark and a light side.  The implementation is almost exactly the same as triangles, but with a diamond shape, so each side has four points instead of three.
function rdiamond(svg, center, radius, start, repeats, angle, i, op) {    
    if (op.height == 0) op.height = radius;
    let left = [[center[0], center[1]-radius],
		[center[0]-op.width/2, center[1]-radius+op.height/2],
		[center[0], center[1]-radius+op.height]];
    left = left.map(pt => rotate(center, pt, angle));
    svg.append('path')
	.style('stroke-width', 0)
	.style('stroke', 'none')
	.style('fill', op.fill)
	.attr('d', lineFunc(left));
    let right = [[center[0], center[1]-radius],
		[center[0]+op.width/2, center[1]-radius+op.height/2],
		[center[0], center[1]-radius+op.height]];
    right = right.map(pt => rotate(center, pt, angle));
    svg.append('path')
	.style('stroke-width', 0)
	.style('stroke', 'none')
	.style('fill', op.darkFill)
	.attr('d', lineFunc(right));
    let diamond = [[center[0], center[1]-radius],
		   [center[0]+op.width/2, center[1]-radius+op.height/2],
		   [center[0], center[1]-radius+op.height],
		   [center[0]-op.width/2, center[1]-radius+op.height/2]
		  ];
    diamond = diamond.map(pt => rotate(center, pt, angle));
    svg.append('path')
	.style('stroke-width', op.lwidth)
	.style('stroke', op.color)
	.style('fill', 'none')
	.attr('d', lineFunc(diamond));
};

producing this:


I'm also going to take this opportunity to go back and refactor the code for RCIRCLE slightly.  The other radial elements (triangles, diamonds and lines) are drawn so that the bottom of the element is on the current radius.  But radial circles are drawn centered on the current radius.  It will be convenient later on (in Part 14 to be precise) if the placement of radial elements is handled consistently.  So I'll fix RCIRCLE to place the bottom of the circle on the radius and draw upwards, unless the radius is negative, in which case it will be drawn downwards.  The other elements also specify the total size of the element in the CDL, but RCIRCLE specifies half the size (the radius of the circle). I'll also fix this so that the size in RCIRCLE is the diameter instead of the radius.  That looks like this:
function rcircle(svg, center, radius, start, repeats, angle, i, op) {
    const effRadius = radius+op.size/2;
    const sx = center[0] + radius * Math.cos(angle);
    const sy = center[1] - radius * Math.sin(angle);
    circle(svg, [sx, sy], Math.abs(op.size/2), op.lwidth, op.color, op.fill);
};
Two changes are necessary.  First, I have to adjust the radius at which I'm drawing the circles by the radius of the circles.  This essentially shifts the circle so that the bottom of the circle rather than the center of the circle is at the current drawing radius.  Second, I have to treat the size of the circle as an absolute number, since SVG won't draw a circle with a negative radius.

A more challenging feature that appears in a number of the example compasses is the wavy compass point:
This is often but not always associated with an image of the Sun.  Interestingly, it is almost always used as in the above compass, where the cardinal directions are straight compass points and the ordinal directions are wavy.

I could add a “wave" parameter to RPOINT but the code will be quite different, so I think I'll just make a new command RWAVE with all the same arguments.  That gets added to cdl.ne and compass.js in the usual ways, and the command invokes Draw.rwave().  So that only leaves the actual hard part to do: drawing the wavy compass points.

My plan for drawing the wavy pointers starts with the two sides to a regular pointer:
I'll find points in the middle part of each side and move one set of points left and the other set right, like this:
which should create a jagged version of a wavy point, like this:
In a later step I'll turn that into a smooth curve.

I don't have a good intuition on how to split up the sides, but eyeballing the examples suggests that thirds is a reasonable starting point.  My intuition on moving the points is that each point should move some small fraction of the width at the point.

The first part of Draw.rwave() remains the same as Draw.rpoint:
function rwave(svg, center, radius, startAngle, repeats, angle, i, op) {
    let start = [center[0], center[1]-radius];
    let end = center;
    // Midpoint is the point on the center line where the
    // diamond will have its maximum width
    let midpoint;
    if (op.span <= 1) {
	// Treat span as a % of radius
	op.span = op.span*radius;
    };
    midpoint = [center[0], center[1]-radius+op.span];
    // The maximum width is one that just touches the adjacent point.
    // Phi is the angle between adjacent points.
    const phi = (2*Math.PI)/repeats;
    const maxWidth = Math.tan(phi/2)*(radius-op.span)*2;
    // Calculate the actual width based on width
    if (op.width <= 1) {
	op.width = op.width*maxWidth;
    };
    // Calculate the two side points
    let side1 = [center[0]-op.width/2, center[1]-radius+op.span];
    let side2 = [center[0]+op.width/2, center[1]-radius+op.span];
I calculate the start, end and side vertices of the point.  But for wavy points, I have to break up the two sides ([start, side1], [start, side2]) so that I have the two midpoints that I will perturb.  I could calculate the thirds and do this manually, but breaking up a line segment into smaller pieces (a form of interpolation) seems like something I might do again, so I'll write a function to handle it:
// Divides a line segment into pieces
function divideLineSegment(p1, p2, n) {
    // Calculate the stepwise dx and dy
    const dx = (p2[0]-p1[0])/n;
    const dy = (p2[1]-p1[1])/n;
    const npl = [p1];
    // We do this n-1 times so that we can use p2 as
    // the last point just to be sure it doesn't move
    // because of a rounding error.
    for(let i=1;i<n;i++) {
	npl.push([p1[0]+dx*i, p1[1]+dy*i]);
    };
    npl.push(p2);
    return npl;
};
I calculate how much x and y change at each step by dividing the total change in x and y by the number of steps, and then start at p1 and step forward that much each time to get the new intermediate points.  But note what I've done for the last point.  I don't want the end point of the line (p2) to move when I interpolate it.  But if I rely on the floating point arithmetic to make everything come out exactly, I risk a rounding error that will end up moving p2 to a new position.  You might think “oh, that's going to be a tiny error" but over a long segment (and cascaded through multiple line segments) it has been a problem in Dragons Abound.

This routine splits the line segment into even pieces, which will be fine if my plan to use thirds works out.  If I want to instead put the intermediate points at (say) 10% and 45%, then I can interpolate 20 points and just pull out the two that correspond to 10% and 45%.

I'll use divideLineSegment to create two three-part lines for the sides:
function rwave(svg, center, radius, startAngle, repeats, angle, i, op) {
    let start = [center[0], center[1]-radius];
    let end = center;
    // Midpoint is the point on the center line where the
    // diamond will have its maximum width
    let midpoint;
    if (op.span <= 1) {
	// Treat span as a % of radius
	op.span = op.span*radius;
    };
    midpoint = [center[0], center[1]-radius+op.span];
    // The maximum width is one that just touches the adjacent point.
    // Phi is the angle between adjacent points.
    const phi = (2*Math.PI)/repeats;
    const maxWidth = Math.tan(phi/2)*(radius-op.span)*2;
    // Calculate the actual width based on width
    if (op.width <= 1) {
	op.width = op.width*maxWidth;
    };
    // Create the side lines
    let side1 = divideLineSegment(center[0]-op.width/2, center[1]-radius+op.span, 3);
    let side2 = divideLineSegment(center[0]+op.width/2, center[1]-radius+op.span, 3);
Now I want to take the middle points of those line segments and shift them right and left.  Recall that I'm drawing this compass point vertically (and I'll later rotate it to where it should be), so I can shift points just by shifting their X values.  
    // Create the side lines
    let side1 = divideLineSegment(start, [center[0]-op.width/2, center[1]-radius+op.span], 3);
    let side2 = divideLineSegment(start, [center[0]+op.width/2, center[1]-radius+op.span], 3);
    // Shift the mid points
    const shiftPercentage = 0.33;
    const shift1 = (side1[1][0]-side2[1][0])*shiftPercentage;
    side1[1][0] += shift1;
    side2[1][0] += shift1;
    const shift2 = (side1[2][0]-side2[2][0])*shiftPercentage;
    side1[2][0] -= shift1;
    side2[2][0] -= shift1;
Shift is the width of the point at that point, which is just the difference in the X value between the two sides.   Then I add a fraction of that to the upper points, and subtract a fraction of the corresponding shift to the lower points.  Here I'm using 33% of the width, based on some eyeballing about what looked right:
Now I will combine the two sides and additional lines to the center of the compass to create a complete polygon:
    // Create a polygon consisting of the two sides, and the center point of the compass.
    // Remove the first point in one of the sides so that we don't end up with duplicate
    // points.  Reverse one of the sides so that the polygon flows correctly.
    let outline = side1.slice().reverse().concat(side2.slice(1)).concat([end]);
As indicated in the code, I have to construct the outline carefully to get the sides in the right order and avoid duplicating points.  I'm using slice() here to get copies of side1 and side2; I'll need them again later so I don't want to change them permanently here.
With the jagged version complete, I need to somehow smooth out the lines.  To do this, I'll use one of D3's curve functions.

You may remember back in Part 6 where I introduced the D3 line function:
    const lineFunc= d3.line().
                        x(pt=> pt[0]).
                        y(pt=> pt[1]).
                        curve(d3.curveLinearClosed);
The line function takes a list of points and draws them on the screen using a supplied curve function.  The purpose of the curve function is to draw between points to connect them together.  So far I've used d3.curveLinearClosed, which simply draws straight lines between the points (and draws from the last point back to the first point to “close" the polygon).  However, D3 provides a bunch of different curves you can use to connect points (you can explore some here ).  Most of these curves try to draw a smooth curve that connects the points.

For example, I can draw the same compass using d3.curveNaturalClosed, which creates a natural cubic spline to connect the points:
And ta-dah, smooth curves!  There's some interesting/funky stuff going on in the middle of the compass, but if you focus on the arms of the compass:
That's actually very much the look we want.  But I still need to implement the two fill halves.

To do that, I need to create a line down the center of the point and connect it up to the two sides to create two separate polygons.  And the center line needs to be wavy to match the sides.
    // Create a center line and shift it's points
    let cline = divideLineSegment(start, [center[0], center[1]-radius+op.span], 3);
    cline[1][0] += shift1;
    cline[2][0] -= shift2;
    cline.push(end);
Note that I can reuse the same shifts I calculated earlier for the sides, and I add the end point (at the center of the compass).

The last step is to construct the two fill polygons by using the center line and each side, again being careful to make a complete polygon.
    // Construct left and right areas
    let left = cline.concat(side1.slice(1).reverse());
    let right = cline.concat(side2.slice(1).reverse());
These can then be rotated, filled with the provided colors, and drawn with the outline and centerline:
    // Rotate everything
    outline = outline.map(pt => rotate(center, pt, angle));
    cline = cline.map(pt => rotate(center, pt, angle));
    left = left.map(pt => rotate(center, pt, angle));
    right = right.map(pt => rotate(center, pt, angle));
    svg.append('path')
	.style('stroke-width', 0)
	.style('stroke', 'none')
	.style('fill', op.lightFill)
	.attr('d', lineFuncWavy(left));
    svg.append('path')
	.style('stroke-width', 0)
	.style('stroke', 'none')
	.style('fill', op.darkFill)
	.attr('d', lineFuncWavy(right));
    svg.append('path')
	.style('stroke-width', op.lwidth)
	.style('stroke', op.lcolor)
	.style('fill', 'none')
	.attr('d', lineFuncWavy(outline));
    svg.append('path')
	.style('stroke-width', op.lwidth)
	.style('stroke', op.lcolor)
	.style('fill', 'none')
	.attr('d', lineFuncWavy(cline));

I'm sure you've noticed that the central part of this drawing is a little odd.  It actually makes an interesting pattern, but it would look odd as part of a compass.  I'm not sure there's a really good way to handle this part of the wavy compass points.  And indeed, every example of this I have covers up the central part of the wavy points one way or another:
Another thing you'll notice as you try out these points is that they no longer match up exactly with other compass points, e.g., 
The ends of the wavy compass points are on the ordinal directions, but the center line is not on the ordinal direction where it meets the juncture of the straight compass points.  Only certain combinations of sizes will meet up correctly.  In this example:
the artist has drawn part of the long wavy compass points with straight lines so that they meet up correctly, but this makes them look awkward and asymmetrical.  And if you look at the shorter wavy compass points you'll see that they do not meet up correctly.  If you look again at this example:

you'll see that the artist has covered up this junction to hide any problems.

Let's check this out and see how it works.  Here's an example of a generated compass using wavy points:

which was created with this CDL:
SPACE(32) RCIRCLE(0, 32, 1, 0, "none", "black") SPACE(-32) 
SPACE(27) RWAVE('+Math.PI/8+', 8, 0.80, 1, 1, "black", "white", "black") SPACE(-27) 
SPACE(11) CIRCLE(1, "black", "none") RDIAMOND(0, 16, 4, 4, 0.5, "black", "white", 
"black") SPACE(4) CIRCLE(1, "black", "none") SPACE(-19) 
SPACE(20) RWAVE('+Math.PI/4+', 4, 0.85, 1, 1, "black", "white", "black") SPACE(-20) 
RPOINT(0, 4, 0.85, 1, 1, "black", "white", "black") SPACE(65) CIRCLE(1, "black", "white") 
SPACE(3) CIRCLE(1, "black", "black") SPACE(2) CIRCLE(1, "black", "white") 
This example is in compass.js for you to try out.

I'll leave you with this eye-watering example:

A reminder that if you want to follow along from home, you need to download the Part 10 branch from the Procedural Map Compasses repository  on Github and get the test web page up and open the console.  Part 1 has instructions, but the short version (on Windows) is to double-click mongoose-free-6.9.exe to start up a web server in the Part 10 directory, and then select the test.html link.  Obviously you can browse the code and other files, and make changes of your own.  You can also try out this code on the web at https://dragonsabound-part10.netlify.app/test.html although you'll only be able to run the code, not modify it. 

Next time I'll offer some thoughts on procedural generation.  

Suggestions to Explore
  • For both the triangles and the diamonds I've always placed the dark fill color to the right.  You can easily swap to the left by swapping the colors in the CDL command, but shading that changes from right to left based on position around the compass (like it was being lit from a specific direction) is more challenging.  Implement shading for triangles and diamonds that changes based on the light direction.  (As was Suggested for compass points in Part 7.)

  • Diamonds as implemented here are symmetrical.  Add a way to make asymmetrical diamonds where the widest point is not right in the center.  Is this the same as RPOINT?  Should the RPOINT and RDIAMOND commands be combined into a single command?

  • d3.curveNatural is only one possibility for curving the wavy compass points.  Experiment with d3.curveCardinal using different tension values, e.g. try d3.curveCardinal.tension(0.25), and d3.curveCardinal.tension(0.75)

  • I've used a fairly aggressive amount of shift, but wavy points can look very nice with more subtle shift:
    Add another parameter to RWAVE that controls the amount of shift to apply.  As with span and width treat values under 1 as a percent of the point width.  Treat values over 1 as the number of pixels to shift.

  • RWAVE does interesting things with unusual values for span and width, e.g., 

             RWAVE(0, 4, 0.50, 1, 1, "black", "white", "black")

    produces this:
    Experiment with this and see what can be done.

  • This example:


    almost looks like a flower.  Can you turn CDL into a flower generation language?

Friday, January 7, 2022

Map Compasses (Part 9): Vertical Text and Radial Arcs

Happy 2022!  Hopefully this year will be better than the last two -- that shouldn't be difficult.  

Welcome back to my blog series on implementing procedurally-generated map compasses!  So far I've implemented the language and interpreter for all the capabilities needed to draw compasses like this example:

In the last part I implemented radial labels as in the example above, but I noted that many compasses do not rotate the labels, especially for the compass points, as in this example:
So let me implement this option.  (This was one of my “Suggestions to Explore" in the last part.)  To start with, I'll add an option to the RTEXT() command to control the orientation of the labels, either “radial" or “vertical."
# Radial text
# RTEXT(start, repeats, font, size, color, style, orientation, texts)
rtextElement -> "RTEXT"i WS "(" WS decimal WS "," WS decimal WS "," WS dqstring WS ","
	     		        WS decimal WS "," WS dqstring WS "," WS dqstring WS "," 
				WS dqstring WS "," WS dqstring WS "," WS dqList WS ")" WS
                 {% data => ({op: "RTEXT", start: data[4], repeats: data[8], font: data[12],
                              size: data[16], color: data[20], style: data[24], weight: data[28],
			      orientation: data[32], texts: data[36]}) %}
Now I'll fix the code to draw the label at the correct spot and with the vertical orientation if that option is chosen.  Since I don't have to rotate the text, I can do this by simply rotating the spot at which I'm going to draw the label just as I did for the other radial elements.
function rtext(svg, center, radius, startAngle, repeats, angle, iteration, op) {
    let x = center[0];
    let y = center[1]-radius;
    // If orientation == vertical, then rotate [x, y] around the center by
    // angle to find the spot for the label.
    if (op.orientation == 'vertical') {
	[x, y] = rotate(center, [x, y], angle-Math.PI/2);
    };
    // Now draw the label
    let label = svg.append('text')
	.attr('x', x)
	.attr('y', y)
	.style('font-family', op.font)
	.style('fill', op.color)
	.style('font-size', op.size)
	.style('font-style', op.style)
	.style('font-weight', op.weight)
	.style('text-anchor', 'middle')
	.text(op.texts[iteration]);
    // If orientation == radial, then we've drawn the label at 12 o'clock
    // and need to use SVG transform to rotate it around to the correct
    // spot.
    if (op.orientation != 'vertical') {
	label.attr('transform', 'rotate('+rad2degrees(angle-Math.PI/2)+','+center[0]+','+center[1]+')');
    };
};
Note that I don't have to set all the attributes and styles of an SVG element when it is created.  I can add or change these later, so I take advantage of that to add the rotation to the radial labels after drawing them as vertical labels.

Let me test this on the N/S/E/W labels from the complicated compass up above:
The labels are now vertical but the placement is off.  Why?  If you look at the code above you'll see that I'm setting the text-anchor of the label to “middle".  This is the middle of the bottom of the label, so SVG is putting that spot at the tip of each of the compass points.
That works for north but not for the other positions.  Instead of anchoring to the bottom of the text, in this case I should anchor to the center of the text [1].  This can be accomplished with something called the “dominant-baseline" style.  Setting this to “central" does [2] what we want:
although now we have the problem that the labels are impaled on the compass points.

[1] Not really.  See below.
[2] This setting doesn't really do exactly what we want, for reasons having to do with text complications like ascenders and descenders and the possibility of subscript accent marks and so on.  But this gets pretty close in most cases.

I can address the impalement by drawing the labels further out:

This works fairly well but you can see that the placement of W and E are different.  That's because W is a wider character than E.  I can make one of the labels longer to make the problem more apparent:
I think for most compasses a “close enough" approach is fine because the labels are usually the same number of characters, but see the Suggestions below for a better approach.  (And if you really want to be finicky, you can place each label using a separate RTEXT command to fully control where they are displayed.)

And here's a recreation of the sample compass from above with its vertical labels:
Another problem with the recreation of the fancy compass is that the line behind the ordinal labels (NE/SE/SW/NW) is not blocked out where the labels are (compare left to right):
I suggested last time that this could be handled by creating a radial rectangle element and then putting white rectangles between the label and the line.  If I implement this to support any color, then alternating white and black rectangles would enable the radial scale in this example:
Although those elements aren't really rectangles, they're arcs of a circle, so I'll call this command RARC.  Here's the CDL command:
# Radial arc
# RARC(start, repeats, subtend, width, color)
rarcElement -> "RARC"i WS "(" WS decimal WS "," WS decimal WS "," WS decimal WS ","
	       	              WS decimal WS "," WS dqstring WS ")" WS
                 {% data => ({op: "RARC", start: data[4], repeats: data[8], subtend: data[12],
                              width: data[16], color: data[20]}) %}
“Subtend" is the angle between the two ends of the arc, e.g., setting this to 20 degrees will result in 18 arcs around the circle.  (Although I'll use radians not degrees.)

Drawing an arc is a bit of a challenge.  SVG has a path command for drawing an elliptical arc; it's notoriously difficult.  As is often the case, D3 provides a much simpler way to draw a circular arc.  However, I'm going to choose not to use an SVG command and instead draw the arc directly.  I have two motivations to do it this way.  First, it's a more interesting coding problem.  Second, while SVG will draw a perfect arc, when I use this code in DA I'll want to be able to draw a (subtly) imperfect arc to make it look hand-drawn.  To do that, I need to do the drawing myself.

So, how do you draw an arc?  It starts with drawing a circle, or rather a polygon that is like a circle:
// Make a circular polygon
function makeCircle(center, radius, num) {
    const result = [];
    const step = (2*Math.PI)/num;
    for(let t=0;t<(2*Math.PI);t += step) {
	result.push([radius*Math.cos(t)+center[0],radius*Math.sin(t)+center[1]]);
    };
    // Close off the circle
    result.push(result[0]);
    return result;
};
What this does is step around a circle num times, adding the point at each step to a polygon.  So if num was 5, the result is a five-sided polygon (a pentagon) inscribed in the circle. 
(The returned polygon has six points in it, because the first point is repeated at the end to “close" the polygon.)  As you increase the number of sides, the polygon gets closer and closer to a circle.
To turn makeCircle into makeArc, I just need to only step over the angles of the arc rather than all the way around the circle. 
// Make a circular arc
function makeCircularArc(center, radius, start, end, num) {
    const [x, y] = center;
    const result = [];
    const step = (end-start)/num;
    for(let t=start;t<end;t += step) {
	result.push([radius*Math.cos(t)+x,radius*Math.sin(t)+y]);
    };
    result.push([radius*Math.cos(end)+x,radius*Math.sin(end)+y]);
    return result;
};
The rarc function starts by creating the inside and the outside edges of the arc:
function rarc(svg, center, radius, startAngle, repeats, angle, iteration, op) {
    const arcStart = angle-0.5*op.subtend;
    const arcEnd = angle+0.5*op.subtend;
    const outsideEdge = makeCircularArc(center, radius, arcStart, arcEnd, 20);
    const insideEdge = makeCircularArc(center, radius-op.width, arcStart, arcEnd, 20);
};
I say inside and outside, but I'm following the same convention for the size of radial elements I have before.  One edge of the arc (called the outsideEdge above) is “radius" away from the center point of the compass.  The other edge is closer to the center point if op.width is positive, and further away if negative.

Note that I'm centering the arc on “angle."  That seems more intuitive to me than starting at angle and going clockwise or counter-clockwise.

To connect the two edges into a polygon, I need to reverse the order of points in one of them, and then concatenate the two edges together.  Then I can draw it like any other path, but in this case there's no edge, it's just a filled area of color.
function rarc(svg, center, radius, startAngle, repeats, angle, iteration, op) {
    const arcStart = angle-0.5*op.subtend;
    const arcEnd = angle+0.5*op.subtend;
    const outsideEdge = makeCircularArc(center, radius, arcStart, arcEnd, 20);
    const insideEdge = makeCircularArc(center, radius-op.width, arcStart, arcEnd, 20);
    const polygon = outsideEdge.concat(insideEdge.reverse());
    svg.append('path')
	.style('stroke-width', 0)
	.style('stroke', 'none')
	.style('fill', op.color)
	.attr('d', lineFunc(polygon));
};
And here is a result:
This is using 20 points on the edges; that seems to be good enough to give a smooth curve at these sizes.  By setting the color to white and placing it immediately behind the ordinal direction labels in this compass, I can now mask out the line:

A reminder that if you want to follow along from home, you need to download the Part 9 branch from the Procedural Map Compasses repository on Github and get the test web page up.  Part 1 has instructions, but the short version (on Windows) is to double-click mongoose-free-6.9.exe to start up a web server in the Part 9 directory, and then select the test.html link.  Obviously you can browse the code and other files, and make changes of your own.  You can also try out this code on the web at https://dragonsabound-part9.netlify.app/test.html although you'll only be able to run the code, not modify it. 

Suggestions to Explore
  • I've suggested that RARC can be used to draw the alternating black and white scale as used in this compass:

    Write the CDL for this compass and generate it.  How did you handle the lines around the white arcs?

  • For map borders, I sometimes generate a scale with three colors:
    Create a compass with a three-colored scale with all the colors outlined.  How did you handle the lines between the arcs?

  • This compass has triangles at the ordinal points on the outer scale that have black and white halves like compass points:
    Implement this as an option in RTRI.  Ignoring the hatched shading, can you now recreate the entire compass?