## Sunday, March 27, 2022

### Map Compasses (Part 15): Scales

Welcome back to my blog series on implementing procedurally-generated map compasses!  Last time I started working on the rules for procedural generation of compasses, and did an initial set of rules for a “two layer" compass.  So far I've only implemented one type of compass, with pointers and a circular decoration behind them:

I will continue to elaborate on this type of compass by adding some variety to the circular decoration (the “ring").  One new subtype is the scale, as in this example:
A scale is a radial arc sandwiched between two thin circles:
<scaleRing> => <thinCircle> RARC(0, 8, Math.PI/8, 5, "black") SPACE(5) <thinCircle> ;
As usual, I'll get one example working and then generalize it.  The above rule gives me this:

A starting point to add Type 1 procgen is to change the number of divisions on the scale.  The above is 8; 16 often works as well:
Although sometimes a fat compass point will completely cover a division of the scale and that looks off to my eye:
The lesson here is probably to cut down a little on the widest compass points, since I don't really love the look of those anyway.
A bit surprising to me, but 32 divisions look good as well.  More surprising is that any even number of divisions looks okay:
This example is 14.  You can see that North and South are on black divisions, East and West are on white divisions, and the intercardinal points are splitting the difference, but if I didn't point it out you probably wouldn't think twice about it.
Odd numbers of divisions really do look odd and I think I can safely avoid those.

Another variation of the scale is where to start the divisions.  In the examples above the divisions are centered at North, but I could slide those over so that they start at North instead:
That looks fine, I guess?  Lastly, I can vary the width of the scale.  I tried a range from 2 to 6 pixels, and that looks okay to me:
That's about it for the scale itself.  At this point I have these rules:
<$scaleDivisions> => Utils.randIntRange(4,16)*2 ; <$scaleStart> => 0 | Math.PI/<$scaleDivisions>/2 ; <$scaleWidth> => Utils.randRange(2,6);
<$scaleCircle> => <thinCircle> | <thinCircle> SPACE(<$thinWidth>) <thinCircle>;
# A scale
<scaleRing> => <$scaleCircle> RARC(<$scaleStart>, <$scaleDivisions>, Math.PI/<$scaleDivisions>,
<$scaleWidth>, "black") SPACE(<$scaleWidth>) <$scaleCircle>; Thinking about the two circles that border the scale, I can dress those up by making them double circles. It's tempting to write a rule like this that can draw either a single circle or a double circle: # A scale <scaleRing> => <scaleCircle> RARC($scaleStart, $scaleDivisions, Math.PI/$scaleDivisions,
$scaleWidth, "black") SPACE($scaleWidth) <scaleCircle>;
<scaleCircle> => <thinCircle> | <thinCircle> SPACE(<$thinWidth>) <thinCircle> ;  Do you see why this won't work? The <scaleCircle> rule will be invoked twice, once for the outside circle and once for the inside circle, and there's no guarantee that the rule will pick the same type of circle both times. Instead, I need to use a one-time rule: # A scale <scaleRing> => <$scaleCircle>
RARC($scaleStart,$scaleDivisions, Math.PI/$scaleDivisions,$scaleWidth, "black")
SPACE($scaleWidth)$scaleCircle;
<$scaleCircle> => <thinCircle> | <thinCircle> SPACE($thinWidth) <thinCircle>;

I think the double circles look quite nice actually.

As a last experiment, let me try a rule for a compass with two rings:
<bottomLayer> => SPACE(Utils.randIntRange(10,30)) <ring> REMEMBER("insideEdge")
SPACE(3) <ring>;

This is the same as the existing rule, just tacking on a little separation and then a second ring:
For two rings I should probably start further out (by reducing the range on that first SPACE command) so that it doesn't get crowded.
Some examples don't look as good.  Two scales looks odd:
It also looks wrong to me to have the scale on the inner ring:
Although that might be a matter of taste.  Both of these can be addressed by changing the rules so that scales can only appear in the outer ring:
<bottomLayer> => SPACE(Utils.randIntRange(5,20)) <ringOrScale> REMEMBER("insideEdge")
SPACE(3) <ring>;
<ring> => <thickThin> | <thinThick> | <thinThinThin> | <thinThickThin>;
<ringOrScale> => <thickThin> | <thinThick> | <thinThinThin> | <thinThickThin> | <scaleRing>;
In the course of writing the two ring rules, I accidentally put two scales on top of each other to interesting effect:
Intentionally, this is done by putting a second scale with more divisions on top of the original scale:
<scaleRing> => <$scaleCircle> RARC(Math.PI/<$scaleDivisions>/2, <$scaleDivisions>, Math.PI/<$scaleDivisions>,
<$scaleWidth>, "black") SPACE(<$scaleWidth>/3)
RARC(Math.PI/<$scaleDivisions>/2, <$scaleDivisions>*2,
Math.PI/(2*<$scaleDivisions>), <$scaleWidth>/3, "black")
SPACE(2*<$scaleWidth>/3) <$scaleCircle>;

