Thursday, November 25, 2021

Map Compasses (Part 5): Radial Lines and Circles

Welcome back to my blog series on implementing procedurally-generated map compasses!  In the previous post, I write the code for the language commands CIRCLE and SPACE.  In this posting, I'm going to work on commands for radial elements.

Radial elements are the parts of the compass that repeat at regular intervals in a circle around the center point of the compass.  For example, consider this very simple compass:

You could view this compass as having a line repeated every 90 degrees, plus a label “N" repeated every 360 degrees.  A more complex example:
Here's a compass with a lot of radial elements.  Labels of various sorts, but also short lines (and somewhat longer lines) that provide the degrees scale, some triangles to mark the diagonals on that scale, a set of smaller compass points and a set of larger compass points, and even some little dots for decoration in an inner ring.  (And everything else in the compass is some version of CIRCLE.)

To start with, I have to add some commands to the Compass Description Language (CDL) to describe radial elements.  There are a couple of ways I might do this.  I could make a separate command for every type of radial element, or I could do something like RADIAL ... END to wrap around commands that I want to place radially.  I'm going to use separate commands, partially because it makes the interpreter simpler and partially because I'd still need those commands to put inside the RADIAL loop anyway.

But I would like all the radial elements to have a similar syntax.  Looking at those radial elements in the complex example above, there are some parameters that seem to apply to all radial elements.  First, a starting point.  Some elements start at the top of the circle (like the larger compass points above) but some start elsewhere (like the smaller compass points above, which start at 45 degrees).  Second, the number of repeats.  The compass points above repeat 4 times, while the smaller lines in the degree scale repeat 360 times.  Third, there's a length or a size.  Some elements extend all the way to the center of the compass (like the compass points above) but some only extend partway to the center (like the lines that make up the degrees scale above).

Pulling all that together, I'll implement a simple radial line element:

RLINE(start, repeats, length, lineWidth, color)

This command will draw a radial line of the given length in the given color.  The length will start at the current radius and extend in towards the center of the compass.  But I'll also support a negative length, which will let me draw outward as well.  It will be handy to have a shorthand syntax that means “draw all the way to the center of the compass" so that I don't have to keep track of that distance when generating CDL.  Zero is conveniently unused in this scheme, so I'll use a zero length to mean “all the way to the center."

The first step in implementing this is adding it to the parser.   The rule for this command is long but straightforward:

# A radial line element
rlineElement -> "RLINE"i WS "(" WS decimal WS "," WS decimal WS "," WS decimal WS "," WS decimal WS "," WS dqstring WS ")"
{% data => ({op: "RLINE", start: data[4], repeats: data[8], length: data[12], width: data[16], color: data[20]}) %}
Other than the literal elements like the name and the parentheses, most of the command is decimals and double-quoted strings to define the parameters.  As with the other commands, it gets translated during post-processing into a Javascript object with the various parameters saved onto the object.

We also need to modify the list of legal CDL elements to include the RLINE element:
# A list of all the legal CDL elements
Element -> spaceElement | circleElement | rlineElement

A description that includes RLINE will now be parsed.  (Remember that when you modify the Nearley grammar file, you need to recompile it using the command line at the top of the file.)

