Рисуем деревья на canvas с помощью L-систем, TypeScript и ООП

Я тут недавно вдохновился одним видосом на Ютубе, в нём автор написал на Python рисовалку деревьев. Я подумал, а чего б не повторить это на веб-технологиях, и повторил 😃

В этой серии из 3 постов мы напишем генератор изображений, который будет рисовать деревья на canvas. Чтобы было веселее, мы сделаем акцент на архитектуре и напишем код по принципам ООП. Я расскажу об основах проектирования и покажу на примере, как использовать TypeScript, чтобы писать в ООП стиле.

В результате мы напишем приложение, которое будет рисовать вот такую красоту:

Сгенерированное изображение дерева
Сгенерированное изображение дерева

…И разберём идеи и приёмы, самые удачные из которых можно будет применять в своих проектах.

Что стоит знать перед прочтением

Я ожидаю, что у вас уже есть базовое представление об ООП, классах и интерфейсах. Для комфортного чтения будет необходимо понимать разницу между интерфейсом и реализацией, а также как работает внедрение зависимостей.

О внедрении зависимостей у меня уже есть большой пост. Если вы чувствуете себя неуверенно, когда слышите «DI-контейнер», рекомендую прочесть сперва его, а потом вернуться сюда.

Также мы часто будем ссылаться на принципы SOLID. Вам необязательно знать их досконально, но будет хорошо, если вы что-то о них слышали. Со всем остальным разберёмся уже на месте.

План на первую часть

В первой части мы спроектируем архитектуру приложения, следуя советам из «Чистой архитектуры» Мартина и Domain Driven Design. Затем настроим окружение и добавим внедрение зависимостей. В конце напишем код одного из модулей доменного слоя.

Начнём с проектирования.

Какие модули нам понадобятся

Мы можем написать всё приложение одной большой простынёй, но делать так конечно же не будем 😃
Нам стоит разделить обязательства между разными модулями. Давайте определим, какие задачи перед нами стоят.

Модуль L-систем

Для генерации деревьев мы будем использовать L-системы — наборы сущностей и правил, которые указывают, как эти сущности меняются со временем.

Деревья — это фракталы, а L-системы удобно использовать для построения фракталов. Первый модуль, который нам будет нужен, — генератор таких L-систем. Он будет создавать набор символов, которые потом мы будем интерпретировать, как команды для рисования.

Модуль геометрии

В качестве основы для собственно дерева мы будем использовать дерево Пифагора. В конце мы добавим случайности, чтобы дерево было больше похоже на настоящее, но за основу возьмём именно эту фигуру.

Пятая итерация дерева Пифагора; на очередной итерации из каждой ветки появляется по 2 новых
Пятая итерация дерева Пифагора; на очередной итерации из каждой ветки появляется по 2 новых

Чтобы понимать, какой длины должна быть линия на полотне и какой у неё должен быть угол, нам понадобится модуль геометрии. Он будет считать расположение ключевых точек отрезков дерева.

Модули интерпретации L-системы, графики и доступа к DOM

Для вывода картинки на экран нам понадобится доступ к DOM и работе с canvas. Также нам потребуется «переводчик» с языка L-систем на команды для рисования на canvas.

Когда мы определились с задачами, спроектируем архитектуру и определим, в каких отношениях модули будут друг с другом состоять.

Архитектура приложения

Когда я говорю об архитектуре, мне нравится ссылаться на статью DDD, Hexagonal, Onion, Clean, CQRS, …How I put it all together. В ней детально описаны принципы, по которым код делится на модули и слои. Если вы когда-нибудь задавались вопросом «А как сочетается чистая архитектура и …?» в этой статье, скорее всего, есть ответ 😃

Я буду использовать эти принципы, как основу для проектирования. По ходу буду объяснять причины, по которым выделяю какой-то слой или модуль, но для более детального объяснения советую прочесть эту статью.

Приложение будет состоять из нескольких слоёв:

  • доменного слоя (domain),
  • прикладного (application),
  • слоя адаптеров (adapters).

Доменный слой

Код и данные в этом слое — это бизнес-логика, те операции, которые отличают одно приложение от другого. Модули из этого слоя ни от чего не зависят.

В нашем случае это модули L-system и Geometry. Первый отвечает за построение самой L-системы, второй — за геометрию линий на плоскости.

Прикладной слой

В прикладном слое содержатся правила, специфичные для конкретно этого приложения. В нашем это случае переводчик Interpreter, который переводит символы L-системы в команды для рисования.

Разница между доменным и прикладным слоями похожа на разницу между мелодией и манерой её воспроизведения. Мелодия записана по правилам нотной грамоты и не меняется (домен), а аккомпанемент, темп, тембр выбираются под ситуацию и настроение (прикладной слой).

Модули в этом слое зависят от модулей из домена, но больше ни от чего.

В прикладном слое также содержатся порты — спецификации, как внешний сервис может использовать наше приложение, или как наше приложение хочет использовать внешний сервис.