The first RARC draws the back scale, the space moves in a third of the way, and then the second arc draws the overlay scale, and the final space moves the rest of the way.

There are some obvious variants:
The rules for these can be found in compass.rules.  These are a little outside the tradition of compass scales, so I'll adjust the weights so that they're uncommon results.

Another kind of scale doesn't alternate black and white but just has empty boxes, like the outer rings on these examples:
These are made in a slightly different way.  We still have the inner and outer circles, but instead of radial arcs there will be radial lines in-between.
<scaleRing> => <$scaleCircle> RLINE(0, <$scaleDivisions>, <$scaleWidth>, 1, "black") SPACE(<$scaleWidth>) <$scaleCircle>;  The third argument to RLINE is the length of the line, and that's the distance between the two circles. There are also a number of example compasses where the main scale of this type has a finer scale inside of it. This is a fairly easy extension: <$scaleDivisions8> => Utils.randIntRange(1,4)*8 ;
<scaleRing> => <$scaleCircle> RLINE(0, <$scaleDivisions8>, <$scaleWidth>, <$thinWidth>, "black")
SPACE(<$scaleWidth>) <thinCircle> RLINE(0, <$scaleDivisions8>*4, <$scaleWidth>, <$thinWidth>, "black")
SPACE(<$scaleWidth>) <$scaleCircle>;


Here I'm forcing the scale to be a multiple of eight because other values end up with the intercardinal points not lining up with both scales.

Next time I'll continue with more elaboration of this compass type, but I want to point out that these rules are already generating a diverse set of compasses (albeit all in the same style family).

And I really haven't done anything very “creative" to get here -- I've just recreated some example compasses and then straightforwardly applied some Type 1, Type 2 and Type 4 elaborations.  This illustrates two important points.  First, procedural generation doesn't necessarily require great insight or creativity.  You can get perfectly acceptable results through a simple step-by-step process of elaboration.  Second, all creativity is built upon a foundation of well-learned (and hence mechanical skills).  Great artists spend years and years drawing and painting scenes over and over until they can render people and trees and many other things without any conscious effort.  Obviously building a procedural generation system is a much different type of endeavor, but this (sometimes tedious) work of writing very specific rules, generalizing them, writing more special cases and so on is a similar kind of foundational work.

A reminder that if you want to follow along from home, you need to download the Part 15 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 15 directory, and then select the test.html link.  You'll find the rules for the compass above in the compass.rules file and you can experiment with changing and extending the rules.  You can also try out this code on the web at https://dragonsabound-part15.netlify.app/test.html although you'll only be able to run the code, not modify it.  But the code is good enough at this point that it's fun to just run a few times and look at the different compasses.

#### Suggestions to Explore

• In the example with two scales, both scales have the same number of divisions and width.  Why is that?

• Write the rules for a scale like the last example above where the outer scale is just lines and the inner, finer scale is black and white arcs.  And vice-versa.

• This compass has a number of interesting features:

It has rings that are just diamonds, or circles inside of rings.  Implement those as ring options.  It also has circles on the ends of the compass points.  Implement that.

## Wednesday, March 2, 2022

### Map Compasses (Part 14): Lodestone Loader & First Compasses

Welcome back to my blog series on implementing procedurally-generated map compasses!  Last time I completed the implementation of the Lodestone rules engine that is able to read an execute rules like these:

<$pi> => Math.PI; <start> => <first> [1] | <second> [99+99]; <first> => I'm surprised this was selected!; <second> => The value of pi is <$pi>;


But entering these rules as a Javascript string is painful, so today I'm going to start off by building a little utility that will read in a file of rules and prepare them for use.

