Skip to content

geomotif.bases.lsystem

Turtle graphics driven by an L-system grammar.

An L-system is a start string plus rewrite rules applied a fixed number of times; the resulting string is then read as turtle instructions. It is the most economical description of the classic fractal curves there is -- Koch, Hilbert, Gosper, dragon, Sierpinski and the rest each reduce to an axiom, one or two rules and a turn angle.

The turtle alphabet, which follows the usual convention:

=========== ================================================== Symbol Meaning =========== ================================================== F G A B move forward, drawing (see :attr:LSystemMotif.draw) f g move forward without drawing, breaking the stroke + turn left by :attr:LSystemMotif.angle - turn right by :attr:LSystemMotif.angle | turn around [ push position and heading ] pop position and heading, starting a new stroke =========== ==================================================

Every other symbol is ignored while drawing, which is what lets grammars use letters like X and Y purely to drive the rewriting.

Classes:

Name Description
LSystemMotif

Base for a motif described by a grammar and drawn with a turtle.

LSystemMotif dataclass

LSystemMotif(*, depth: int = 4, step: float = 1.0, start_angle: float = 0.0)

Bases: Motif, ABC

Base for a motif described by a grammar and drawn with a turtle.

Define :attr:axiom, :attr:rules and :attr:angle as class variables; everything else is a parameter with a sensible default. A concrete fractal is four lines::

@register("koch", family="fractal")
@dataclass(frozen=True, slots=True)
class KochCurve(LSystemMotif):
    axiom = "F"
    rules: ClassVar[Mapping[str, str]] = {"F": "F+F--F+F"}
    angle = math.pi / 3

The ClassVar annotation on :attr:rules is not decoration: without it, a mutable class attribute in a dataclass body is ambiguous to reader and linter alike, and annotating it as anything else would make it a constructor parameter with a mutable default, which dataclasses reject.

Notes

Both the string expansion and the point count grow exponentially with :attr:depth; the expansion is capped so that an accidental depth=20 raises instead of exhausting memory.

Methods:

Name Description
expand

Return the axiom rewritten :attr:depth times.

expand

expand() -> str

Return the axiom rewritten :attr:depth times.

Raises:

Type Description
ValueError

If depth is negative, if no axiom was defined, or if the expansion outgrows what can usefully be drawn.

Source code in src/geomotif/bases/lsystem.py
def expand(self) -> str:
    """Return the axiom rewritten :attr:`depth` times.

    Raises
    ------
    ValueError
        If ``depth`` is negative, if no axiom was defined, or if the
        expansion outgrows what can usefully be drawn.
    """
    if self.depth < 0:
        raise ValueError(f"depth must be >= 0, got {self.depth}")
    if not self.axiom:
        raise ValueError(
            f"{type(self).__name__} must define a non-empty `axiom` class variable"
        )

    current = self.axiom
    for round_number in range(self.depth):
        current = "".join(self.rules.get(symbol, symbol) for symbol in current)
        if len(current) > _MAX_SYMBOLS:
            raise ValueError(
                f"{type(self).__name__} expanded to more than {_MAX_SYMBOLS} symbols "
                f"after {round_number + 1} of {self.depth} rounds; use a smaller depth"
            )
    return current