L-systems
An L-system (Lindenmayer system) grows a string by rewriting its symbols. A turtle then walks that string and draws it. Those two steps are the whole method. A small rule set unfolds into a great deal of self-similar detail: plants, snowflakes, dragon curves, and space-filling curves.
Three pieces define one system. The axiom is the starting string. The production rules say how each symbol rewrites, and every pass applies them to every symbol at once. The iterations count says how many rewrite passes to run. The turtle then reads the final string from left to right.
The output is a set of open [Contour]s, the line-work. It feeds straight into stroking, the shape booleans, hatching, and SVG export for the pen plotter. A run is a pure function of the grammar, and for a stochastic system also of the seed. The same seed therefore always grows the same form.
The turtle alphabet
F, G move forward one step, drawing a line
f move forward one step, pen up (a gap)
+ - turn left / right by the angle
| turn around (180 degrees)
[ ] push / pop the turtle's position and heading (the branch mechanism)
X, Y draw nothing; they only steer the rewriting
The [ / ] stack is what makes plants possible. [ remembers where the turtle is, and ] puts it back there. A branch can therefore grow, and the turtle returns to the trunk to carry on.
Contents
- drawLSystem / lSystem
- Presets
- Building one by hand
- Stochastic systems
- Standalone (outside a sketch)
- Parametric L-systems
drawLSystem / lSystem
drawLSystem(_ system: LSystem, iterations: Int,
in bounds: Rectangle? = nil, padding: Double = 60)
lSystem(_ system: LSystem, iterations: Int,
in bounds: Rectangle? = nil, padding: Double = 60) -> [Contour]
drawLSystem strokes an L-system with the current stroke. lSystem returns its [Contour] line-work instead, so you can color it, transform it, or feed it onward. Both scale and center the result to fill bounds, which is the whole canvas by default, leaving a padding margin. A preset therefore fills the frame whatever its iteration count. For a stochastic system both draw from the seeded random, so seed(_:) fixes the form. isStochastic says whether a grammar is one of those, which is true as soon as any symbol has more than one production.
seed(3)
stroke(.white); strokeWeight(2); strokeCap(.round); noFill()
drawLSystem(.plant, iterations: 5)
The line-work is a pure function of the grammar. So compute it once and keep it in a stored property, rather than rebuilding it every frame. The LSystem example does that, and it caches its whole catalog.
Presets
Each preset is a ready-made LSystem with its canonical rules and angle. Pick an iteration count for the detail you want, and remember that curves take more passes than plants.
| Preset | What it is |
|---|---|
.kochCurve | a line that grows a square bump on each segment |
.kochSnowflake | three Koch curves closed into a snowflake outline |
.sierpinskiTriangle | the Sierpinski gasket traced as one connected curve |
.sierpinskiArrowhead | the same gasket drawn as a single unbroken path |
.dragonCurve | the Heighway dragon, a folded space-filling curve |
.hilbertCurve | a space-filling curve that visits every cell of a grid |
.gosperCurve | the flowsnake, a hexagonal space-filling curve |
.levyCurve | the Lévy C curve, nested right-angle bends |
.peanoCurve | an early space-filling curve on a square grid |
.plant | a fern-like plant that branches and droops |
.bush | an upright bushy weed, the classic branching plant |
.tree | a symmetric binary tree that forks at every tip |
.randomPlant | a stochastic plant, a different form per seed |
// A contact sheet of presets, each fit to a grid cell:
let g = grid(columns: 4, rows: 4)
let presets: [(LSystem, Int)] = [(.dragonCurve, 12), (.hilbertCurve, 5), (.plant, 5), (.tree, 4)]
for (i, (system, iters)) in presets.enumerated() {
drawLSystem(system, iterations: iters, in: g.cells[i].frame, padding: 10)
}
Building one by hand
LSystem(axiom: String, rules: [Character: String], angle: Double) // angle in degrees
An LSystem is a value you build from an axiom, one production per symbol, and the turn angle in degrees. A symbol with no rule is left unchanged, so a control symbol such as X passes straight through.
let koch = LSystem(axiom: "F", rules: ["F": "F+F-F-F+F"], angle: 90)
for c in lSystem(koch, iterations: 4) { drawPolyline(c.points) }
// A plant uses X to steer the rewriting while F draws and [ ] branch:
let fern = LSystem(axiom: "X", rules: ["X": "F+[[X]-X]-F[-FX]+X", "F": "FF"], angle: 25)
For full manual control, call the value's own contours(iterations:step:start:heading:). It takes an explicit step, start point, and heading, and it does not fit the result to bounds.
Stochastic systems
Give a symbol several productions and one of them is chosen at random each time that symbol rewrites. Every run then varies, and a given seed still reproduces the same form. That is what turns an exact fractal into a plant that looks grown rather than printed.
LSystem(axiom: String, choices: [Character: [String]], angle: Double)
seed(9)
let weed = LSystem(axiom: "X",
choices: ["X": ["F[+X]F[-X]+X", "F[-X]F[+X]-X", "F[+X][-X]FX"],
"F": ["FF"]],
angle: 22)
drawLSystem(weed, iterations: 6)
Standalone (outside a sketch)
The Sketch methods are sugar over the LSystem value type, which is self-contained. Its expanded(iterations:) gives the rewritten string, and contours(iterations:step:start:heading:) walks the turtle. A stochastic system takes a random source through using rng:, and a deterministic one needs none.
let string = LSystem.dragonCurve.expanded(iterations: 6) // "F+G+..."
let lines = LSystem.plant.contours(iterations: 5, step: 8) // [Contour]
Parametric L-systems
A plain grammar can only choose which symbols come next, so every length it draws is a whole multiple of one step. A parametric L-system carries the numbers themselves. Its symbols are modules such as A(1.5) or F(x, t), and its rules do arithmetic on them.
That gives you three things a symbolic grammar cannot state:
- a segment exactly three tenths as long as its parent,
- a bud that counts down a few passes before it opens,
- a trunk given its own width as well as its own length, so it tapers.
// A branch that halves at every fork and stops once it gets too short:
let system = ParametricLSystem(
axiom: "A(1)",
rules: ["A(s) : s > 0.02 -> F(s)[+A(s*0.5)][-A(s*0.5)]"],
angle: 30)
stroke(.white); strokeWeight(2); noFill()
drawLSystem(system, iterations: 8)
ParametricLSystem is a separate type, but the Sketch calls are overloads on the same names. So lSystem(…), lSystemMarks(…), and drawLSystem(…) each take either kind.
Writing a rule
Each rule is one string, in the notation the literature uses:
predecessor : condition -> successor : weight
A(s) : s > 0.02 -> F(s)[+A(s/2)]
- The predecessor is an
LSystemModule(letter:parameters:), a letter with names for its parameters. The condition and the successor may use those names, and a name may appear only once. - The condition is optional. Write
*, or leave it out, for a rule that always applies. A false condition is not an error. The rule simply does not apply, and the next rule is tried. - The successor is the modules that replace the predecessor, with an expression wherever a number goes.
- The weight is optional, and Choosing among rules explains it.
A module matches a rule only when the letter and the number of parameters agree. So A, A(x), and A(x, y) are three different things. All three may live in one system:
ParametricLSystem(axiom: "A A(1) A(1,2)",
rules: ["A -> B", "A(x) -> C(x)", "A(x,y) -> D"],
angle: 90)
.expanded(iterations: 1) // "BC(1)D"
A module that matches no rule is left exactly as it is. That is why the turtle's own +, -, and [ need no rules. It is also how a form settles, because once a condition stops holding, that bud stops changing.
Write the arrow as ->, -->, or →, whichever your source uses.
Arithmetic
Expressions take + - * / %, exponentiation ^, comparison < <= > >= == !=, and && || !. A comparison is worth 1 when it holds and 0 when it does not, so you can use one as a number. The functions are sin cos tan asin acos atan floor ceil trunc abs exp log sqrt sign, plus ran(x) for a random number in 0 ..< x.
Two precedence rules are not the ones Swift uses. Both come from the published table:
2^3^2 is 2^(3^2) = 512 exponentiation groups to the RIGHT
2*3^2 is 2*(3^2) = 18 and binds TIGHTER than multiplication
-2^2 is (-2)^2 = 4 unary minus binds tighter still
Trigonometry is in radians. Every angle the turtle turns is in degrees.
Put numbers worth naming in constants, and any rule may then use them:
ParametricLSystem(axiom: "A(1)",
rules: ["A(s) -> F(s)[+A(s/R)][-A(s/R)]"],
angle: 85,
constants: ["R": 1.456])
Comparing with == is exact, as floating point always is. So use a counter that steps down by whole numbers. Guard one rule with d > 0 and the next with d == 0, which is what the published grammars do.
Choosing among rules
With no weights anywhere, the first matching rule wins. Rules are tried in the order you wrote them. A grammar can therefore spell its cases out as a list and let the earlier one take precedence.
Give rules a weight and one of them is picked at random instead:
ParametricLSystem(axiom: "A(1)",
rules: ["A(s) -> F(s)[+A(s*0.6)]A(s*0.8) : 2",
"A(s) -> F(s)[-A(s*0.6)]A(s*0.8) : 1"],
angle: 25)
The numbers are weights, not probabilities, so they need not add up to one. They are shared out among whichever rules actually matched, because a condition rules candidates out first.
A weight may be an expression in the module's own parameters, so the odds can change as a form grows.
Weighting is all or nothing per system. As soon as any rule carries a weight, every choice is made by weight rather than by order. Conditions still filter first, so a grammar whose guards do not overlap behaves the same either way.
The turtle
The turtle is the same one described above, except that each symbol can now carry its own number:
F(a) G(a) move forward a, drawing
f(a) g(a) move forward a, pen up
+(a) -(a) turn left / right by a degrees (a may be negative)
| turn around
[ ] push / pop position, heading, and width
!(w) #(w) set the line width to w
% cut: abandon the rest of this branch
A symbol with no parameter falls back to the system's own angle and step. So + means +(angle), and F means F(1). Ollin ignores extra parameters. That is what lets F(x, t) draw x while t carries a counter the drawing never reads.
Ollin's turtle draws in the plane, so it handles the published three-dimensional symbols only where those stay in the plane. A roll or a pitch of half a turn does stay in the plane, so /(180) keeps the drawing plane and swaps left for right. That is enough for the mirrored members of the tree family below.
Any other roll or pitch draws nothing, and neither do the polygon, color, and surface symbols. The rewriting still carries them through untouched. Each one reports itself once on the console, so a solid model is never flattened without a word.
Width, and drawing it
!(w) sets the pen width. [ and ] save and restore that width along with the pose, so a thin twig never thins the trunk that carries it. Ask for marks instead of contours and the widths come with them:
stroke(.white); strokeWeight(14)
for mark in lSystemMarks(.taperedTree(), iterations: 10) { drawMark(mark) }
// or, the same thing in one call:
drawLSystem(.taperedTree(), iterations: 10, tapered: true)
Widths come back as multiples of strokeWeight, scaled so the widest is exactly 1. So strokeWeight sets the thickness of the trunk. Each point carries its own width, and the width interpolates between points. A branch therefore tapers smoothly rather than stepping at every segment.
Ordinary lSystem(…) returns [Contour] exactly as the symbolic form does. The line-work still feeds the shape booleans, hatching, and SVG export for the pen plotter.
Presets
| Preset | What it is |
|---|---|
.triangleCurve | one unbroken line that packs itself into a triangle, cut into four unequal pieces each pass |
.delayedTriangleCurve | the same, with a counter holding the short pieces back so it fills evenly |
.selfSimilarBranch | a binary branch, each child shorter than its parent by a fixed ratio |
.growingBranch | the same proportions built the other way round, by lengthening every old segment |
.compoundLeaf | a stalk making leaflets in facing pairs while every part goes on lengthening |
.alternatingLeaf | the same, with leaflets on alternate sides |
.snowflake | the snowflake curve written with thirds rather than whole steps |
.mesotonicBranch | a form widest partway up, which a plain grammar provably cannot make |
.taperedTree(…) | a tree given its own width as well as its own length, drawn with tapered |
.randomBranch | a weighted branch that leans a different way for each seed |
taperedTree is the one preset with parameters, because it stands for a whole family of trees. Its defaults are the first row of a published table of nine trees. Three more of them are worth typing out:
.taperedTree(contraction1: 0.65, contraction2: 0.71, angle1: 27, angle2: -68,
width: 20, split: 0.53, exponent: 0.5, minLength: 1.7) // 12 passes
.taperedTree(contraction1: 0.50, contraction2: 0.85, angle1: 25, angle2: -15,
roll1: 180, width: 20, split: 0.45, exponent: 0.5, minLength: 0.5) // 9
.taperedTree(contraction1: 0.92, contraction2: 0.37, angle1: 0, angle2: 60,
roll1: 180, width: 2, split: 0.5, exponent: 0, minLength: 0.5) // 15
minLength is the termination guard. Growth stops in a branch once that branch would be shorter than the guard, so the form settles instead of running out of passes.
Reading the word yourself
The turtle is not the only way to read the grown word. modules(iterations:) hands that word back as its modules, so a sketch can draw something of its own for each one:
for module in system.modules(iterations: 6) where module.letter == "F" {
let length = module.parameters.first ?? 1
// ... place a leaf, a glyph, a mesh, whatever the length suggests
}
expanded(iterations:) gives you the same word written back out as text, which is the quickest way to see what a rule is doing.
When a rule will not read
A rule that cannot be parsed costs you that rule and not the sketch. Ollin leaves the rule out, and the modules it should have rewritten stay as they are. The reason is printed once, and it is also on the value:
let system = ParametricLSystem(axiom: "A(1)", rules: ["A(s) -> F(q)"], angle: 90)
system.errors // ["rule 'A(s) -> F(q)': 'q' is not a parameter of this rule, a constant, or a function"]
Related: Marks (the StrokeMark a tapered system draws through), Truchet tiling (another seed-driven line-work generator), Geometry (the Contour type and the shape booleans the output feeds), Random (the seed that drives a stochastic system).