But there's one -- or really two -- complications when you try to read a file from the browser.  For security reasons, Javascript running in the browser is not permitted to read in files.  This makes sense -- you don't want some random website looking around on your computer for MyPasswords.txt or any other sensitive information.  What the browser is allowed to do is read any file or web page it can reach with a URL.  So the browser can't read a file of rules from the local directory, but it can read the same file from a web server.  This is why this project is set up to use the Mongoose web server.  The code can ask the web server to give it files that are located in the project directory, such as a file of rules or a local font file.

So the first complication is that we have to get to the rules file through the web server rather than loading it directly.  The second complication is that the browser mechanism for fetching a file this way works asynchronously, meaning it doesn't wait around for the file to be fetched, it just promises to let you know later when it's done.  There are actually two asynchronous steps to this process.  Let me start with the first and easiest step -- fetching the contents of the URL as a “blob."
    // Gets the response and returns it as a blob
let blob = await fetch('compass.rules').then(res => res.blob());
To do that, I use the browser's fetch() function, which takes a URL and fetches the contents of the URL.  In this case, the URL is just the name of the file with the rules in it.  Since this file is in the same directory as the file with the Javascript, I can use this sort of relative URL.  The .then() method is executed when the fetch completes, and takes the result (which has a bunch of information) and pulls off the “blob."  The details of the blob aren't important; it's just an internal format that could be text, an image, or anything else that could be fetched by URL.

But since fetch is an asynchronous function, it returns immediately, before it has fetched anything and before the then() has pulled out the blob.  The purpose of the await keyword in front of the fetch is to force the Javascript engine to stop and wait for the fetch (really the then) to complete.  So in effect, the await keyword turns the asynchronous function into a synchronous function.

Now I need to turn the blob into text.  Javascript provides a mechanism to do this called a FileReader, which has a method readAsText that takes a blob and returns text.  Or at least it eventually returns text, because like fetch, FileReader is asynchronous.  You might think then that I could use await like this:
const compassRulesReader = new FileReader();

But this would be too easy.  You see, await is built on something called Promises, and FileReader is from before Promises were added to Javascript.  So it uses an older and uglier way to accomplish the same thing, something called event handlers.  Fortunately, it is possible to wrap FileReader in a Promise so that it can be used with await.  I won't show that code here -- you can see it at the link above or in the compass.js file.  But with that wrapper in place I can write:
    const blob = await fetch('compass.rules').then(res => res.blob());

And at the end of that, text holds the text of the compass.rules file. I can put these two awaits together and put this at the top level of the compass module as so:
const compassRulesText = await fetch('compass.rules')
.then(res => res.blob())

When you put an await at the top level of a module like this, you essentially pause the loading of the module until the await completes.  In this case, that's fine, but in many cases you want to avoid doing that so that you can immediately use the parts of the module that aren't waiting.

Now that I have the text of the compass.rules file, I can prepare those rules and then execute them to create a compass description in CDL and pass it to the interpreter:
function test(svg) {
const rules = prepareLodestone(compassRulesText);
const cdl = executeRules('<compass>', rules, s => eval(s));
const result = interpretCDL(svg, parseCDL(cdl, true), [100,100], 75, true);
};

Of course, I'll need to actually have some rules in compass.rules that will produce a compass :-) so let me start in on that.  (Finally!  We've been waiting!)  In the past posts I've used these two compasses as examples:
These are what I think of as “two-layer" compasses.  They've got a bottom layer consisting of a circular decoration, and then a top layer of compass points.  There are a few other two-layer compasses in the example compasses, but I'll start with these two.

To begin with, I'll put together rules to produce the left-hand example.
<compass> => <twoLayerCompass>;
<twoLayerCompass> => <bottomLayer> <topLayer>;
<bottomLayer> => SPACE(23) <ring>;
<ring> => <thickCircle> SPACE(3) <thinCircle>;
<thickCircle> => CIRCLE(<$thickWidth>, "black", "none"); <thinCircle> => CIRCLE(<$thinWidth>, "black", "none");
<$thickWidth> => 4 | 3.5 | 3; <$thinWidth> => 1.5 | 1 | 0.5;
<topLayer> => <interCardinalPoints> <cardinalPoints>;
<interCardinalPoints> => RPOINT(0.7854, 4, 0.85, 1, 1, "black", "white", "black");
<cardinalPoints> => SPACE(-22) RPOINT(0, 4, 0.85, 1, 1, "black", "white", "black");
This reproduces something fairly close to the example compass above, but that's all it can do.  It's stuck at Level 0 of procedural generation, only able to produce one object.  But by individually generalizing the parameters in these rules I can transform this into a Level 1 procedural generator.

