7
\$\begingroup\$

A while ago, I wrote a program that combined L-systems and turtles. This was before I understood OOP (I still don't fully understand classes, or if they are even needed here). I tried to add some documentation and included some demos as functions. If I ever get around to it, I hope to include a simple GUI with user-adjustable patterns and parameters (like a typical Java applet).

Generate L-system

The inclusion of V, the alphabet, is for completeness in the formal description of a L-system as a 3-tuple.

import turtle

def l_system(V, w, P, n):
    """Generates an L-system run for n rounds.
    They are defined as
    G = (V, w, P)

    V = The alphabet (tuple, not actually used, can be specified as None)
    w = The start (string)
    P = The production rules (dictionary for replacement)
    """
    # Make sure all production rules are in alphabet
    if V:
        assert(all(key in V for key in P))

    current = w
    for i in range(n):
        current = [P[x] if x in P else x for x in list(current)]
        current = ''.join(current)

    return current

Run the turtle

def run_turtle(var, start, rules, iters, angle, startdir=0):
    """Var, start, rules and iters, correspond to (V, w, P, n) of the 
    l-system function. The distance moved is scaled down from size.
    The turtle starts facing startdir.

    Instructions are defined as the following:
    F, G: Draw forward
    M, N: Move forward (don't draw)
    [, ]: Push and pop angle and location
    +, -: Turn left and right by angle degrees   
    Variables not described can be used as constants.
    """

    # Initialization 
    terry = turtle.Turtle()
    turtle.mode("world") # Coordinate system
    terry.pensize(1)
    terry.pencolor("blue")
    terry.speed(0) # Instant speed
    turtle.tracer(0, 0) # Don't draw anything yet (could change in future)
    turtle.setup(width=900, height=900, startx=None, starty=None) # Square pixels
    terry.hideturtle()


    dist = 1
    positions = []
    angles = []
    bounds = [0, 0, 0, 0] # llx, lly, urx, ury

    instructions = l_system(var, start, rules, iters)
    print("First 50 instructions:\n", instructions[:50])

    def update_bounds(bounds):
        coords = terry.position()

        bounds[0] = min(bounds[0], coords[0])
        bounds[1] = min(bounds[1], coords[1])
        bounds[2] = max(bounds[2], coords[0])
        bounds[3] = max(bounds[3], coords[1])

    # Run turtle
    terry.left(startdir) # Starting direction
    for instr in instructions:
        if instr in ('F', 'G'):
            terry.forward(dist)
            update_bounds(bounds)

        elif instr in ('M', 'N'):
            terry.penup()
            terry.forward(dist)
            terry.pendown()
            update_bounds(bounds)

        elif instr == '[':
            positions.append(terry.pos())
            angles.append(terry.heading())

        elif instr == ']':
            terry.penup()
            terry.goto(positions.pop())
            terry.setheading(angles.pop())
            terry.pendown()

        elif instr == '+':
            terry.left(angle)

        elif instr == '-':
            terry.right(angle)


    llx, lly, urx, ury = bounds
    width = urx - llx
    height = ury - lly

    if width > height: 
        y_center = (ury + lly)/2
        ury = y_center + width/2
        lly = y_center - width/2
    else: 
        x_center = (urx + llx)/2
        urx = x_center + height/2
        llx = x_center - height/2

    print("Bounds:", bounds)
    turtle.setworldcoordinates(llx, lly, urx, ury) # Redraw
    turtle.update() # Draw everything
    turtle.exitonclick()

Demo systems

Ideally this area could be cleaned up.

def right_koch(iters):
    run_turtle(('F',), 'F', {'F':'F+F-F-F+F'}, iters, 90)

def dragon_curve(iters):
    run_turtle(('X', 'Y'), 'FX', {'X':'X+YF', 'Y':'FX-Y'}, iters, 90)

def sierpinski(iters):
    run_turtle(('F', 'G'), 'F', {'F':'G-F-G', 'G':'F+G+F'},  iters, 60)

def plant_1(iters):
    run_turtle(('F', 'G'), 'F', {'G':'GG', 'F':'G[+F]-F'}, iters, 45, startdir=90)

def plant_2(iters):
    run_turtle(('X', 'F'), 'X', {'X':'F-[[X]+X]+F[+FX]-X', 'F':'FF'},
               iters=iters, angle=360-25, startdir=70)

def hilbert_curve(iters):
    run_turtle(('A', 'B'), 'A', {'A':'-BF+AFA+FB-', 'B':'+AF-BFB-FA+'},
               iters=iters, angle=90)

def koch_island(iters):
    run_turtle(('F',), 'F-F-F-F', {'F':'F+FF-FF-F-F+F+FF-F-F+F+FF+FF-F'}, 
               iters=iters, angle=90)

def square_koch(iters):
    run_turtle(('F',), 'F-F-F-F', {'F':'FF-F-F-F-FF'}, iters, 90)

def plant_3(iters):
    run_turtle(('F', 'X'), 'X', {'X':'F[+X]F[-X]+X', 'F':'FF'}, iters,
               20, startdir=90)

def koch_burst(iters):
    # Own design
    run_turtle(('F'), 'F++F++F++F++F', {'F':'F+F--FF++F-F'}, iters, 72,
               startdir=180)


koch_burst(4)
\$\endgroup\$
3
\$\begingroup\$

I like the concept of the question (using a domain-specific language to specify a fractal) and your implementation. An object-oriented rewrite could make it better, but it's not bad as it is.

l_system() could be better written as

def l_system(V, w, P, n):
    …
    cmd_seq = w
    for _ in range(n):
        cmd_seq = itertools.chain(*(P.get(cmd, cmd) for cmd in cmd_seq))
    return ''.join(cmd_seq)

Namely,

  • cmd_seq would be more meaningful than current. cmd would be more meaningful than x.
  • Use _ for a variable whose value is disregarded.
  • String are directly iterable; you don't have to convert them into lists first.
  • Dictionary lookup with a default can be done using get(key, default).
  • Instead of re-forming the string with each iteration, just chain them, and join the result just once at the end.

Aside from that, I just have a few minor remarks:

  • The parameters are l_system(V, w, P, n) but run_turtle(var, start, rules, iters, …) — why not use consistent notation?
  • As you said, the l_system() function doesn't really need V. I would prefer to just leave it out altogether.
  • In various places, you refer to the turtle's heading as terry.heading(), startdir, and angles. I suggest using consistent terminology based on "heading".
  • update_bounds(bounds) takes a bounds array as an explicit parameter, but takes terry through its scope chain. I'd prefer to see either both variables as explicit parameters, or both via the closure.
  • Rather than having two separate stacks for positions and angles, I would prefer to see one turtle_state stack that stores (position, heading) tuples.
  • The epilogue could use a comment, like # Extend the shorter dimension of the window to make it square
\$\endgroup\$
  • \$\begingroup\$ Do you think the demo functions are fine the way they are, or could they be modified? \$\endgroup\$ – qwr Dec 17 '15 at 13:20
  • \$\begingroup\$ One thing you could do is add another level of indirection, and put all those demo curve definitions in a dictionary or a config file. \$\endgroup\$ – 200_success Dec 17 '15 at 16:05
1
\$\begingroup\$

You seem to expect your var (or V) parameter to contain exactly P.keys().

First of, you could always check that using assert set(V) == set(P.keys()), which I find more understandable at first. Also note that assert is a keyword, not a function.

Second, and since you don't make use of V anyway, you could automate this check without having the user build V for you:

  • You want every symbol found in start and each rewritten rule to be in "FCMN[]+-" + P.keys();
  • [Optional] You want every symbol in P.keys() to appear in start or the rewritten rules so that there is no unused rules;
  • [Optional] You want rules to operate on a single-character string, otherwise they won't be used.

You can get rid of both V and var, and check that, in l_system using:

assert all(len(R) == 1 for R in P), "Rules need to apply on 1 character only"
key_set = set(P.keys())
rules_set = set(itertools.chain(w, *P.values()))
assert key_set <= rules_set, "{} Rules Unused".format(key_set - rules_set)
key_set.update("FGMN[]+-")
assert rules_set <= key_set, "{} Symbols without Rules".format(rules_set - key_set)

Note this is just a rough sketch, you could wrap it in an if __debug__: (even if redundant with assert) to avoid building sets in optimized mode, you could turn some of them into warning messages with print("...", file=sys.stderr) and/or some of them into proper exceptions (possibly ValueError).

But the key point here is that you avoid requiring the user to provide two redundant parameters.

\$\endgroup\$

Your Answer

By clicking “Post Your Answer”, you agree to our terms of service, privacy policy and cookie policy

Not the answer you're looking for? Browse other questions tagged or ask your own question.