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?

2 comments:

  1. Loving it so far!
    The tips of the points look a bit blunt. You could try different values of stroke-linejoin.

    ReplyDelete
  2. That last one (before the suggestions) really follows you around the room!

    ReplyDelete

Note: Only a member of this blog may post a comment.