DEV Community

Cover image for Generating Trees Images on Canvas Using L-Systems, TypeScript and OOP
Alex Bespoyasov
Alex Bespoyasov

Posted on • Updated on • Originally published at bespoyasov.me

Generating Trees Images on Canvas Using L-Systems, TypeScript and OOP

Frontend development and Object-Oriented Programming are closer than they might seem. The SOLID principles and Clean Architecture can be used to create frontend applications and they provide convenient tools for the development.

In this series of 3 posts, we will create an image generator that will draw trees on canvas. We will accent the architecture and write the code in compliance with OOP principles.

I will show the basics of system design and examples of using TypeScript for writing code in the OOP style.

As a result, we will create an application that will draw images like this one:

Generated tree image

Prerequisites

In this post, I suppose you know the basics of OOP, the difference between classes and interfaces, and how dependency injection works.

I've got a post about dependency injection. It might be worth reading for a better understanding of DI and DI-containers.

Also, we will refer to SOLID principles. You don't need to know them in detail but it's better if you've heard something about them.

What We'll Cover in the First Part

In this post, we're going to design the application architecture using Clean Architecture principles and Domain Driven Design. Then, we will set up the dev-environment. In the end, we will write the code for one of the domain layer modules.

Let's begin with the design!

What We're Going to Need

We want to split responsibilities between different modules as it is advised in Single Responsibility Principle, SRP. Let's define what problems we need to solve.

L-System Module

For generating the basics of the tree, we're going to use L-Systems. Those are sets of entities and rules that describe how those entities transform over time.

Trees are fractals, and L-Systems are a convenient mathematical model for describing fractals. So, the first module we're going to need is an L-System generator.

It will create a set of transforming characters like this:

1st recursion:  1[0]0
2nd recursion:  11[1[0]0]1[0]0
3rd recursion:  1111[11[1[0]0]1[0]0]11[1[0]0]1[0]0
Enter fullscreen mode Exit fullscreen mode

We will take those characters and interpret them as commands for drawing on canvas.

Geometry Module

We will use the Pythagoras tree as a basic fractal. In the end, we will add some randomness to make it look more like a real tree.

Pythagoras tree, 5th iteration

For drawing it, we're going to need to know where on canvas to draw lines and what length those lines should have. The geometry module will handle these calculations.

L-System Interpretation, Graphics, and DOM Modules

For showing a generated image, we're going to need the access to the DOM and a canvas element. Also, we will need a “translator” from L-System characters language to the drawing commands language.

Okay, we now determined all the tasks and problems. Let's design the relationships between modules.

Application Architecture

When I need to design an architecture I like to re-read this post: DDD, Hexagonal, Onion, Clean, CQRS, …How I put it all together.

I like to use it as a guide. I take principles and heuristics from it, and apply them to my code.

The first thing to do is to split the application into layers:

  • domain,
  • application (including ports),
  • adapters layer.

Domain Layer

This layer contains business-logic. The code and the data in the domain layer make an application different from other applications. Modules in this layer should not have any dependencies.

In our case, the domain layer contains L-Systems and Geometry modules.

Application Layer

This layer contains code and rules specific to this particular application. In our case, the application layer contains an interpreter that “translates” L-System characters into drawing commands.

The difference between the domain layer and the application layer is similar to the difference between a melody and the playing manner. The melody is written in notes and doesn't change (domain). The accompaniment, tempo, timbre, and so on depend on the situation and the mood (application).

Modules in the application layer depend only on the domain layer.

Application layer also contains ports. Ports are specifications for external services how they can connect to our application. It describes how our application wants to use or be used by external services.

Ports satisfy application needs. If an external service interface is incompatible with application needs we create an adapter.

Adapters Layer

An adapter makes an external service interface compatible with our application.

In our case, a port is an application entry point. It tells how the application can be used.

Adapters, in our case, are modules for working with the DOM and accessing canvas.

Combine Layers

Let's draw an architecture diagram:

Components Diagram

Notice that dependencies' direction is towards the domain. It means that outmost layers depend on inmost and never otherwise.