<bottomLayer> => SPACE(23) <ring>;


How can I generalize this rule to increase the space of compasses it produces?  The obvious first candidate is to turn the specific 23 pixel space into a range of possible spaces.  It isn't immediately obvious what a reasonable range is, but I can write the rule and try it out to narrow down.  To pick a random range of values I could write a rule like this:

distance => 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27;


But those sorts of rules get tedious very quickly, so I prefer to use the embedded Javascript capability to have Javascript generate a random number in a range:

<bottomLayer> => SPACE(Math.random()*40+10) <ring>;
In this case in the range from 10 to 50.  It's handy to have some utility functions to deal with random numbers, so I've included some in utils.js and I'll use one here instead of Math.random:
<bottomLayer> => SPACE(Utils.randIntRange(10,50)) <ring>;

This function generates an random integer in the given range.  But rather than moving the ring in closer or farther away from the center, this seems to grow or shrink the whole compass:
So what's going on here?  The problem is not in this rule but in a related rule:
<cardinalPoints> => SPACE(-22) RPOINT(0, 4, 0.85, 1, 1, "black", "white", "black");

The SPACE(-22) here is intended to undo the SPACE(23) in the <bottomLayer> rule.  But when the value in that rule isn't 23, everything breaks.

There are a number of ways you might fix this.  But I think what's really needed here is a method in CDL to remember a location and return to it later.  I implemented a version of this for MBDL called PUSH and POP that let you return to the last location you had remembered.  But for CDL I think it will be better to have the capability to remember and return to multiple locations by name, so I'm going to implement two new commands.  REMEMBER(name) will remember the current location as “name" and RECALL(name) will return to that location.

Implementing this is not difficult.  First I will add those commands to the Nearley parser:
# Remember a location
rememberElement -> "REMEMBER"i WS "(" WS dqstring WS ")" WS
{% data => ({op: "REMEMBER", name: data[4]}) %}
# Recall a location
recallElement -> "RECALL"i WS "(" WS dqstring WS ")" WS
{% data => ({op: "RECALL", name: data[4]}) %}

If you remember your Nearley this is pretty straightforward.

Then I'll implement the two new operations in interpretCDL in compass.js:
    const memory = {};

} else if (op.op == 'REMEMBER') {	    memory[op.name] = radius;
} else if (op.op == 'RECALL') {
}