Порт создаётся, чтобы удовлетворить нужды приложения. Если интерфейс внешнего инструмента не подходит приложению, мы напишем адаптер.

Слой адаптеров

Адаптер — это переходник, который делает несовместимый интерфейс внешнего сервиса таким, какой нужен нашему приложению.

У нас порт — это входная точка в приложение. Её интерфейс говорит, какой именно метод надо «трогнуть, чтобы начать».

С помощью порта мы говорим: «С приложением можно взаимодействовать так и никак иначе»

Адаптеры в нашем случае — это модули для работы с DOM и рисованием на canvas.

Всё вместе

Если соединить всё в одной диаграмме, то получится вот такая архитектура:

Диаграмма компонентов приложения, разложенных по слоям и зонам UI и инфраструктуры
Диаграмма компонентов приложения, разложенных по слоям и зонам UI и инфраструктуры

Об Infrastructure и Shared Kernel мы поговорим в конце. Сейчас обратим внимание, что стрелка зависимостей направлена к домену. Это значит, что внешние слои зависят от внутренних, и никогда не наоборот.

В чём плюс подобного деления кода? Их несколько:

  • Самый главный код (домен), можно переносить от проекта к проекту, потому что он ни от чего не зависит.
  • При изменении платформы нам потребуется (в идеале) заменить только адаптеры. Допустим мы решили съехать с canvas на svg: приложение останется прежним, изменится лишь адаптер к рисовалке.
  • Приложение удобно делить на «пакеты» для публикации. Подробнее об этом — в статье DDD, Hexagonal, Onion, Clean, CQRS, …How I put it all together.

С архитектурой разобрались, перейдём к окружению и коду.

Окружение и внедрение зависимостей

Я не буду вдаваться в подробности начальной настройки webpack.config.js и tsconfig.json. Для старта проекта я использовал createapp.dev, конфиги почти не менялись.

В любом случае я оставлю ссылку на репозиторий со всеми исходниками. Оттуда можно забрать конфиги, шаблоны и код всех модулей, которые мы напишем, чтобы запустить проект локально.

Для настройки внедрения зависимостей мы буем использовать wessberg/DI как DI-контейнер. А чтобы все зависимости определялись на этапе компиляции, а не в рантайме, мы используем wessberg/di-compiler.

Добавим конфиги DI в webpack.config.js

// webpack.config.js

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

// …

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

// …

Кастомный трансформер di подставляет классы на место интерфейсов, которые они реализуют. В будущем мы будем явно указывать контейнеру, какой класс с каким интерфейсом связать, чтобы трансформер это заметил и правильно скомпилировал код.

Дальше создадим сам контейнер:

// `src/composition/core.ts`

import { DIContainer } from '@wessberg/di';
export const container = new DIContainer();

Этот объект container мы и будем в будущем использовать для регистрации модулей.

Пишем код доменного слоя

Как мы помним, модуль l-system генерирует L-системы по заданным правилам.

Сперва объявим публичное API, которым будут пользоваться другие модули, потом напишем реализацию, а в конце зарегистрируем модуль в DI-контейнере.

Объявляем публичное API

Создадим интерфейс SystemBuilder. Этот интерфейс будет входной точкой в этот модуль:

// src/l-system/types.ts

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

export interface SystemBuilder {
	build(settings: SystemSettings): Expression;
}

Все модули, которые как-либо используют L-системы, будут зависеть от интерфейса SystemBuilder и только от него. Почему и зачем — я объясню чуть-чуть позже 🙂

Типы Axiom, RuleSet, IterationsCount и Expression — это алиасы, которые описывают сущности и правила модуля:

  • Axiom — стартовый символ, с которого начинается построение;
  • RuleSet — набор правил, по которым символы меняются;
  • IterationsCount — сколько раз нужно применить правила и изменить символы;
  • Expression — финальное выражение, которое получается после всех преобразований.

Все эти типы мы делаем доступными глобально с помощью аннотаций:

// 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;

Теперь можно приступать к реализации.

Реализация SystemBuilder

Для реализации интерфейса мы напишем класс:

// src/l-system/implementation.ts

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

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

При создании класса мы указываем, что он реализует интерфейс SystemBuilder. Ключевое слово implements просит компилятор убедиться, что класс содержит все публичные методы, заявленные в интерфейсе, а также что их сигнатура совпадает с заявленной.

Если мы в классе забудем прописать метод build, или он будет принимать аргументы других типов, или он будет не публичным, код не соберётся.

Интерфейс — это контракт на поведение. Он фиксирует обязательства одного модуля перед другими, описывая, как этот модуль можно использовать. Что именно происходит под капотом другие модули не волнует, им важно, чтобы можно было вызвать метод SystemBuilder.build и получить результат.

Можно сказать, что модуль — это чёрный ящик. У него есть вход через интерфейс (сверху), который описывает как к модулю можно обращаться:

Диаграмма компонента: сверху указан интерфейс, через который к модулю можно обратиться из внешнего мира
Диаграмма компонента: сверху указан интерфейс, через который к модулю можно обратиться из внешнего мира

