Все началось где-то два месяца назад, когда я (aquaratixc) страдал очередной фигней и развлекался с dlib, рисуя первые приходящие в голову кривые, задаваемые с помощью математики…
Сижу, значит, за компьютером, никого не трогаю, как вдруг пишет мне Bagomot во ВКонтакте и говорит, что у него не получается нарисовать гипоциклоиду.
Само собой, удивлению моему не было предела, ибо обычно, этот товарищ ничем подобным не занимается да и я знать не знаю ничего про гипоциклоиду (это правда, к сожалению) — и тут, я решаю помочь Bagomot’у и «запилить» код, так как на вид параметрическое задание функции, рисующей эту занятную кривую, довольно несложное, кроме того, параметрическое задание — это уже давно пройденный нами этап…
Ну, так вот, гипоциклоида — это, согласно авторитетным источникам, плоская кривая, образуемая точкой окружности, катящейся по внутренней стороне другой окружности без скольжения. Определение, данное математиками, не прибавило никакой ясности, но вот уравнения в параметрической форме дают уже кое-что и выглядят примерно так:
x(t) = (r * (k - 1.0) * (cos(t) + (cos((k - 1.0) * t) / (k - 1.0)))); y(t) = (r * (k - 1.0) * (sin(t) - (sin((k - 1.0) * t) / (k - 1.0))));
Параметры r и k определяют внешний вид гипоциклоиды, при этом r — это размер катящейся окружности, а k — это отношение радиуса неподвижной окружности (обозначается как R) к радиусу катящейся окружности, иными словами, k = R / r.
В принципе, это все, что необходимо для реализации и не хватает только части, ответственной за отрисовку, а также диапазона входных параметров (кроме того, мы понятия не имеем, что должно получиться). Логично предположить, что если функция задана в таком виде и при присутствующих внутри нее тригонометрических функциях, то диапазон, скорее всего, представляет собой проход градусов от 0 до 360 с некоторым минимальном шагом, ну а что касается функции отрисовки, то здесь чуть похитрее…
Вот код функции отрисовки:
auto parametricalDraw(alias func1, alias func2, Range)(Range r, ref SuperImage simg, Color4f color = Color4f(0.0f, 0.0f, 0.0f)) { import std.algorithm : each, map; import std.range : zip; auto xt = map!(a => cast(int) (0.5 * simg.width + func1(a)))(r); auto yt = map!(a => cast(int) (0.5 * simg.height + func2(a)))(r); each!(ind => simg[ind[0], ind[1]] = color)(zip(xt, yt)); }
Не стоит этого бояться, так как ничего особенного страшного тут нет — просто код сделан в виде очередного шаблона, который вводит новый уровень обобщения и позволяет реализовать алгоритм в достаточно универсальной (хотя и не до конца) форме.
Как оно работает ?
Сначала, в функциональном стиле мы вычисляем функции x(t) и y(t), представленный переменными с говорящими именами, что делается сразу для всего входного диапазона значений, с помощью хитроумного шаблона map из std.algorithm. Этот шаблон умеет делать одну вещь: принимая некоторую функцию и некоторый диапазон, он возвращает новый диапазон, состоящий из элементов, к каждому из которых применена эта функция (синтаксис примерно такой map!(a => функция_от_а(a))(диапазон).
Прошу заметить, что в первых скобках фигурирует так называемая лямбда-функция или анонимная функция, тело которой записано сразу после знака =>, который в данном случае может читаться и восприниматься, как математическая фраза «отображается в»). Функция, которая берется для вычисления берется из первого параметра шаблона, но помимо вычисления самого результата для всех элементов диапазона, происходит еще и прибавление к ним значения половины от длины изображения, что необходимо для позиционирования начала координат отрисовки в центр изображения. Аналогичные трюки проделаны и для второй функции, только тут в качестве заданной функции берется второй аргумент шаблона и прибавляется уже половина от ширины изображения.
Следующая фаза работы шаблона представляет собой тоже прием из функционального программирования, использование шаблона zip, который сворачивает несколько диапазонов в единую структуру, выдавая из нее партии, в каждой из которых заключен n-ый элемент первого диапазона, n-ый элемент второго диапазона и т.д. В D этот шаблон действует именно так, и выдает он партии в виде кортежей (tuples), доступ к элементам которых возможен с помощью индексов, таких же как и в обыкновенных массивах.
Далее, наш путь до смешного прост: смешиваем xt и yt в один диапазон с помощью zip, а потом достаем из него элементы и используем их как индексы массива с изображением (simg — это массив, который содержат в себе элементы типа Color4f, ответственные за цвет пикселя с координатами, соответствующими индексам этого двумерного массива), присваивая им понравившийся цвет.
По идее, для такого трюка, можно было бы использовать map, а не each, но тут возникает проблема — поскольку выражение для анонимной функции возвращает в данном случае «пустышку» (хотя это не совсем так, но с точки зрения map, это выглядит как было написано выше) и компиляция кончается ошибкой. Шаблон each, как говорится в стандартной документации, «применяет энергично» функцию к каждому элементу диапазона, что означает для нас большую удачу — энергичное выполнение (в противоположность ленивому. как например, в map) позволяют использовать функцию применимо к целому диапазону без всяких циклов и дополнительных усилий.
Теперь, можно оформить гипоциклоиду и начать уже процесс ее отрисовки:
class Hypocycloide { private: float r, k; public: this(float r, float k) { this.r = r; this.k = k; } void draw(ref SuperImage simg) { auto f = delegate(float t){ return cast(float) (r * (k - 1.0) * (cos(t) + (cos((k - 1.0) * t) / (k - 1.0)))); }; auto g = delegate(float t){ return cast(float) (r * (k - 1.0) * (sin(t) - (sin((k - 1.0) * t) / (k - 1.0)))); }; parametricalDraw!((a => f(a)), (b => g(b)))(iota(0.0, 360.0, 0.01), simg, Color4f(1.0f, 0.0f, 0.9f)); } }
А испытать полученный код можно примерно так:
void main() { auto simg = image(1000, 1000); for (int i = 0; i < 1000; i++) { for (int j = 0; j < 1000; j++) { simg[i, j] = Color4f(1.0f, 1.0f, 1.0f); } } Hypocycloide hc = new Hypocycloide(50, 3); hc.draw(simg); simg.savePNG("hypocycloide.png"); }
Выглядит оно вот так:
Честно говоря, первый параметр мы выбрали от балды, а вот второй параметр, по идее должен быть целочисленным и по идее больше 2 (случаи с 0 и 1, дают вырождающиеся результаты и просто неинтересны) и приводит к таким занимательным картинкам:
k = 4 (астроида)
k = 5
k = 6
k = 7
k = 21 (r = 20)
Однако, я пошел дальше, отступив от определения с окружностями и от реалий физического мира — я предположил, что коэффициент k может быть дробным и может быть отрицательным (а может быть и так, и так одновременно). Так, например, дробные значения k приводят к увеличению количества «шипов» для гипоциклоиды, иногда приводя к совсем странным случаям, дающим нечто наподобие «солнышка», которое демонстрировалось в одной из статей по Icon:
k = 3.8 (r = 40)
Отрицательные значения приводят к тому, что «шипы» обращаются вовнутрь, давая интересные шарообразные структуры:
k = -7.8 (r = 40)
А у Bagomot’а, насколько я помню, возникла ошибка в реализации, когда он попытался параметр R задавать извне, считая из него k, но, иногда бывает так, что ошибки приносят более интересный результат, чем тот, что планировалось получить, но об этом он как-нибудь расскажет сам…