Why divide code in such a way?

  • The most important code (the domain) can be transferred from one project to another without modifications since it doesn't depend on anything.
  • If the UI is changed (e.g. we change canvas to svg) we will only need to replace UI adapters and nothing else.
  • It's more convenient to split the application in packages that can be deployed independently.

Okay, we designed the app. Let's set up the environment.

Setting Up the Environment and DI

For initial setup, I used createapp.dev.

All the configs and settings you can find in the project repo on GitHub.

For setting up the dependency injection (DI), we will use wessberg/DI as a DI-container and wessberg/di-compiler to resolve all the dependencies at compile-time.

Let's add DI configs in webpack.config.js:

// webpack.config.js

const { di } = require("@wessberg/di-compiler");

// …

rules: [
  {
    test: /\.ts$/,
    use: [
      {
        loader: "ts-loader",
        options: {
          getCustomTransformers: (program) => di({ program }),
        },
      },
    ],
  },
],

// …
Enter fullscreen mode Exit fullscreen mode

The custom di transformer replaces interfaces with according class instances. In the future, we will explicitly define which class should implement a particular interface.

Then, create the container itself:

// `src/composition/core.ts`

import { DIContainer } from "@wessberg/di";
export const container = new DIContainer();
Enter fullscreen mode Exit fullscreen mode

The container object will be used for interface registrations.

Writing Domain Layer

As we remember, the l-system module generates L-System characters using specific rules.

First, we will define a public API of this module, then implement it, and finally, register the module in the DI container.

Defining Public API

We create an interface SystemBuilder. This interface will be the entry point to this module:

// src/l-system/types.ts

export type SystemSettings = {
  rules: RuleSet;
  initiator: Axiom;
  iterations: IterationsCount;
};

export interface SystemBuilder {
  build(settings: SystemSettings): Expression;
}
Enter fullscreen mode Exit fullscreen mode

All the modules that somehow use L-Systems will depend on the SystemBuilder interface and only on it. I will explain the reasons why a bit later 🙂

The Axiom, RuleSet, IterationsCount, and Expression types are type-aliases that describe module entities and rules:

  • Axiom, is a starting character of an L-System;
  • RuleSet, is a set of rules that describe characters transformation;
  • IterationsCount, how many times we need to transform characters;
  • Expression, is the final string of characters.

We make all these types globally accessible via type annotations:

// typings/l-system.d.ts

type Combined<TCharacter> = TCharacter;
type Transformed<TExpression> = TExpression;

type Character = string;
type Variable = Character;
type Constant = Character;

type Expression = Combined<Variable | Constant>;
type RuleSet = Record<Expression, Transformed<Expression>>;

type Axiom = Variable;
type SystemState = Expression;
type IterationsCount = number;
Enter fullscreen mode Exit fullscreen mode

Now, we can start implementing the logic.

SystemBuilder Implementation

For implementation, we will create a class:

// src/l-system/implementation.ts

import { SystemBuilder, SystemSettings } from "./types";

export class Builder implements SystemBuilder {
  public build(settings: SystemSettings): Expression {
    // …
  }
}
Enter fullscreen mode Exit fullscreen mode

When creating a class, we define that it implements SystemBuilder. The implements keyword tells a compiler to make sure that the class contains all the public methods and properties defined in the interface.

An interface is a behavior contract. It describes how this module can be used. It guarantees that a module has defined methods and properties.

Other modules don't need to know the implementation details. They only need to be sure that they can call SystemBuilder.build and get the result.

The interface becomes the only input that an external world can communicate to the module:

Component diagram: input on the top

This makes the code less coupled, and hence more replaceable.

Let's get back to the implementation. To create the Pythagoras tree we're going to need:

  • 2 variables: "0" и "1";
  • 2 constants: [ и ];
  • an axiom (initial character): "0";
  • and transformation rules: "1" → "11", "0" → "1[0]0".

So we expect that starting character "0" will be transforming into:

  • 1st iteration: "1[0]0";
  • then: "11[1[0]0]1[0]0";
  • then: "1111[11[1[0]0]1[0]0]11[1[0]0]1[0]0";
  • and so on...