memory is a Javascript dictionary that will keep track of the remembered locations.  To REMEMBER a location, we set the name of the location in the dictionary to the current value of the radius.  (Which is where we're drawing.)  To RECALL a location, we get the remembered location out of the dictionary and set radius to that value.

With that in place, I can now modify the compass rules as so:
<twoLayerCompass> => REMEMBER("start") <bottomLayer> <topLayer>;
<bottomLayer> => SPACE(Utils.randIntRange(10,35)) <ring> REMEMBER("insideEdge");
<interCardinalPoints> => RECALL("insideEdge") RPOINT(0.7854, 4, 0.85, 1, 1, "black", "white", "black");
<cardinalPoints> => RECALL("start") RPOINT(0, 4, 0.85, 1, 1, "black", "white", "black");

I have to remember and return to two locations.  The first is the start location, so I can go back there to draw the cardinal (N/S/E/W) points.  I also need to remember where the inside edge of the bottom layer is, because I want the intercardinal (NE/SE/SW/NW) points to just reach the inside edge.

And now even when the bottom layer ring is far out, or far in towards the center, the cardinal points still reach to the outside of the compass:
So now I can play around a bit and pick a range for this parameter.  10 to 30 seems to be reasonable to me, but you can make your own decision.  This process of selecting independent random ranges for the parameters of the individual elements I called Level 1 procedural generation in a previous posting.

Now that I'm repeatedly generating compasses to check the range on parameters, I'm running into a problem that I noted in the Suggestions to Explore last time -- if you hit the Test button more than once, the new compass gets drawn on top of the old compass:
Which can look interesting sometimes but does make it difficult to see what is going on.  To fix this, before we draw a new compass we need to remove the old compass.  Remember that the compass on the screen is just a number of SVG commands within the SVG element on the web page, so what we need to do is delete all the commands within that SVG element.  In D3, this is easy:
function test(svg) {
// Remove any previous compass
svg.selectAll('*').remove();
const rules = prepareLodestone(compassRulesText);
const cdl = executeRules('<compass>', rules, s => eval(s));
const result = interpretCDL(svg, parseCDL(cdl, true), [100,100], 75, true);
};
selectAll is a D3 function that lets us act on all the elements that match an expression.  In this case, the wildcard '*' matches all the elements within the SVG element, and then the remove() method removes those elements.  This has the effect of clearing the SVG before the new compass is drawn.

Another parameter I can vary is the width of the compass points.  Some experimentation suggests that a range of 0.80 to 0.925 works well.  I also already have rules for the sizes of thick and thin lines, so that's about the range of Level 1 generation for this example.

Level 2 of procedural generation is multi-part constraints.  There are some of these already built into this compass -- namely that the intercardinal points are constrained to start at the inner edge of the ring.  That's certainly something we could experiment with.
That looks okay to me.  A negative spacing from the inside ring is interesting if it happens to hit the thick ring:
We don't have external constraints (Level 3) yet, so let's briefly move on to Level 4, independent subtypes.  The obvious subtypes with these compasses are the design of the ring.  Last time I did some ring subtypes to illustrate RiTa rules, so I can resurrect those here to add thin-thick and the other variants.
<ring> => <thickThin> | <thinThick> | <thinThinThin> | <thinThickThin>;
<thickThin> => <thickCircle> SPACE(3) <thinCircle>;
<thinThick> => <thinCircle> SPACE(3) <thickCircle>;
<thinThinThin> => <thinCircle> SPACE(3) <thinCircle> SPACE(3) <thinCircle>;
<thinThickThin> => <thinCircle> SPACE(3) <thickCircle> SPACE(3) <thinCircle>;
When I added these rules I realized that I'd missed a Level 1 opportunity -- the space between the circles could be varied.  I find this pretty common -- I'll work back and forth across the different levels of procedural generation, and making a change at one level will cause me to go back and add related changes at different levels.

With those rules in place I'm now generating a wider variety of compasses, although they're all still obviously in the same family:
As you add rules and complexity to the procedural generation and generate examples, you'll likely come across some results that you don't like.
When this happens, I like to do a little analysis to understand why I don't like the result.  (Interestingly, in coming back to review this post weeks later, I think this compass looks fine.  There's a lesson there!)  In this case, I think the intercardinal pointers look wrong.  Why is that?  Perhaps because they're too narrow, which would direct me towards reconsidering the Level 1 constraint on that part of the generation.  Or maybe they're only too narrow in comparison to the fairly thick cardinal pointers.  That would suggest some kind of Level 2 constraint between those two parts of the compass.

I also think about whether or not I should simply accept this compass, even if I don't like it a lot.  There's a trade-off between getting a wide variety of interesting results from procedural generation and accepting some poor results.  If you try to “clamp down" too much to avoid unhappy results you end up with something that can generate only in a limited area of the creative space and that's usually pretty boring (if competent).  So it might be better to accept some level of bad results, particularly if you can toss those out and just use the good results.  On the other hand, this can be a trap if you're building something like Dragons Abound with many different procedural generation systems.  If each system has only a 1% chance of getting a bad result, but you have 100 systems, then your chance of getting all 100 good results is only about 40% (!).  (The power of combinatorics.)  So you'll be throwing out a lot of bad results.

Let me finish off this compass by adding labels to the cardinal points.  To do this I'll draw the labels first and then draw the compass inside the labels.
<twoLayerCompass> => <labels> REMEMBER("start") <bottomLayer> <topLayer>;
<labels> => RTEXT(0, 4, "Serif", 16, "black", "", "bolder", "vertical", '["N", "E", "S", "W"]') SPACE(8);

(Note that I have to put the array of labels in single quotes.  Otherwise Lodestone is going to interpret them as a weight -- Lodestone expects anything between square brackets to be a rule weight.)

