Скотт Тёрнер продолжает работу над своей процедурно генерируемой игрой и теперь решил заняться проблемой оформления границ карт. Для этого ему предстоит решить несколько непростых задач и даже создать собственный язык описания границ.
Важным элементом фэнтезийных карт, который уже довольно долго находился в моём списке, оставались границы. У функциональных карт обычно есть простая линия рамки, но у фэнтезийных карт и средневековых карт, из которых первые часто заимствуют идеи, имеют довольно продуманные и художественные границы. Эти границы дают понять, что карта намеренно сделана фантастической, и вселяют в зрителя ощущение чуда.
В настоящее время в моей игре Dragons Abound есть пара простых способов отрисовки границ. Она может отрисовывать одинарную или двойную линию по периметру карты и добавлять простые элементы в углах, как на этих рисунках:
Также игра может добавлять поле в нижней части границы для названия карты. В Dragons Abound есть несколько вариаций этого поля, в том числе такие сложные элементы, как фальшивые головки винтов:
В этих полях названий присутствует вариативность, но все они созданы вручную.
Один из интересных аспектов границ фэнтезийных карт заключается в том, что они одновременно и креативны, и шаблонны. Часто они состоят из небольшого количества простых элементов, сочетающихся разными способами для создания уникального результата. Как всегда, первым шагом при работе с новой темой для меня является изучение коллекции примеров карт, создание каталога типов элементов границ и изучение их внешнего вида.
Простейшая граница — это одна линия, идущая вдоль краёв карты и обозначающая её пределы. Как я сказал выше, она также называется «линией рамки»:
Существует также вариация с расположением границ в пределах карты. В этой версии карта доходит до краёв изображения, но граница создаёт виртуальную рамку внутри изображения:
Это можно сделать с любым типом границы, но обычно используется только с простыми границами наподобие линии рамки.
Популярная концепция оформления фэнтезийных карт заключается в том, чтобы имитировать, как будто они нарисованы на старом надорванном пергаменте. Иногда это реализуется отрисовкой границы как неровного края бумаги:
Вот более изощрённый пример:
По моему опыту, такой способ стал менее популярным, потому что в обиход вошли цифровые инструменты. Если вы хотите, чтобы карта выглядела как старый надорванный пергамент, то проще наложить на неё текстуру пергамента, чем рисовать её вручную.
Самым мощным инструментом в создании границ карты является повторяемость. В простейшем случае достаточно повторить одиночную линию, чтобы создать две линии:
Карте можно добавить интересности, варьируя стиль повторяемого элемента, в данном случае — скомбинировав толстую одиночную линию с тонкой одиночной линией:
В зависимости от элемента возможны различные вариации стиля. В этом примере линия повторяется, но меняется цвет:
Для создания более сложных узоров можно использовать «повторяемую повторяемость». Эта граница состоит из примерно пяти одиночных линий с разной шириной и расстоянием:
Эта граница повторяет линии, но отделяет их таким образом, что они выглядят как две отдельные тонкие границы. В этой части поста я не буду говорить про обработку углов, но разные углы для двух линий тоже помогают в создании этого отличия.
Это две линии, четыре, или шесть? Думаю, всё зависит от того, как их нарисуешь!
Ещё один элемент стилизации — заполнение пространства между элементами цветом, узором или текстуро. В этом примере граница стала более интересной благодаря заливкой между двумя линиями акцентным цветом:
А вот пример того, как граница заполнена узором:
Также элементы можно стилизовать так, чтобы они выглядели трёхмерными. Вот карта, в которой граница затенена, чтобы она выглядела объёмной:
В этой карте граница затенена, чтобы выглядеть трёхмерной, и это скомбинировано с расположением границ внутри краёв карты:
Ещё один распространённый элемент границы — масштаб в виде разноцветных полос:
Эти полосы образуют сетку (картографическую сетку). На реальных картах масштаб помогает определять расстояния, однако на фэнтезийных он в основном является декоративным элементом.
Эти полосы обычно отрисовываются чёрным и белым цветами, но иногда добавляется красный или какой-то другой цвет:
Этот элемент также можно сочетать с другими, как в этом примере с линиями и масштабом:
Этот пример немного необычен. Обычно масштаб (если он есть) является самым внутренним элементом границы.
На этой карте есть разные масштабы с разным разрешением (а также странные рунические пометки!):
(На Reddit пользователь AbouBenAdhem сообщил мне, что рунические пометки — это числа 48 и 47, написанные вавилонской клинописью. Кроме того, «масштабы с разным разрешением» имеют шесть делений, разделённых на десять более мелких делений, что соответствует вавилонской шестидесятиричной системе исчисления. Обычно я указываю источники карт, но в этом посте слишком много маленьких кусков, поэтому я не стал утруждаться. Однако эта карта создана Томасом Реем для автора С.Е. Болейн, так что, возможно, действие в его книгах происходит в антураже Вавилона.)
Кроме линий и масштаба наиболее распространённым элементом является повторяющийся геометрический узор. Часто он состоит из таких частей, как круги, ромбы и прямоугольники:
Геометрические элементы, как и линии, можно затенить, чтобы они выглядели трёхмерными:
Сложные границы можно создавать комбинированием этих элементов разными способами. Вот граница, в которой сочетаются линии, геометрические узоры и масштаб:
Показанные выше примеры были цифровыми картами, но, разумеется, то же самое можно проделать и с рукописными картами. Вот пример простого геометрического узора, созданного вручную:
Эти элементы тоже можно гибко комбинировать разными способами. Вот геометрический узор в сочетании с «оборванным краем»:
В показанных выше примерах геометрический узор довольно прост. Но можно создавать и очень сложные узоры, комбинируя различным способом основные геометрические элементы:
Ещё один популярный элемент узора — плетение или кельтский узел:
Вот более сложная плетёная граница, содержащая цвет, масштаб и другие элементы:
На этой карте плетение сочетается с элементом оборванного края:
Кроме геометрических узоров и плетений частью границы карты может быть любой повторяющийся узор. Вот пример с использованием фигур, напоминающих наконечники стрел:
А вот пример с повторяющимся волновым узором:
И, наконец, на края фэнтезийных карт иногда добавляют руны или другие элементы фэнтезийного алфавита:
Показанные выше примеры взяты из современных фэнтезийных карт, но вот пример исторической (18 век) карты с линиями и нарисованным от руки узором:
Разумеется, можно найти примеры карт со множеством других элементов на границах. Некоторые из самых красивых полностью нарисованы от руки и имеют настолько тщательно выполненные украшения, что могут превзойти саму карту (World of Alma, Francesca Baerald):
Также стоит немного поговорить о симметрии. Как и повторяемость, симметрия является мощным инструментом, и границы карты обычно симметричны или имеют симметричные элементы.
Многие границы карт симметричны изнутри наружу, как в этом примере:
Здесь граница составлена из нескольких линий с заливкой и без заливки, но снаружи внутрь она идеально повторяется относительно центра границы.
В этом более сложном примере граница симметрична, за исключением перемежающихся чёрно-белых полос масштаба:
Так как дублировать масштаб не имеет смысла, часто он считается отдельным элементом, даже если остальная часть границы симметрична.
Кроме внутренне-наружной симметрии, границы часто повторно симметричны вдоль своей длины. Некоторые иллюстрированные границы могут иметь простой дизайн, растянувшийся на всю длину края карты, но в большинстве случаев паттерн довольно короток и повторяется, заполняя границу от одного угла до другого:
Заметьте, что в этом примере паттерн содержит элемент, который не является симметричным (слева направо), но общий паттерн симметричен и повторяется:
Одним из примечательных исключений из этого правила являются границы, заполненные рунами или алфавитными символами. Часто они оказываются уникальными, как будто вдоль границы написано какое-то длинное сообщение:
Разумеется, существует множество других примеров элементов границ карт, которые я здесь не рассмотрел, но у нас уже есть хорошая опорная точка. В следующих нескольких частях я разработаю в Dragons Abound несколько функций для описания, отображения и процедурной генерации границ карт, похожих на эти примеры. Во второй части мы начнём с задания языка для описания границ карт.
Часть 2
В этой части я создам первоначальную версию языка описания границ карт Map Border Description Language (MBDL).
Зачем тратить время на создание языка описания границ карт? Во-первых, это будет целью моей процедурной генерации. Позже я напишу алгоритм для создания новых границ карт, и выходными данными этого алгоритма станет описание новой границы на MBDL. Во-вторых, MBDL будет служить текстовым представлением границ карт. В частности, мне нужно иметь возможность сохранять и повторно использовать понравившиеся границы. Для этого мне потребуется текстовая нотация, которую можно записывать и использовать для воссоздания границы карты.
Создание MBDL я начну с задания простейшего элемента: линии. Линия имеет цвет и ширину. Поэтому в MBDL я представлю линию в таком виде:
L(width, color)
Вот несколько примеров (простите за мои навыки Photoshop):
Последовательность элементов отрендерена снаружи внутрь (*), поэтому будем считать, что это граница сверху карты:
Посмотрите на второй пример — линия с границами представлена как три отдельные элемента-линии.
(* Отрисовка снаружи внутрь была произвольным выбором — мне просто показалось, что это естественнее, чем отрисовка изнутри наружу. К сожалению, как выяснилось намного позже, была веская причина работать в обратном направлении. Вскоре я об этом расскажу, но в посте всё оставлено по-старому, потому что на переделку всех иллюстраций ушло бы много времени)
Удобно, что пробелы можно представить как линии без цвета:
Но было бы нагляднее иметь конкретный элемент вертикального пробела:
VS(width)
Следующие простые элементы — это геометрические фигуры: полосы, ромбы и эллипсы. Предполагается, что линии растягиваются на всю длину границы, поэтому у них нет явно заданной длины. Но геометрические фигуры не могут заполнять целую линию, поэтому кроме ширины (*) у каждой должна быть длина, цвет контура, ширина контура и цвет заливки:
B(width, length, outline, outline width, fill)
D(width, length, outline, outline width, fill)
E(width, length, outline, outline width, fill)
(* Я принял, что буду считать ширину в направлении снаружи внутрь, а длина измеряется вдоль границы.)
Вот примеры простых геометрических фигур:
Чтобы эти элементы заполняли всю длину границы, они должны повторяться. Чтобы обозначить группу элементов, которые будут повторяться для заполнения длины границы, я использую квадратные скобки:
[ element element element ... ]
Вот пример повторяющегося узора из прямоугольников и ромбов:
Иногда мне будет нужен (горизонтальный) пробел между элементами повторяющегося узора. Хотя для создания пробела можно использовать элемент без цветов, умнее и удобнее будет иметь элемент горизонтального пробела:
HS(length)
Последняя функция, необходимая для этой первой итерации MBDL — это возможность наложения элементов друг на друга. Вот пример границы:
Проще всего описать её широкой жёлтой линией под верхним узором. Реализовать это можно разными способами (например отрицательным вертикальным пробелом), но я решил использовать для обозначения порядка элементов по направлению внутрь фигурные скобки:
{element element element ...}
По сути, эта запись приказывает запоминать, где находился узор снаружи внутрь при входе в скобки, а затем возвращаться к этой точке при выходе из скобок. Скобки также можно рассматривать как описание элементов занимающих вертикальное пространство. Поэтому показанную выше границу можно описать так:
L(1, black)
{L(20, yellow)}
VS(3)
[B(5, 10, black, 3, none)
D(5, 10, black,3,red)]
VS(3)
L(1, black)
Мы отрисовываем чёрную линию, фиксируем, где находимся, рисуем жёлтую линию, а затем возвращаемся к зафиксированной ранее позиции, опускаемся немного вниз, отрисовываем паттерн из прямоугольников и ромбов, опускаемся немного вниз, а затем рисуем ещё одну чёрную линию.
В MBDL нужно сделать гораздо больше, но этого достаточно для описания множества границ карт. Следующим шагом будет преобразования описания границы на MBDL в саму границу. Это похоже на преобразование письменного представления компьютерной программы (например, на Javascript) в выполнение этой программы. Первый этап — это лексический анализ (парсинг) языка — преобразование исходного текста в настоящую границу карты или в какой-то промежуточный вид, который проще преобразовать в границу.
Парсинг — это достаточно хорошо изученная область компьютерных наук. Парсинг языка выполнять не очень просто, но в нашем случае хорошо то (*), что MBDL является контекстно-свободной грамматикой. Контекстно-свободные грамматики парсятся достаточно легко, и для них существует множество инструментов парсинга на Javascript. Я остановился на Nearley.js, который кажется достаточно зрелым и (что более важно) хорошо документированным инструментом.
(* Это не просто удача, я позаботился о том, чтобы язык был контекстно-свободным.)
Я не буду знакомить вас с контекстно-свободными грамматиками, но синтаксис Nearley достаточно прост и вы без особых проблем должны понять смысл. Грамматика Nearley состоит из набора правил. Каждое правило имеет символ слева, стрелку и правую часть правила, которая может быть последовательностью символов и не-символов, а также различные опции, разделённые оператором "|" (или):
border -> element | element border
element -> “L"
Каждое из правил говорит, что левая часть может быть заменена любой из опций в правой части. То есть первое правило гласит, что граница является элементом, или элементом, за которым идёт ещё одна граница. Которая сама может быть элементом, или элементом, за которым следует граница, и так далее. Второе правило гласит, что элемент может быть только строкой «L». То есть вместе эти правила соответствуют вот таким границам:
L
LLL
и не соответствуют таким границам:
X
L3L
Кстати, если вы захотите поэкспериментировать с этой (или любой другой) грамматикой в Nearley, то для этого есть онлайн-песочница здесь. Можно ввести грамматику и протестировать случаи, чтобы увидеть, чему она соответствует и не соответствует.
Вот более полное определение примитива линии:
@builtin “number.ne"
@builtin “string.ne"
border -> element | element border
element -> “L(" decimal “," dqstring “)"
В Nearley есть несколько общих встроенных элементов, и «number» — один из них. Поэтому я могу использовать его, чтобы распознать численную ширину примитива линии. Для распознавания цвета я использую ещё один встроенный элемент и разрешу использовать любую строку в двойных кавычках.
Было бы неплохо добавить пробелы между разными символами, поэтому давайте сделаем это. Nearley поддерживает классы символов и РБНФ для «нуля или больше» чего-то с помощью ":*", поэтому я могу использовать это для задания «нуля или больше пробелов» и вставлю в любое место, чтобы разрешить пробелы в описаниях:
@builtin "number.ne"
@builtin "string.ne"
border -> element | element border
WS -> [\s]:*
number -> WS decimal WS
color -> WS dqstring WS
element -> "L(" number "," color ")"
Однако использование повсюду WS усложняет чтение грамматики, поэтому я откажусь от них, но воображайте, что они есть.
Также элемент может быть вертикальным пробелом:
@builtin "number.ne"
@builtin "string.ne"
border -> element | element " " border
number -> decimal
color -> dqstring
element -> "L(" number "," color ")"
element -> "VS(" number ")"
Это соответствует таким границам
L(3.5,"black") VS(3.5)
Далее идут примитивы полосы, ромба и эллипса.
@builtin "number.ne"
@builtin "string.ne"
border -> element | element " " border
number -> decimal
color -> dqstring
element -> "L(" number "," color ")"
element -> "VS(" number ")"
geometric -> "B(" number "," number "," color "," number "," color ")"
geometric -> "E(" number "," number "," color "," number "," color ")"
geometric -> "D(" number "," number "," color "," number "," color ")"
Это будет соответствовать таким элементам
B(34, 17, "white", 3, "black")
(Учтите, что геометрические примитивы не являются «элементами», потому что они не могут находиться одни на верхнем уровне. Они должны быть заключены в паттерн.)
Также мне нужен примитив горизонтального пробела:
@builtin "number.ne"
@builtin "string.ne"
border -> element | element " " border
number -> decimal
color -> dqstring
element -> "L(" number "," color ")"
element -> "VS(" number ")"
geometric -> "B(" number "," number "," color "," number "," color ")"
geometric -> "E(" number "," number "," color "," number "," color ")"
geometric -> "D(" number "," number "," color "," number "," color ")"
geometric -> "HS(" number ")"
Теперь я добавлю операцию паттерна (повторения). Это последовательность одного или нескольких элементов внутри квадратных скобок. Я воспользуюсь РБНФ-оператором ":+", который здесь обозначает «один или больше».
@builtin "number.ne"
@builtin "string.ne"
border -> element | element " " border
number -> decimal
color -> dqstring
element -> "L(" number "," color ")"
element -> "VS(" number ")"
geometric -> "B(" number "," number "," color "," number "," color ")"
geometric -> "E(" number "," number "," color "," number "," color ")"
geometric -> "D(" number "," number "," color "," number "," color ")"
geometric -> "HS(" number ")"
element -> "[" (geometric):+ "]"
Учтите, что паттерн можно заполнить только геометрическими примитивами. Мы не можем, например, поместить внутрь паттерна линию. Элемент паттерна теперь будет соответствовать чему-то подобному
[B(34,17,"white",3,"black")E(13,21,"white",3,"rgb(27,0,0)")]
Последняя часть языка — это оператор наложения. Это любое количество элементов внутри фигурных скобок.
@builtin "number.ne"
@builtin "string.ne"
border -> element | element " " border
number -> decimal
color -> dqstring
element -> "L(" number "," color ")"
element -> "VS(" number ")"
geometric -> "B(" number "," number "," color "," number "," color ")"
geometric -> "E(" number "," number "," color "," number "," color ")"
geometric -> "D(" number "," number "," color "," number "," color ")"
geometric -> "HS(" number ")"
element -> "[" (geometric ):+ "]"
element -> "{" (element ):+ "}"
что позволяет нам сделать следующее:
{L(3.5,"rgb(98,76,15)")VS(3.5)}
(Заметьте, что в отличие от оператора повторения, оператор наложения можно использовать внутри себя.)
Подчистив описание и добавив в нужные места пробелы, мы получим следующую грамматику MBDL:
@builtin "number.ne"
@builtin "string.ne"
border -> (element WS):+
WS -> [\s]:*
number -> WS decimal WS
color -> WS dqstring WS
element -> "L(" number "," color ")"
element -> "VS(" number ")"
element -> "(" WS (element WS):+ ")"
element -> "[" WS (geometric WS):+ "]"
geometric -> "B(" number "," number "," color "," number "," color ")"
geometric -> "E(" number "," number "," color "," number "," color ")"
geometric -> "D(" number "," number "," color "," number "," color ")"
geometric -> "HS(" number ")"
Итак, MBDL теперь определён и мы создали грамматику языка. Её можно использовать с Nearley для распознавания строк языка. Прежде чем углубляться в MBDL/Nearley, я хотел бы реализовать используемые в MBDL примитивы, чтобы можно было отображать описанную на MBDL границу. Этим мы займёмся в следующей части.
Часть 3.
Теперь мы приступим к реализации самих примитивов отрисовки. (На этом этапе мне ещё не обязательно привязывать парсер к примитивам отрисовки. Для тестирования я буду просто вызывать их вручную.)
Начнём с примитива линии. Вспомним, какой он имеет вид:
L(width, color)
В дополнение к ширине и цвету тут есть неявный параметр — расстояние от внешнего края карты. (Я отрисовываю границы с края карты наружу. Заметьте, что начинали мы с другого!) Он не должен указываться на MBDL, потому что это может отслеживать интерпретатор, который выполняет MBDL для отрисовки границы. Однако это должны быть входные данные для всех примитивов отрисовки, чтобы они знали, где их нужно рисовать. Я назову этот параметр смещением.
Если бы мне нужно было только отрисовать границу вдоль верхней части карты, то примитив линии был бы очень прост в реализации. Однако на самом деле мне нужно будет отрисовывать сверху. снизу, слева и справа. (Возможно, когда-нибудь я реализую наклонные или искривлённые границы, но пока мы будем придерживаться стандартных прямоугольных границ.) Кроме того, длина и расположение элемента линии зависят от размеров карты (а также от смещения). Поэтому в качестве параметров мне нужны все эти данные.
Задав все эти параметры, достаточно просто создать примитив линии и использовать его для отрисовки линии вокруг карты:
(Заметьте, что для отрисовки «рукописной» линии я использую различные функции Dragons Abound.) Давайте попробуем создать более сложную границу:
L(3, black) L(10, gold) L(3, black)
Она выглядит вот так:
Довольно неплохо. Заметьте, что есть места, в которых чёрные линии и золотая линия не совсем выровнены из-за колебаний. Если я захочу избавиться от этих пятен, то можно просто уменьшить величину колебаний.
Реализовать примитив вертикального пробела довольно просто; он просто выполняет инкремент смещения. Давайте добавим небольшой пробел:
L(3, black) L(10, gold) L(3, black)
VS(5)
L(3, black) L(10, red) L(3, black)
При отрисовке линий углы можно реализовать отрисовкой между смещением и рисованием вдоль карты по часовой стрелке. Но в общем случае мне нужно реализовать на каждой стороне границы карты усечение, чтобы создать угловое соединение со скосом. Это будет необходимо для создания границ с узорами, правильно стыкующихся на углах, и в общем случае позволит избавиться от необходимости отрисовки элементов с краями под углом, которые бы потребовались в противном случае. (*)
(Примечание: как будет сказано в последующих частях, со временем я отказался от использования областей усечения при реализации углов. Основная причина заключается в том, что для создания сложных углов, например, квадратных смещений:
требуются всё более сложные области усечения. Также со временем я нашёл более хороший способ работы с паттернами в углах. Вместо того, чтобы возвращаться и переписывать эту часть статьи, я решил оставить её, чтобы проиллюстрировать процесс «творчества».)
Основная идея заключается в усечении каждой границы по диагоналям и создании четырёх усечённых областей в которой будет отрисовываться каждая сторона границы:
При усечении всё, нарисованное в соответствующей области, будет отрезано под нужным углом.
К сожалению, это создаёт небольшие разрывы вдоль диагональных линий, вероятно, потому, что браузер неидеально выполняет сглаживание вдоль усечённого края. Тест показал, что через зазор между двумя краями просвечивает фон. Исправить это удалось, немного расширив одну из масок (похоже, достаточно половины пикселя), но и это иногда не решает проблему.
Следующими нужно реализовать геометрические фигуры. В отличие от линий, они повторяются в паттерне, заполняя сторону границы карты:
Человек бы нарисовал этот узор слева направо, отрисовывая прямоугольник, ромб, а затем повторяя так же, пока не заполнится вся граница. Поэтому это можно реализовать так же и в программе, отрисовывая паттерн вдоль границы. Однако будет проще сначала отрисовать все прямоугольники, а затем все ромбы. Достаточно будет просто отрисовать вдоль границы одну и ту же геометрическую фигуру с интервалами. И очень удобно то, что каждый элемент имеет одинаковый интервал. Разумеется, человек так бы делать не стал, потому что слишком сложно располагать элементы в нужных местах, но для программы это не проблема.
То есть процедуре отрисовки простых геометрических фигур нужны параметры, в которые передаются все размеры и цвета фигуры (т.е. ширина, длина, толщина линии, цвет линии и заливки), а также исходная позиция (которую по причинам, которые скоро станут понятны, я буду считать центром фигуры), интервал горизонтального пробела для перехода между повторами, и количество повторов. Удобно также будет указать направление повтора в виде вектора [dx, dy], чтобы мы могли выполнять повторения слева направо, справа налево, вверх или вниз, просто меняя вектор и начальную точку. Соединим всё это вместе, и получим полосу повторяющихся фигур:
Использовав этот код несколько раз и выполняя отрисовку с одинаковым смещением, я смогу скомбинировать чёрные и белые полосы для создания масштаба карты:
Прежде чем я начну разбираться, как применить всё это к настоящей границе карты, давайте сначала реализуем тот же функционал для эллипсов и ромбов.
Ромбы — это просто прямоугольники с повёрнутыми вершинами, поэтому нужно внести только небольшое изменение в код. Оказалось, что у меня ещё нет готового кода для отрисовки эллипса, но очень легко взять параметрический вид эллипса и создать функцию, дающую мне точки эллипса:
Вот пример (созданный вручную), в котором используются реализованные выше возможности:
Для такого малого объёма кода выглядит довольно неплохо!
Давайте теперь решим сложный случай границ с повторяющимися элементами: углы.
При наличии границы с повторяющимися элементами решить проблему с углами можно несколькими способами. Первый — отрегулировать повторы таким образом, чтобы они выполнялись в углах без заметного брака:
Ещё один вариант — останавливать повторение где-то рядом с углом с обеих сторон. Так часто поступают, если паттерн нельзя легко «повернуть» в углу:
Последний вариант — закрыть паттерн каким-нибудь угловым украшением:
Когда-нибудь я доберусь до угловых украшений, но пока воспользуемся первым вариантом. Как сделать так, чтобы паттерн из полос или кругов без разрывов «поворачивал» в углах карты?
Основная идея заключается в том, чтобы поместить элемент паттерна ровно в угол, чтобы одна его половина находилась на одном краю карты, а другая — на соседнем. В этом примере круг находится ровно в углу и может отрисовываться с любого направления:
В других случаях элемент наполовину отрисовывается в одном направлении, и наполовину в другом, но края при этом совпадают:
В этом случае белая полоса отрисована с обеих сторон, но без зазоров соединяется в углу.
При размещении элемента в углу стоит учитывать два аспекта.
Во-первых, угловой элемент будет разбит и отзеркален относительно диагонали, проходящей через центр элемента. Элементы с радиальной симметрией, например, квадраты, круги и звёзды, не изменят своей формы. Элементы без радиально симметрии, например, прямоугольники и ромбы — при отзеркаливании относительно диагонали изменят форму.
Во-вторых, чтобы угловые элементы двух сторон соединялись правильно, вдоль обеих сторон карты должно быть целое количество элементов (*). Их необязательно должно быть одинаковое количество, но на обоих сторонах должно быть целое количество элементов. Если на одной стороне содержится дробное количество паттернов, то с одного края паттерн не совпадёт с прилегающей стороной.
(* В некоторых случаях, например, при длинных полосах, частичное повторение может встретиться с полным повторением и элементы всё равно будут выровнены. Однако получившийся угловой элемент будет асимметричным и отличаться длиной от того же элемента на сторонах карты. Пример этого можно увидеть здесь:
Белая полоса масштаба встречается с разными частичными повторами и в результате получается сдвинутый относительно центра элемент. Для масштаба карты это не всегда неверно, потому что он показывает абсолютное расстояние и не обязан быть симметричным. Но для декоративного узора это обычно выглядит плохо.)
Вот пример, показывающий, как целое число повторений обрезается ровно в углу:
Если сделать то же самое со всех четырёх сторон, то углы совпадут и узор бесшовно будет расположен по всей длине границы:
При внимательном изучении можно заметить, что узор не встречается ровно в углах. Половина круга в каждом углу берётся с каждой из сторон, и эти две половины независимо друг от друга «отрисовываются вручную», поэтому совпадают неидеально. Но теперь они достаточно близки к этому.
Итак, мы можем реализовать безупречное соединение узора в углах, подобрав для каждого края целое количество повторов. Однако решение этой задачи нетривиально.
Во-первых, предположим, нам известно, что сторона имеет длину 866 пикселей, и мы хотим повторить элемент 43 раз. Тогда элемент должен повторяться каждые 20,14 пикселей. Как же нам задать конкретную длину элемента (а в общем случае — паттерна элементов)? В показанном выше примере я добавил между кругами дополнительное пространство. Но если круги изначально касались друг друга, то это изменит паттерн. Возможно, стоит растянуть круги, чтобы они продолжали касаться друг друга?
Теперь элементы соприкасаются, но круги превратились в эллипсы и углы имеют странную форму. (Помните, я говорил, что элементы без радиальной симметрии изменяют форму при отражении относительно угла? Для полос это не будет большой проблемой.) Или, возможно, стоит сжать все элементы, чтобы они и касались друг друга, и помещались в подходящую длину:
Но чтобы это реализовать, нам нужно сделать элементы значительно меньше, чем они были изначально. Ни один из этих вариантов не кажется идеальным.
Вторая проблема возникает, когда стороны карты имеют разный размер. Теперь нужно решить задачу нахождения целого числа повторений, подходящих обеим сторонам. Идеально было бы найти одно решение, подходящее обеим сторонам. Но я не хочу делать этого ценой слишком большого изменения паттерна. Возможно, лучше будет создать немного отличающиеся паттерны на обеих сторонах, если они оба будут достаточно близки к исходному паттерну.
И, наконец, третья проблема возникает, когда я использую функцию наложения нескольких элементов друг на друга:
Я не хочу вносить в паттерн никаких изменений, которые разрушат соотношение между элементами. Думаю, что при правильном масштабировании соотношения в целом сохранятся, но мне нужно протестировать это.
Интересная задача, правда? Пока у меня нет особо качественных решений для неё. Возможно, они появятся позже!
Часть 4
Итак, мы реализовали примитивы для отрисовки линий и геометрических фигур. Я начал работать над использованием для заполнения границ повторяющихся фигур и рассказал о сложностях размещения произвольных паттернов в границу карты таким образом, чтобы они идеально соединялись в углах. Основная проблема заключается в том, что в общем случае приходится делать паттерн длиннее (или короче), чтобы он поместился в сторону. Варианты изменения длины паттерна — добавление или устранение пробелов, изменение длины элементов паттернов — ведут к различным изменениям самого паттерна. Похоже, что задача подбора паттерна из нескольких элементов очень сложна!
Когда я сталкиваюсь с такими, кажущимися неуступчивыми задачами, я люблю начинать с реализации простой версии. Неподдающиеся задачи часто можно решить многократным решением «простых» задач до тех пор, пока результат не станет достаточно хорошим. А иногда реализация простой версии даёт некое понимание, упрощающее решение более сложной задачи. Если лучше не становится и проблема остаётся неуступчивой, то по крайней мере у нас будет упрощённая версия, которая всё равно может пригодиться, пусть и не совсем так, как нужно.
Проще всего изменить длину паттерна, добавив длины, не меняя ничего в паттерне. По сути, это добавляет в конец паттерна пустое пространство. (Примечание: лучше распределить пустое пространство между всеми элементами в паттерне.) Стоит учесть, что такое решение может только удлинять паттерн. Мы всегда можем добавить в паттерн пустое пространство, но при необходимости не может его забрать — возможно, в паттерне больше не останется пустого места!
При таком подходе алгоритм расположения паттерна на стороне карты будет очень простым:
- Разделить длину стороны карты на длину паттерна и округлить в меньшую сторону, чтобы определить количество повторений паттерна, помещающихся на этой стороне.
- Расстояние между элементами в таком случае будет равно длине стороны, поделённой на количество повторений. (Это наиболее близкое к исходному расположение, учитывая то, что мы можем только добавлять пространство.)
- Отрисовать паттерн вдоль стороны с учётом вычисленного расстояния.
Эту систему реализовать было сложно. Углы упорно не желали совпадать. У меня ушло слишком много времени на осознание того, что когда карта не квадратная, я не могу отрисовывать области усечения для четырёх сторон из центра карты, ведь при этом создаются углы усечения, не равные 45 градусам. На самом деле области усечения должны напоминать обратную часть конверта:
Когда я с этим разобрался, алгоритм стал работать без проблем.
(Но не забывайте предыдущее примечание о том, что со временем я отказался от областей усечения!)
Вот пример с соотношением приблизительно 2:1:
В таком масштабе это довольно сложно заметить, но углы соединяются правильно, и между сторонами присутствует только небольшая визуальная разница. В данном случае алгоритму для выравнивания паттернов достаточно только вставить дробные пиксели, поэтому это незаметно глазу, в особенности потому, что контуры кругов перекрываются пикселем.
Вот другой пример, с полосами:
Это верхняя часть квадратной границы. Вот та же граница на более прямоугольной карте:
Здесь видно, что на стороне карты есть визуально больший зазор между полосами. Алгоритм должен вставлять пространство не больше, чем длина одного полного элемента; поэтому наихудший случай возникает, когда у нас есть длинные элементы и короткая сторона, которая чуть отличается от подходящего размера. Но в большинстве практических случаев выравнивание не очень вредит.
Вот пример с паттерном из нескольких элементов:
Здесь полосы накладываются на полосы:
Можно увидеть, что поскольку для каждого элемента выполнено одинаковое выравнивание, полосы остаются центрированными относительно друг друга.
Я предположил, что хорошее решение для размещения паттерна в стороне карты будет сложным, но очень простой подход с равномерным распределением элементом паттерна для заполнения нужного пространства достаточно хорошо работает для многих паттернов. Это напоминание всем нам: не нужно считать, что решение должно быть сложным; оно может оказаться проще, чем вы думаете!
Однако это решение не работает для паттернов с касающимися элементами, например, для масштаба карты. В этом случае добавление пространства смещает элементы:
Ещё один вариант удлинения паттерна, о котором я говорил выше, — растягивание отдельных элементов паттерна. Он подойдёт для чего-то наподобие паттерна масштаба, но будет плохо выглядеть в паттерне с симметричными элементами, потому что растягивание сделает их асимметричными.
Реализация варианта с растяжением оказалась сложнее, чем я ожидал, в основном потому, что мне пришлось растягивать элементы на разных краях карты на разную величину (потому что карта может быть не квадратной, а прямоугольной), а также динамически изменять расположение элементов на основании новых растянутых размеров. Но спустя несколько часов мне удалось этого добиться:
Теперь у меня есть все возможности, необходимые для отрисовки границы карты (хотя сами элементы границы создаются вручную):
Я преобразовал изображение в градации серого, потому что не хотел заморачиваться подбором цветов, да и сама карта довольно скучная, но как proof of concept границы выглядят достаточно красиво.
Часть 5
В части 2 я разработал грамматику Map Border Description Language (MBDL), а в частях 3 и 4 реализовал процедуры для выполнения всех примитивов языка. Теперь я поработаю над соединением этих частей, чтобы можно было описывать границу на MBDL и отрисовывать её на карте.
В части 3 я писал грамматику MBDL так, чтобы она работала с Javascript-инструментом парсинга Nearley. Готовая грамматика выглядит так:
@builtin "number.ne"
@builtin "string.ne"
border -> (element WS):+
WS -> [\s]:*
number -> WS decimal WS
color -> WS dqstring WS
element -> "L(" number "," color ")"
element -> "VS(" number ")"
element -> "(" WS (element WS):+ ")"
element -> "[" WS (geometric WS):+ "]"
geometric -> "B(" number "," number "," color "," number "," color ")"
geometric -> "E(" number "," number "," color "," number "," color ")"
geometric -> "D(" number "," number "," color "," number "," color ")"
geometric -> "HS(" number ")"
По умолчанию, при успешном парсинге правила с помощью Nearley правило возвращает массив, содержащий все элементы, соответствующие правой части правила. Например, если правило
test -> "A" | "B" | "C"
сопоставлено со строкой
A
то Nearley вернёт
[ "A" ]
Массив с единственным значением — строку «A», соответствующую правой части правила.
Что же возвращает Nearley, когда с помощью этого правила парсит элемент?
number -> WS decimal WS
В правой части правила есть три части, поэтому она вернёт массив с тремя значениями. Первое значение будет тем, что вернёт правило для WS, второе значение будет тем, что вернёт правило для decimal, а третье значение будет тем, что вернёт правило для WS. Если с помощью приведённого выше правила я выполню парсинг " 57", то результат будет следующим:
[
[ " " ],
[ "5", "7" ],
[ ]
]
Конечным результатом парсинга Nearley будет вложенный ряд массивов, представляющий собой синтаксическое дерево. В некоторых случаях синтаксическое дерево — это очень полезное представление, в других случаях — не совсем. В Dragons Abound, например, такое дерево не особо полезно.
К счастью, правила Nearley могут переопределять стандартное поведение и возвращать то, что им захочется. На самом деле, (встроенное) правило для decimal не возвращает список цифр, оно возвращает эквивалентное число Javascript, что в большинстве случаев намного полезнее, то есть возвращаемое значение правила number имеет вид:
[
[ " " ],
57,
[ ]
]
Правила Nearley переопределяют стандартное поведение, добавляя к правилу постобработчик, берущий стандартный массив и заменяющий его тем, что нужно. Постобработчик — это просто код Javascript внутри особых скобок в конце правила. Например, в правиле number меня никогда не интересуют возможные пробелы с любой стороны от числа. Поэтому было бы удобно, если бы правило просто возвращало число, а не массив из трёх элементов. Вот постобработчик, который выполняет эту задачу:
number -> WS decimal WS {% default => default[1] %}
Этот постобработчик берёт стандартный результат (показанный выше массив из трёх элементов) и заменяет его вторым элементом массива, который является числом Javascript из правила decimal. Итак, теперь правило number возвращает настоящее число.
Этот функционал можно использовать для переработки входящего языка в промежуточный язык, с которым проще работать. Например, я могу использовать грамматику Nearley для превращения строки MBDL в массив структур Javascript, каждая из которых представляет примитив, идентифицируемый по полю «op». Правило для примитива линии будет выглядеть примерно так:
element -> "L(" number "," color ")" {% data=> {op: "L", width: data[1], color: data[3]} %}
То есть результатом парсинга «L(13,black)» будет структура Javascript:
{op: "L", width: 13, color: "black"}
После добавления соответствующей постобработки возвращаемый из грамматики результат может быть последовательностью (массивом) структур операций для входящей строки. То есть результатом парсинга строки
L( 415, “black")
VS(5)
[B(1, 2, “black", 3, “white") HS(5) E(1, 2, “black", 3, “white")]
будет
[
{op: "L", width: 415, color: "black"},
{op: "VS", width: 5},
{op: "P",
elements: [{op: "B", width: 1, length: 2,
olColor: "black", olWidth: 3,
fill: "white"},
{op: "HS", width: 5},
{op: "E", width: 1, length: 2,
olColor: "black", olWidth: 3,
fill: "white"}]}
]
что гораздо проще обработать для создания границы карты.
На этом моменте у вас может возникнуть вопрос — если этап постобработки правила Nearley может содержать любой Javascript, то почему бы не пропустить промежуточный вид и просто отрисовывать границу карты прямо во время постобработки? Для многих задач этот подход был бы идеальным. Я решил не использовать его по нескольким причинам.
Во-первых, в MBDL есть пара (*) компонентов, которые невозможно выполнить в процессе парсинга. Например, мы не можем отрисовать повторяющиеся геометрические элементы (полосу или ромб) в процессе парсинга, потому что нам нужно знать информацию от других элементов в том же паттерне. В частности, нам нужно знать общую длину паттерна, чтобы понимать, как далеко надо расставить повторы каждого отдельного элемента. То есть элемент паттерна всё равно должен создать промежуточное представление всех геометрических элементов.
(* Есть и другие компоненты с похожими ограничениями, о которых я пока не говорил.)
Во-вторых, Javascript в Nearley встраивается в правила, поэтому мы никак не сможем передать в Javascript дополнительную информацию, за исключением глобальных переменных. Например, для отрисовки границы мне нужно знать размер карты, четыре используемые области усечения и т.д. Хоть я и могу добавить код, делающий эту информацию доступной для постобработчиков Nearley, это будет немного неряшливо и, возможно, такой код будет сложно поддерживать.
По этим причинам я выполняю парсинг в промежуточное представление, которое затем выполняется для создания самой границы карты.
Следующим этапом будет разработка интерпретатора, получающего промежуточное представление MBDL и выполняющего его для генерации границ карты. Это сделать не очень сложно. В основном работа заключается в задании исходных условий (например, генерации областей усечения для четырёх сторон карты) и итерировании по последовательности структур промежуточного представления с выполнением каждой.
Здесь есть пара скользких моментов.
Во-первых, мне нужно перейти от отрисовки снаружи внутрь к отрисовке изнутри наружу. Причина заключается в том, что я хочу, чтобы большинство границ не накладывалось на карту, поэтому мне нужно отрисовывать границы так, чтобы линии внутреннего края совпадали с краями карты. Если я рисую снаружи внутрь, то мне нужно узнать ширину границы до того, как я начну отрисовку, чтобы граница не накладывалась на карту. Если я рисую изнутри наружу, то просто начинаю с края карты и отрисовываю наружу. Также это позволяет при желании накладывать границу на карту; для этого достаточно начать границу с отрицательного вертикального пробела (VS).
Ещё один сложный момент — повторяющиеся паттерны. Для отрисовки повторяющихся паттернов мне нужно просмотреть все элементы паттерна и определить самый широкий, потому что он будет задавать ширину всего паттерна. Также мне нужно просмотреть и отследить длину паттерна, чтобы я знал, какое расстояние оставлять перед каждым повтором.
Вот пример довольно сложной границы, который я использовал для проверки интерпретатора:
Думаю, можно (нужно?) было присоединить его для тестирования к парсеру, но для этой границы я просто создал промежуточное представление вручную:
[
{op:'P', elements: [
{op:'B', width: 10, length: 37, lineWidth: 2, color: 'black', fill: 'white'},
{op:'B', width: 10, length: 37, lineWidth: 2, color: 'black', fill: 'black'},
]},
{op:'VS', width: 2},
{op:'L', width:3, color: 'black'},
{op:'PUSH'},
{op:'L', width:10, color: 'rgb(222,183,64)'},
{op:'POP'},
{op:'PUSH'},
{op:'P', elements: [
{op:'E', width: 5, length: 5, lineWidth: 1, color: 'black', fill: 'red'},
{op:'HS', length: 10},
]},
{op:'L', width:3, color: 'black'},
{op:'POP'},
{op:'VS', width: 2},
{op:'P', elements: [
{op:'E', width: 2, length: 2, lineWidth: 0, color: 'black', fill: 'white'},
{op:'HS', length: 13},
]},
]
Я создал это представление путём проб и ошибок. Как бы то ни было, интерпретатор работает!
В качестве последнего шага позвольте мне использовать парсер для создания промежуточного представления из версии на MBDL. Здесь мне показать особо нечего: пришлось исправить несколько названий полей, но во всём остальном код работал хорошо. Для границы я использовал слегка отличающуюся версию MBDL:
[B(5,37,"black",2,"white") B(5,37,"black",2,"black")]
VS(3)
L(3,"black")
{L(10,"rgb(222,183,64)")}
[E(5,5,"black",1,"red") HS(-5) E(2,2,"none",0,"white") HS(10)]
L(3,"black")
Она отрисовывает ту же границу, но немного иным способом. Также я изменил синтаксис для наложения, заменив круглые скобки фигурными, чтобы оно сильнее отличалось от другого синтаксиса.
Чтобы показать, почему я хотел выполнять отрисовку изнутри наружу, а не просто автоматически размещать границу снаружи карты, я могу добавить к началу этой границы отрицательный вертикальный пробел, чтобы переместить масштаб карты внутрь края карты:
Теперь у меня есть бОльшая часть инфраструктуры, необходимой для процедурной генерации границ карт: язык описания границ, парсер языка и процедуры для выполнения промежуточного представления. Осталось только заняться сложной частью — процедурной генераций!
Часть 6
Теперь, когда реализован весь MBDL, я намеревался приступить к процедурной генерации границ карт, но не уверен пока, как я хочу это делать, потому что немного помедлю и реализую ещё пару возможностей MBDL.
При первом обсуждении обработки углов с паттернами я говорил о паре разных подходов. В конце концов я реализовал скошенные углы, но был и второй вариант: останавливать паттерн недалеко от угла, как в этих примерах:
Такое решение часто используется, когда паттерн границы является какой-то асимметричной фигурой, рунами или чем-то ещё, что нельзя повернуть на 90 градусов, сохранив при этом выравнивание. Но очевидно, что это сработает и с геометрическими фигурами.
Это может быть опцией, которую выбирают перед генерацией границы, но можно добавить немного гибкости, если включить её с одной части границы, а на другой использовать скошенный угол. Для этого мне придётся добавить в MBDL новую команду. Подозреваю, что могут возникнуть и другие варианты для разных частей границы, поэтому я добавлю общую команду опций:
element -> "O(MITER)"
element -> "O(STOPPED)"
element -> "O(STOPPED," number ")"
(Здесь мы снова для понятности опустим пробелы и некоторые другие детали.) Пока единственными вариантами опций являются «MITER» для скошенных углов и «STOPPED» для остановки рядом с углами. Если STOPPED не передаётся никакое значение, то программа останавливает паттерн на каком-то разумном расстоянии от угла. Если значение передаётся, то паттерн останавливается на таком расстоянии до угла.
Если используются углы STOPPED, то я прекращаю отрисовывать паттерн углов вдали от углов. Вот как это выглядит:
Здесь я использовал для чёрно-белого паттерна масштаба опцию MITER, поэтому он отзеркаливается относительно угла. Для паттерна из красных кругов и чёрных квадратов внутри золотой линии (и для паттерна из кругов снаружи границы) я использовал STOPPED. Можно увидеть, что эти два паттерна завершаются неподалёку от угла.
Однако тут есть пара проблем. Во-первых, мы видим, что слева самым ближним к углу элементом является чёрный квадрат, а сверху — красный круг. Так получилось, потому что угол находится рядом с началом повтора с одной стороны и рядом с концом повтора с другой. Но это выглядит странно. Было бы лучше, если бы углы были симметричными, даже если для этого пришлось добавить в конец паттерна ещё один элемент. Во-вторых, можно увидеть, что паттерн снаружи границы (полукруги и чёрные точки) тоже заканчивается за один повтор до угла. Но так как длина этого повтора намного меньше длины повторов красных кругов/чёрных квадратов, они оказываются в разных местах. Наверно, лучше было бы, чтобы все паттерны останавливались на одинаковом расстоянии до угла.
Чтобы устранить первую проблему, нужно добавить ещё один повтор первого элемента паттерна в конце каждой стороны границы. Но на самом деле это немного сложнее, потому что я мог бы использовать отрицательное горизонтальное смещение внутри паттерна для наложения нескольких элементов (как и сделано здесь). Также нужно добавить ещё один повтор к любому элементу паттерна, имеющему ту же начальную точку, что и у первого элемента.
Теперь паттерн симметричен относительно угла и выглядит намного лучше.
Далее мне нужно отследить самый длинный паттерн STOPPED и остановить каждый паттерн STOPPED на этом расстоянии:
Теперь паттерн белых кругов отставлен больше, но он всё равно не выровнен относительно паттерна красных кругов. Почему? Так получилось, потому что паттерн белых кругов отстоит дальше от края карты, и длина границы здесь больше чем там, где отрисован паттерн красных кругов. Чтобы устранить эту проблему, нужно отодвигать паттерны ещё и учитывая их смещение относительно края карты.
Теперь всё выровнено красиво.
Вторая опция для углов — это квадратные смещения по углам, например такие:
Реализовать это будет гораздо сложнее!
Однако грамматика этой опции проста и использует опкод Option:
element -> "O(SQOFFSET)"
element -> "O(SQOFFSET," number ")"
Число обозначает размер квадратного смещения для элемента на краю карты; элементы с разными смещениями должны соответствующим образом выравниваться. При отсутствии числа подходящий размер смещения выбирает программа. Обнуление числа приводит отключению квадратного смещения. Это позволяет создавать границы, в которых некоторые элементы используют квадратные смещения, а другие нет, как в этой границе:
Первым делом я осознал, что здесь мне понадобятся дополнительные области усечения, потому что я использую усечение для обработки мест, в которых граница изменяет направление. Для SQOFFSET потребуется больше сложных областей усечения; также понадобятся отдельные области для разных элементов при включении и отключении SQOFFSET. С учётом того, что области усечения и так добавляют нежелательные артефакты, это кажется слишком большим объёмом работы.
Когда выше я работал над останавливаемыми паттернами, я реализовал заполнение асимметричного паттерна, чтобы добавлять ещё один повтор с одного конца паттерна. Также я понял, что это позволить избавиться от необходимости скошенных углов. Я просто будут отрисовывать все паттерны вдоль границы по часовой стрелке, начиная паттерн в одном углу и заканчивая неподалёку от следующего угла. Это позволит мне избавиться от областей усечения.
Самым важным в этом новом способе работы с углами стало то, что первый элемент паттерна теперь не является «разделённым» на две стороны. Если посмотреть на чёрно-белые паттерны масштаба на картах выше, то можно увидеть, что там есть белый прямоугольник, проходящий через угол. Теперь белый прямоугольник будет упираться в угол:
Карты рисуются обоими способами, но это не очень большая проблема.
Для начала я реализовал смещения для линий. Для этого достаточно было повернуть линию относительно соответствующих углов:
Как можно понять, я могу комбинировать углы со смещениями и обычные углы, как на показанной выше карте:
Разумеется, заворачивать паттерны за угол сложнее. Общая идея заключается в том, чтобы отрисовывать от одного угла почти до другого, и так далее вдоль границы, пока мы не вернёмся к началу. Теорегически достаточно отрисовывать только горизонтальные и вертикальные паттерны, и всё должно красиво выравниваться; на самом деле отслеживать всё это довольно муторно. На самом деле мне пришлось дважды полностью переписать код и исписать кучу бумаги, но подробно об этом рассказывать я не будут. Просто покажу результат:
В углах возникает раздражающая оптическая иллюзия — угловой элемент кажется неотцентрированным ближе к внешней части угла. На самом деле это неправда, но так кажется, потому что ближе к внутренней части угла визуально больше пустого пространства.
Так как отрезки смещённых углов достаточно коротки, в углу очень легко создать неравновесный паттерн:
Иногда это выглядит довольно некрасиво. Мне это напомнило старый анекдот:
Пациент: «Доктор, когда я так делаю, мне больно».
Доктор: «Тогда не делайте так!»
Поэтому я постараюсь так не делать.
Обычно я не буду рисовать масштаб карты вдоль смещённого угла, но если мне это понадобится, то нужно будет использовать опцию, растягивающую паттерн, чтобы масштаб карты помещался в угол без зазоров между прямоугольниками:
Можно увидеть, что в результате размер прямоугольников масштаба заметно меняется. То есть это не очень хороший вариант. (Кстати, у смещённых углов есть ещё и баг в паттерне из кругов. Позже я его исправил, но как и говорил, делать это очень сложно.)
Если паттерн слишком велик для размещения на отрезке смещённого угла, то алгоритм просто сдаётся:
Что далеко неидеально, но, как я говорил выше «Тогда не делайте так». (На самом деле не очень сложно будет добавить функцию сжатия или растяжения, если бы она мне понадобилась.)
Что произойдёт, если я использую и смещённые углы, и опцию, останавливающую паттерны перед углами? В таком случае я просто останавливаюсь недалеко от смещённых углов:
Мне кажется, что это логичное решение.