Гипоциклоида и элементы функционального программирования

Все началось где-то два месяца назад, когда я (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, но, иногда бывает так, что ошибки приносят более интересный результат, чем тот, что планировалось получить, но об этом он как-нибудь расскажет сам…

Добавить комментарий