The next step is to implement RLINE in the interpreter (in compass.js).  First, we need to add RLINE to our if-else in the interpreter that dispatches based on the op code:

	} else if (op.op == 'RLINE') {
// Draw radial lines Draw.rline(svg, center, radius, op.start, op.repeats, op.length, op.width, op.color); if (debug) console.log('Draw radial lines.'); } else {

I've filled in a call here to “Draw.rline" that will do the actual work of drawing the radial lines.  “Op" here is the Javascript structure built in the Nealey rule above, and op.start, etc., are the parameters.  Now I'll need to jump over to draw.js and write that function.

The heart of the function is a loop that draws the proper number of repeats around the circle.  

function rline(svg, center, radius, start, repeats, len, width, color) {
    for(let angle = start; angle < start+Math.PI*2; angle += (Math.PI*2)/repeats) {
    };
};

angle is where I am currently drawing.  It starts at the start angle and loops all the way around and back to the same spot.  I'm working in radians here, so a full circle is 2PI.  Every step advances 2PI/repeats, which means I'll draw repeat number of elements as I go around the circle.  From the angle and the radius I need to calculate the coordinates of the actual point for the element.  Recalling my old friend Sohcahtoa from high school geometry, and remembering to measure from the center, I add those calculations:

function rline(svg, center, radius, start, repeats, len, width, color) {
    for(let angle = start; angle < start+Math.PI*2; angle += (Math.PI*2)/repeats) {
	const sx = center[0] + radius * Math.cos(angle);
	const sy = center[1] + radius * Math.sin(angle);
    };
};

Actually I lied just a little bit right there.  In SVG (and in many computer graphics systems), the Y axis is reversed from your familiar days in high school geometry.  The origin of a window is typically at the upper left, and Y goes up as you move down from the origin.  In practical terms, this means I have to reverse the sign on the calculation of sy:

function rline(svg, center, radius, start, repeats, len, width, color) {
    for(let angle = start; angle < start+Math.PI*2; angle += (Math.PI*2)/repeats) {
	const sx = center[0] + radius * Math.cos(angle);
	const sy = center[1] - radius * Math.sin(angle);
    };
};

(And yes, I messed this up when I first wrote the code.  I always mess this up.)

With that corrected, I can do a similar calculation to determine the end point of the line, but instead of using radius, I'll subtract the length.  So a positive length will result in a point closer to the center, as desired.  And I need to remember the special treatment when length == 0, which indicates a line that goes to the center of the compass.

function rline(svg, center, radius, start, repeats, len, width, color) {
    for(let angle = start; angle < start+Math.PI*2; angle += (Math.PI*2)/repeats) {
	const sx = center[0] + radius * Math.cos(angle);
	const sy = center[1] - radius * Math.sin(angle);
	// Length == 0 indicates a line that goes to the center of the compass
	if (len == 0) len = radius;
	const ex = center[0] + (radius-len) * Math.cosine(angle);
	const ey = center[1] - (radius-len) * Math.sine(angle);
    };
};

Now that I have the start and the end of the line I can call a line drawing routine.  This is very straightforward:

function line(svg, start, end, width, color) {
	svg.append('line')
	    .style('stroke-width', width)
	    .style('stroke', color)
	    .attr('x1', start[0])
	    .attr('y1', start[1])
	    .attr('x2', end[0])
	    .attr('y2', end[1]);
};

This appends an SVG <line> element to the SVG.  Let me feed a CDL that uses a RLINE element into the test routine:

CIRCLE(2, “black", “none") RLINE(0, 8, 4, 2, “black")
And voila!  Eight tick marks around the inside of our circle.  Let's see if drawing to the center of the compass works:
CIRCLE(2, “black", “none") RLINE(0, 8, 0, 2, “black")
Whoo-hoo!  Pizza pie!

Before I get too happy, let's check a couple of other things.  How about a single line:
CIRCLE(2, “black", “none") RLINE(0, 1, 0, 2, “black")
Well, partial success.  It's a single line as expected, but pointing at 3 o'clock rather than 12 o'clock.  In retrospect, this makes sense.  The starting angle is 0, and a zero angle is right on the X axis.  I should have started at 90 degrees instead.  On the other hand, I'm always going to want to think of noon as the zero angle on a compass, so I'll shift the start angle by 90 degrees before I start drawing:
function rline(svg, center, radius, start, repeats, len, width, color) {
    for(let angle = start+(Math.PI/2); angle < start+Math.PI*2; angle += (Math.PI*2)/repeats) {
	const sx = center[0] + radius * Math.cos(angle);
	const sy = center[1] - radius * Math.sin(angle);
	// Length == 0 indicates a line that goes to the center of the compass
	if (len == 0) len = radius;
	const ex = center[0] + (radius-len) * Math.cos(angle);
	const ey = center[1] - (radius-len) * Math.sin(angle);
	line(svg, [sx, sy], [ex, ey], width, color);
    };
};

Now let me quickly implement another radial element, one that draws circles like this:

I won't go through adding this to the parser and the interpreter, as it is much the same as RLINE.  But here is the function that implements RCIRCLE:

function rcircle(svg, center, radius, start, repeats, len, width, color, fill) {
    for(let angle = start+(Math.PI/2); angle < start+Math.PI*2+(Math.PI/2); angle += (Math.PI*2)/repeats) {
	const sx = center[0] + radius * Math.cos(angle);
	const sy = center[1] - radius * Math.sin(angle);
	// Length == 0 indicates a line that goes to the center of the compass
	if (len == 0) len = radius;
	const ex = center[0] + (radius-len) * Math.cos(angle);
	const ey = center[1] - (radius-len) * Math.sin(angle);
	circle(svg, [sx, sy], len, width, color, fill);
    };
};

In this case “len" is the size of the circle to draw so I don't actually need the end point.  I left it in to illustrate how similar RLINE and RCIRCLE are -- they're identical except for the actual call to the drawing function.  All of the radial elements are going to share the same repeats loop and this suggests that can be abstracted out and not rewritten in every element.

To do that, I'll write a function that implements the repeats loop and takes the drawing function as a parameter.  That looks like this:

function repeat(svg, center, radius, start, repeats, op, fn) {
    let angle = start+(Math.PI/2);
    for(let i=0;i<repeats;i++) {
	fn(svg, center, radius, start, repeats, angle, i, op);
	angle += (Math.PI*2)/repeats;
    };
};

The new repeat function loops all the radial placements and calls fn with the SVG, the start angle, the number of repeats, the angle, the iteration and the original operation object.  Some of these parameters aren't needed for RLINE or RCIRCLE but (warning: foreshadowing!) they will be needed for future functions.  In particular, passing in the operation object will give our drawing routines access to the parameters that were in the original CDL command.

Now we can replace the call to RCIRCLE with a call to repeat.  This code in the interpreter:

	} else if (op.op == 'RCIRCLE') {
// Draw radial circles Draw.rcircle(svg, center, radius, op.start, op.repeats, op.length, op.width, op.color, op.fill); if (debug) console.log('Draw radial circles.'); } else {

becomes:

	} else if (op.op == 'RCIRCLE') {
// Draw radial circles Draw.repeat(svg, center, radius, op.start, op.repeats, op, Draw.rcircle); if (debug) console.log('Draw radial circles.'); } else {

Now I have to adjust the Draw.rcircle function slightly to handle the new parameters.

function rcircle(svg, center, radius, start, repeats, angle, i, op) {
    const sx = center[0] + radius * Math.cos(angle);
    const sy = center[1] - radius * Math.sin(angle);
    circle(svg, [sx, sy], op.length, op.width, op.color, op.fill);
};

And then a similar thing for RLINE:

	} else if (op.op == 'RLINE') {
// Draw radial lines Draw.repeat(svg, center, radius, op.start, op.repeats, op, Draw.rline2); if (debug) console.log('Draw radial lines.'); 
	}
I'll define the rline2 function to calculate the start and end points and call Draw.line:
function rline2(svg, center, radius, start, repeats, angle, i, op) {
    // Length == 0 indicates a line that goes to the center of the compass
    if (op.length == 0) op.length = radius;
    const sx = center[0] + radius * Math.cos(angle);
    const sy = center[1] - radius * Math.sin(angle);
    const ex = center[0] + (radius-op.length) * Math.cos(angle);
    const ey = center[1] - (radius-op.length) * Math.sin(angle);
    line(svg, [sx, sy], [ex, ey], op.width, op.color);
};

I've used the kind of awkward rline2 name here because I wanted to leave the original rline function in the code so that you could see it there.  

That's where I'll stop for today.  A reminder that if you want to follow along from home, you can download the Part 5 branch from the Procedural Map Compasses repository on Github.  Part 1 has more detailed 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 5 directory, and then select the test.html link.  Obviously you can browse the code and other files, and make changes of your own.  Try writing some CDL, inserting it into the test function, and using the button to generate it.  You can also try out this code on the web at https://dragonsabound-part5.netlify.app/test.html although you'll only be able to run the code, not modify it.

I've left the code set up to produce this:

But the CDL for all the examples is included in the code in comments just above the test function in compass.js.

Suggestions to Explore
  • Implement RCROSS that draws a radial cross element.  This is the same as RLINE but it adds a horizontal line across the existing line.  How did you deal with getting the horizontal line at the correct angle? Extend RCROSS to an asterisk by adding the two lines at 45 degree angles.

6 comments:

  1. Cool, as always. But this raises a thought that's been bugging me for a while now. Have you considered reusing the frame decoration language here? You've already built a powerful mechanism for specifying decorative twiddles. But if you think of a compass as being a "frame" expressed in polar coordinates, couldn't you adapt it to implement all kinds of decoration magic for compasses as well?

    It would probably require a rewrite of the decoration parser, to abstract out the coordinate system, but in terms of increased expressive power, it strikes me as worth the effort.

    ReplyDelete
  2. That's a very astute comment. I've obviously borrowed some ideas from the border stuff, but I think they're different enough that trying to adapt the border language/code to do a compass would be a lot of work and would result in something that was rather more complicated and fragile than I would like. But it's certainly something I considered.

    ReplyDelete
  3. This is fascinating stuff. The sheer fact that you're creating a Compass Description Language is in itself epic.

    ReplyDelete
    Replies
    1. Thanks! It's not as big a deal as it may seem, and creating a language like this has a lot of leverage when you get around to the actual procedural generation.

      Delete
  4. BTW, the name for these things is "compass rose".

    ReplyDelete
  5. Thanks! I'll actually be using the radial lines and circles to draw a number of different things, but the rose will be one of them.

    ReplyDelete