The labels are an example of where we might get a Level 3 external constraint in procedural compass generation.  Rather than always use the same font, or pick a font randomly, we might want to use a font that matches (or complements) the fonts we're using in the rest of the map.  But for now I don't have that constraint, so I'll modify this to add some Level 1 variation in the font, size, and type of the labels.
<twoLayerCompass> => <labels> REMEMBER("start") <bottomLayer> <topLayer>;
<$labelFont> => "Serif" | "Lobster" | "IM Fell English"; <$labelSize> => 14 | 16 | 18;
<$labelStyle> => "normal" | "bold" | "bolder"; <labels> => RTEXT(0, 4, <$labelFont>, <$labelSize>, "black", "", <$labelStyle>, "vertical", '["N", "E", "S", "W"]') SPACE(8);
I've added a couple of new font options here: Lobster and IM Fell English.  These are not fonts that the browser knows about by default, so I need to add some code to the test.html web page to load these fonts.  These are Google Fonts, meaning I can load them off of a Google server with this incantation that I've added near the top of test.html:
<link href='https://fonts.googleapis.com/css?family=Lobster' rel='stylesheet' type='text/css'>

(This is a partial answer to one of the Suggestions to Explore in Part 8.)

Now I can now use those fonts in the labels:

The complete rules for this compass now look like this:
<compass> => <twoLayerCompass>;
<twoLayerCompass> => <labels> REMEMBER("start") <bottomLayer> <topLayer>;
<$labelFont> => "Serif" | "Lobster" | "IM Fell English"; <$labelSize> => 14 | 16 | 18;
<$labelStyle> => "normal" | "bold" | "bolder"; <labels> => RTEXT(0, 4, <$labelFont>, <$labelSize>, "black", "", <$labelStyle>, "vertical", '["N", "E", "S", "W"]') SPACE(8);
<bottomLayer> => SPACE(Utils.randIntRange(10,30)) <ring>
REMEMBER("insideEdge");
<ring> => <thickThin> | <thinThick> | <thinThinThin> | <thinThickThin>;
<$ringSpacing> => 1 | 2 | 2 | 3 | 3 | 4; <thickThin> => <thickCircle> SPACE(<$ringSpacing>) <thinCircle>;
<thinThick> => <thinCircle> SPACE(<$ringSpacing>) <thickCircle>; <thinThinThin> => <thinCircle> SPACE(<$ringSpacing>) <thinCircle>
SPACE(<$ringSpacing>) <thinCircle>; <thinThickThin> => <thinCircle> SPACE(<$ringSpacing>) <thickCircle>
SPACE(<$ringSpacing>) <thinCircle>; <$thickWidth> => 4 | 3.5 | 3;
<$thinWidth> => 1.5 | 1 | 0.5; <thickCircle> => CIRCLE(<$thickWidth>, "black", "none");
<thinCircle> => CIRCLE(<\$thinWidth>, "black", "none");
<topLayer> => <interCardinalPoints> <cardinalPoints>;
<interCardinalPoints> => RECALL("insideEdge")
SPACE(Utils.randIntRange(-5,5))
RPOINT(0.7854, 4, Utils.randRange(0.80,0.925),
1, 1, "black", "white", "black");
<cardinalPoints> => RECALL("start")
RPOINT(0, 4, Utils.randRange(0.80,0.925), 1, 1,
"black", "white", "black");
What's striking about this is that I already have 20 rules for generation, just for this one type of compass!  Of course, some of these rules will likely be reused in other compass types, but in general I find that this sort of grammar-based procedural generation leads to many rules, and organizing and keeping track of them can sometimes be a challenge.

A reminder that if you want to follow along from home, you need to download the Part 14 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 14 directory, and then select the test.html link.  You'll find the rules for the compass above in the compass.js file and you can experiment with changing and extending the rules.  You can also try out this code on the web at https://dragonsabound-part14.netlify.app/test.html although you'll only be able to run the code, not modify it.

Suggestions to Explore
• Level 4 procedural generation is independent subtypes.  A rule of this sort for the compass points would be to have wavy compass points for the intercardinal compass points.  Implement this rule.

• There are many more subtypes to explore for the <ring> in this compass style.  Implement the scale from the other example at the top of this posting.  Implement some rings that use radial circle, triangle or diamond elements.

• Suppose you want to implement a meta-subtype for <ring> which would repeat the same ring twice:

In general, is there any way to write rules which will repeat some previous rules?  If not, how would you add that capability?