Если мы уверены, что можем вызывать build и получить символьное выражение L-системы, нам становится неважно, какой вообще модуль её реализует. Это делает код заменяемым, менее сцепленным. Круто!

Вернёмся к реализации. Чтобы построить дерево Пифагора, нам понадобятся:

  • две переменные: "0" и "1";
  • две константы: [ и ];
  • аксиома (стартовый символ): "0";
  • и правила преобразования: "1" → "11", "0" → "1[0]0".

То есть мы ожидаем, что при старте с символа "0" у нас получатся такие последовательности символов:

  • на 1-й итерации: "1[0]0";
  • на 2-й: "11[1[0]0]1[0]0";
  • на 3-й: "1111[11[1[0]0]1[0]0]11[1[0]0]1[0]0";
  • …и так далее.

Нам потребуется «локальный стейт», в котором мы будем собирать символы после очередной итерации. Сохраним его в приватном поле state. (Я буду пользоваться терминами поле и свойство класса, как это принято в C#.)

Из объекта с настройками достанем аксиому, правила и количество итераций, которые надо провести. На каждую итерацию применим правила:

// 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;
	}
}

«Применить правила» в нашем случае — это пройтись по каждому символу из текущего состояния и заменить его на символы из правил:

// 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;
  }
}

// …

Заметьте, что метод applyRules приватный — мы прячем его от внешнего мира. Наружу мы показываем только те методы, которые определены в интерфейсе.

Мы помним, что модулям снаружи важно лишь соблюдение контракта из интерфейса. Поэтому как именно мы работаем с данными — дело только этого класса. Вся обработка данных вместе с самими данными находится в одном месте — это называется инкапсуляция.

Теперь зарегистрируем модуль в контейнере и попробуем запустить.

Регистрируем реализацию в DI-контейнере

Обычно для работы с классами мы создаём экземпляры через new:

const builder = new Builder();
// builder.build();

С классом Builder это сработает, но в будущем со сложными модулями такой подход будет неудобным. Вместо этого мы будем использовать DI-контейнер.

// src/l-system/composition.ts

// Получаем контейнер:
import { container } from '../composition';

// ...интерфейс:
import { SystemBuilder } from './types';

// ...и реализацию:
import { Builder } from './implementation';

container.registerSingleton<SystemBuilder, Builder>();

На последней строке мы говорим контейнеру, чтобы когда у него спросят что-то, что реализует интерфейс SystemBuilder, он вернул экземпляр класса Builder.

Мы помним, что все модули, которые хотят использовать L-системы, обращаются к интерфейсу. Когда это произойдёт, контейнер увидит этот запрос и отдаст конкретный объект.

Это развязывает руки: если мы изменим реализацию этого модуля, ничто больше не изменится, пока интерфейс остаётся таким же. Так мы создаём «барьер», который ограничивает зону распространения изменений.

Регистрация готова, осталось импортировать сам файл в index.ts:

// src/composition/index.ts

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

export { container };

Пдажжи, а чё такое синглтон?

Здесь singleton — это вид жизненного цикла объектов, которые контейнер создаст. В общих чертах, registerSingleton создаёт один объект, который потом внедряет во все места, которые от него зависят.

Чуть подробнее я писал об этом в статье об инъекции зависимостей.

А как этим пользоваться-то?

Справедливый вопрос 😃
И правда, просто так экземпляр builder нам не достать. Но мы можем попросить контейнер нам его выдать:

// 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

В коде выше мы импортируем контейнер и интерфейс SystemBuilder. Опять-таки, снаружи мы ничего не знаем о деталях модуля, нам доступен лишь интерфейс.

Сразу предупрежу: напрямую запрашивать у контейнера экземпляры нельзя нигде, кроме входной точки в приложение

У контейнера мы запрашиваем что-нибудь, что этот интерфейс реализует. Ранее мы зарегистрировали класс Builder как тип SystemBuilder:

container.registerSingleton<SystemBuilder, Builder>();

…Поэтому контейнер знает, экземпляр какого класса надо создать и вернуть. В результате в объекте builder мы получаем экземпляр класса Builder со всеми публичными методами, объявленными в интерфейсе:

Все публичные методы доступны через автозаполнение
Все публичные методы доступны через автозаполнение

Соберём проект и посмотрим, сработало ли:

Вывод в консоли совпадает с ожидаемым — всё работает
Вывод в консоли совпадает с ожидаемым — всё работает

Ура! 🥳
Всё работает, значит модуль зарегистрирован верно.

В следующих сериях

Во второй части мы создадим модуль геометрии для расчёта положения точек на плоскости. После него создадим модули для работы с DOM и canvas. А в конце выведем на экран первый простенький рисунок.

В третьей части напишем «переводчик» с языка L-систем на язык команд для рисования на canvas. Сгенерируем дерево Пифагора, добавим ему случайности и цвета, чтобы оно стало похоже на настоящее дерево.

Ссылки из поста

L-системы, фракталы и вот это всё

Архитектура, ООП, DI

Принципы SOLID

Термины из TypeScript, C#

Инструменты