Всё началось невинно. Шёл 2009 год, и я просто хотел портировать Earcut на Flash — для своей мини-игры. Тогда это сработало, но с годами стало понятно: простые решения перестают работать, как только хочешь выжать из них максимум.
Я углубился в теорию, и начал перебирать статьи и просматривать ролики на youtube. Сильно помогла книга А.В. Скворцова. В итоге я остановился на подходе разбиения на монотонные многоугольники. Он казался самым очевидным. И ох, сколько я набил себе шишек, пока его реализовал.
Первым прорывом стало осознание, что float
нужно заменить на int
. Алгоритм почти заработал - но всё равно сыпался. Он оказался крайне не устойчивым ко входным данным: самопересечения, коллинеарные рёбра, касания дыр к внешнему контуру, дегенеративные точки - всё это могло его поломать в любой момент. В итоге алгоритм вроде и работал, но мне он не нравился.
Шли года, моя рука крепла. Я написал iOverlay - булеву библиотеку, которая может устранить самопересечения и привести начальные условия в надлежащий вид. И тогда я решил вернуться к старому триангулятору и разобрать его по косточкам. На этот раз всё пошло проще - потому что я уже знал, чего ждать.
В небе начинает светить радуга, когда можно доверять входным данным. А еще я понял, что монотонное разбиение - это просто абстракция, и ее можно спокойно выкинуть. Я также отполировал условие Делоне так, что оно заблестело.
Так появился мой кастомный sweep-line алгоритм с прямым триангулированием. Он не только быстрый и простой, но и невероятно стабильный: я прогнал больше миллиарда случайных тестов - и всё сошлось бит в бит.

К делу!
Как я уже упоминал, для большинства алгоритмов такого типа формат входных данных критичен. Поэтому сразу зафиксируем жёсткие требования:
Нет самопересечений
Контуры могут касаться друг друга только точка-в-точку
Внешние контуры идут против часовой стрелки
Дырки - наоборот, по часовой стрелке
Шаг 1: выбираем направление заметающей прямой
Поскольку это sweep-line алгоритм, первым делом нужно выбрать ось, вдоль которой мы будем двигать заметающую прямую. Мне привычнее всего двигать её слева направо вдоль оси X.
Все вершины нужно отсортировать по координатам:
сначала по
x
,если
x
равен - поy
.
Граничный случай: касания
Одним из тонких моментов является ситуация, когда в одной точке сходятся несколько контуров, например внешний и одна или несколько дыр. Такие точки могут касаться друг друга в различных конфигурациях, но есть важное свойство:
Число рёбер в точке всегда чётное, а при обходе по кругу рёбра будут чередоваться: входящее / исходящее / входящее / исходящее...

Шаг 2: структура вершины
Каждая вершина должна "знать" своих соседей по контуру, поэтому мы храним prev
и next
:
Должно получиться, что то вроде:
struct ChainVertex {
index: int,
this: Point,
next: Point,
prev: Point,
}
index
- порядковый номер вершины. Совпадающие точки имеют одинаковый индекс.this
- координаты текущей вершины.prev
- предыдущая точка по контуру.next
- следующая точка по контуру.
Шаг 3: классификация вершин
Теперь, нужно определить тип каждой вершины - это необходимо для правильной обработки в sweep-line алгоритме. Тип зависит от формы угла, положения соседей и направления обхода.
Вот все категории:
Start - начало выпуклого сегмента монотонного многоугольника.
End - конец монотонного многоугольника
Split - вершина, "разрезающая" многоугольник
Merge - вершина, где сливаются два монотонных многоугольника
Join - вершина равная, либо next либо prev
Steiner - искусственная точка, является некой комбинацией Split/Merge

Шаг 4: sweep и триангуляция "на лету"
Двигаясь по отсортированным вершинам, мы жадно собираем треугольники. При этом формируются временные сегменты - фактически, монотонные многоугольники, которые:
начинаются в вершинах типа Start и Split,
заканчиваются в вершинах End и Merge.
У каждого активного сегмента есть опорное ребро - обычно это либо следующее ребро по контуру, либо мостик между двумя сегментами. На анимации такие рёбра выделены синим цветом.
Работа с опорными рёбрами
Все опорные рёбра хранятся в древовидной структуре, отсортированной по вертикали. Это позволяет быстро определить, в какой сегмент попадает новая вершина: мы просто ищем ближайшее снизу ребро, которому она принадлежит или под которым находится.

Сборка треугольников
Каждый активный сегмент может содержать как одну вершину, так и цепочку рёбер. При добавлении новой вершины:
мы пытаемся сформировать один или несколько треугольников
их вершины всегда обходятся против часовой стрелки
Для хранения результата используется структура:
struct Triangle {
vertices: [IndexPoint; 3], // координата + индекс
neighbors: [int; 3], // ссылки на соседние треугольники
}
vertices
- три точки, упорядоченные против часовой стрелкиneighbors
- индекс соседнего треугольника по каждой стороне (если есть)

Внешние и внутренние рёбра
Каждое ребро в треугольнике может быть:
внешним - если оно принадлежит контуру (внешнему или дырке)
внутренним - если оно "разделяет" два треугольника
Если ребро внутреннее, у треугольника должен быть проставлен индекс соседнего треугольника, который примыкает с другой стороны.
Фантомные рёбра
Иногда при построении треугольника добавляется внутреннее ребро, у которого пока нет второго треугольника. В этом случае оно считается фантомным — оно "висит" в воздухе. При первом визите такое ребро конвертируется в обычное внутреннее: оно связывается с текущим треугольником.

Шаг 5 (опциональный): приведение к Делоне
На этом этапе у нас уже есть корректная триангуляция. Но если хочется сделать её ещё лучше, можно привести её к соответствию условию Делоне.
Для этого мы проходим по всем треугольникам и проверяем условие Делоне для каждого ребра, которое граничит с соседним треугольником. Я не буду подробно расписывать саму проверку - у меня есть отдельная статья с наглядной анимацией, объясняющей, что именно считается нарушением.
Если условие не выполняется, мы:
удаляем общее ребро между двумя соседними треугольниками
перестраиваем диагональ, соединяя противоположные вершины
Процесс повторяется, пока все внутренние рёбра не удовлетворяют условию Делоне.

Почему это работает?
Каждая такая операция уменьшает тупой угол в соответствующей паре треугольников. Это означает, что процесс монотонно сходится - и гарантированно завершится.
В реализациях на float
здесь часто всплывает баг: из-за накопленных ошибок округления может возникнуть бесконечный цикл-флипов. Это известная проблема, ее еще часто называют проблемой "правильного шестиугольника".
С int
(или fixed-point) логикой всё проще:
проверка становится строгой
все операции детерминированы
а значит — бесконечных циклов просто не возникает.
Заверните я беру
Если вам интересна моя реализация - ее можно потрогать в iTriangle.
Это Rust-библиотека, в которой помимо описанного выше, есть ещё много полезного:
тесселяция
разбиение на выпуклые многоугольники
построенние серединных многоугольников
поддержка внутренних точек
Можно попробовать интерактивные демо:
И посмотреть на бенчмарки против других популярных решений.