We're going to need a local state. It will keep all the characters for a current iteration. Let's use a private field called state for it.

Extract an axiom, rules, and iteration count from the argument. In every iteration apply the rules:

// src/l-system/implementation.ts

import { SystemBuilder, SystemSettings } from "./types";

export class Builder implements SystemBuilder {
  private state: SystemState = "";

  public build({ axiom, rules, iterations }: SystemSettings): Expression {
    this.state = axiom;

    for (let i = 0; i < iterations; i++) {
      this.applyRules(rules);
    }

    return this.state;
  }
}
Enter fullscreen mode Exit fullscreen mode

In our case, applying the rules means replacing each character in the state with those from the rules set:

// src/l-system/implementation.ts
// …

private applyRules(rules: RuleSet): void {
  const characters: List<Character> = this.state.split("");
  this.state = "";

  for (const character of characters) {
    const addition = rules[character] ?? character;
    this.state += addition;
  }
}

// …
Enter fullscreen mode Exit fullscreen mode

Notice that the applyRules method is private. External modules won't be able to access it directly. We expose only methods defined in the SystemBuilder interface.

All the transformation details are encapsulated inside of this module.

Now, let's register this module in the container and try to run the application.

Registering Module in DI Container

Usually, for creating class instances we use the new keyword:

const builder = new Builder();
// builder.build();
Enter fullscreen mode Exit fullscreen mode

In our case, we will use a slightly different approach.

// src/l-system/composition.ts

// Importing the DI-container:
import { container } from "../composition";

// ...the interface:
import { SystemBuilder } from "./types";

// ...and the implementation:
import { Builder } from "./implementation";

container.registerSingleton<SystemBuilder, Builder>();
Enter fullscreen mode Exit fullscreen mode

On the last line, we tell the container to return an instance of the Builder class when it's asked to give something that implements the SystemBuilder interface.

Remember, all the modules that depend on L-Systems refer to the SystemBuilder interface. They “ask” the container to give them something that implements it. When this happens the container returns the Builder instance.

So, if we need to change the implementation, we change only the Builder class and nothing else. As long as the SystemBuilder interface stays the same no external modules are affected.

Okay, to complete the registration we need to import it into composition/index.ts:

// src/composition/index.ts

import { container } from "./core";
import "../l-system/composition";

export { container };
Enter fullscreen mode Exit fullscreen mode

Wait, What's a Singleton?

Here, singleton refers to the object lifetime type. In general, registerSingleton creates at most one instance of the registered service, and the clients will always receive that same instance from the container.

I explained it in a bit more detail in the post about DI.

Well, Okay But How to Use It?

Fair question 😃

Indeed, we cannot access the builder right away. We need to ask the container to give it to us:

// src/index.ts

import { container } from "./composition";
import { SystemBuilder } from "./l-system/types";

const builder = container.get<SystemBuilder>();

console.log(
  builder.build({
    axiom: "0",
    iterations: 3,
    rules: { "1": "11", "0": "1[0]0" },
  }),
);

// 1111[11[1[0]0]1[0]0]11[1[0]0]1[0]0
Enter fullscreen mode Exit fullscreen mode

In the code above, we ask the container to give us something that implements SystemBuilder. Earlier, we registered the Builder class as the implementation of SystemBuilder:

container.registerSingleton<SystemBuilder, Builder>();
Enter fullscreen mode Exit fullscreen mode

So, the container will give us an instance of this class. Again, we don't know anything about implementation details, we access only the interface.

Let's run the app and check if everything works properly:

Console output is the same as expected

Everything works! 🥳

In the Next Posts

In the second part, we will create the geometry module and the DOM adapter. In the end, we will display our first image on the `canvas.

In the last part, we will write a “translator” for L-System characters. Also, we will generate the Pythagoras tree and add some randomness to make it look more like a real tree.

Sources

Application sources and example:

L-Systems, fractals:

Architecture, OOP, DI:

SOLID principles:

Discussion (2)

Collapse
lizhiyume profile image
lizhiyu

perfect

Collapse
bespoyasov profile image
Alex Bespoyasov Author

Thank you! 😊