
Сегодня мы рассмотрим графический пакет для языка Julia, который называется Luxor. Это один из тех инструментов, которые превращают процесс создания векторных изображений в решение логических задачек с сопутствующей бурей эмоций.
Осторожно! Под катом 8.5 Мб легковесных картинок и гифок изображающих психоделические яйца и четырехмерные объекты, просмотр которых может вызвать лёгкое помутнение рассудка!
Установка
https://julialang.org — скачиваем дистрибутив Джулии с официального сайта. Затем, запустив интерпретатор, вбиваем в его консоль команды:
using Pkg Pkg.add("Colors") Pkg.add("ColorSchemes") Pkg.add("Luxor")
что установит пакеты для расширенной работы с цветами и сам Luxor.
Возможные проблемы
Главная проблема как современного программирования в общем так и опенсорса в частности — то что одни проекты строятся поверх других, наследуя все ошибки, а то и порождая новые из-за несовместимостей. Как и многие другие пакеты Luxor использует для своей работы другие julia-пакеты, которые, в свою очередь, являются оболочками существующих решений.
Так, ImageMagick.jl не хотел загружать и сохранять файлы. Решение нашлось на странице оригинала — оказалось, он не любит кириллицу в путях.
Проблема номер два возникла с пакетом низкоуровневой графики Cairo на Windows 7. Решение упрячу здесь:
- Набираем в интерпретаторе
]add Gtk— начнет устанавливаться пакет для работы с gui и скорее всего он упадет во время построения - Далее качаем gtk+-bundle_3.6.4-20130513_win64
- В папке с пакетами Джулии во время установки накидалось всё необходимое, но во время выполнения пункта один, gtk не достроился, поэтому мы и скачали готовую версию для нашей машины — кидаем содержимое скачанного архива в директорию C:\Users\User.julia\packages\WinRPM\Y9QdZ\deps\usr\x86_64-w64-mingw32\sys-root\mingw (Ваш путь может отличаться)
- Запустите julia и вбейте
]build Gtkи после построенияusing Gtk, и, для пущей верности, перестроим Люксор:]build Luxor - Перезапускаем julia, и можем смело использовать всё что нужно:
using Luxor
В случае иных проблем стараемся найти свой случай
Если хочется пробовать анимацию
Пакет Luxor создает анимацию средствами ffmpeg при условии, что он присутствует на вашем компьютере. ffmpeg — это кроссплатформенная open-source библиотека для обработки видео- и аудиофайлов, очень полезная штука (есть хороший экскурс на хабре). Установим ее:
- Качаем ffmpeg с оффсайта. В моем случае это загрузка для windows
- Распаковываем и прописываем путь к ffmpeg.exe в переменную Path.

Компьютер/Свойства сиситемы/Дополнительные параметры системы/Переменные среды/Path (Создать если нет) и добавить туда путь к Вашему ffmpeg.exe
Пример C:\Program Files\ffmpeg-4.1.3-win64-static\bin
если в Path уже есть значения, то отделяем их точкой с запятой.
Теперь если в командную консоль (cmd) вбить ffmpeg с нужными параметрами, оно запустится и отработает, а Julia будет с ним общаться только так.
Hello world
Начнем с маленького подводного камня — при построении изображения создается графический файл и сохраняется в рабочей директории. То есть, при работе в REPL корневая папка julia будет забиваться картинками, а если рисовать в Jupyter — то картинки накапливаются рядом с блокнотом-проектом, поэтому, будет хорошей привычкой перед началом работы задавать рабочую директории в отдельно отведенном месте:
using Luxor cd("C:\\Users\\User\\Desktop\\mycop")
Создадим первый рисунок
Drawing(220, 220, "hw.png") origin() background("white") sethue("black") text("Hello world") circle(Point(0, 0), 100, :stroke) finish() preview()

Drawing() создает рисунок, по умолчанию в формате PNG, имя файла по умолчанию 'luxor-drawing.png', размер по умолчанию 800x800, для всех форматов кроме png можно задавать нецелочисленные размеры, а также, можно использовать размеры листа бумаги ("A0", "A1", "A2", "A3", "A4"...)
finish() — завершает рисование и закрывает файл. Вы можете открыть его во внешнем приложении просмотра с помощью preview(), который при работе в Jupyter (IJulia) отобразит файл PNG или SVG в блокноте. При работе в Juno отобразит файл PNG или SVG на панели «График». В Repl же вызовется средство для работы с изображениями, которое вы задали для данного формата в своей ОС.
То же самое можно записать в короткой форме используя макросы
@png begin text("Hello world") circle(Point(0, 0), 100, :stroke) end
Для векторных форматов EPS, SVG, PDF всё работает аналогично.
Евклидово яйцо

Это довольно интересный способ рисования яйца, а если соединить ключевые точки и разрезать по полученным линиям, выйдет отличный танграм

Начнем с окружности:
@png begin radius=80 setdash("dot") sethue("gray30") A, B = [Point(x, 0) for x in [-radius, radius]] line(A, B, :stroke) circle(O, radius, :stroke) end 200 200 "egg0" # размеры и название файла

Всё предельно просто: setdash("dot") — рисуем точками, sethue("gray30") — цвет линии: чем меньше, тем темнее, чем ближе к 100 тем белее. Класс точки определен и без нас, а центр координат (0,0) можно задавать буквой O. Добавляем две окружности и подписываем точки:
@png begin radius=80 setdash("dot") sethue("gray30") A, B = [Point(x, 0) for x in [-radius, radius]] line(A, B, :stroke) circle(O, radius, :stroke) label("A", :NW, A) label("O", :N, O) label("B", :NE, B) circle.([A, O, B], 2, :fill) circle.([A, B], 2radius, :stroke) end 600 400 "egg2"

Для поиска точек пересечения есть функция, которая называется intersectionlinecircle(), которая находит точку или точки, где линия пересекает окружность. Таким образом, мы можем найти две точки, где один из кругов пересекает воображаемую вертикальную линию, проведенную через O. Из-за симметрии нам можно обработать только круг A.
@png begin radius=80 setdash("dot") sethue("gray30") A, B = [Point(x, 0) for x in [-radius, radius]] line(A, B, :stroke) circle(O, radius, :stroke) label("A", :NW, A) label("O", :N, O) label("B", :NE, B) circle.([A, O, B], 2, :fill) circle.([A, B], 2radius, :stroke) # >>>> nints, C, D = intersectionlinecircle(Point(0, -2radius), Point(0, 2radius), A, 2radius) if nints == 2 circle.([C, D], 2, :fill) label.(["D", "C"], :N, [D, C]) end end 600 400 "egg3"

Чтоб определить центр верхней окружность найдем пересечение OD
@png begin radius=80 setdash("dot") sethue("gray30") A, B = [Point(x, 0) for x in [-radius, radius]] line(A, B, :stroke) circle(O, radius, :stroke) label("A", :NW, A) label("O", :N, O) label("B", :NE, B) circle.([A, O, B], 2, :fill) circle.([A, B], 2radius, :stroke) # >>>> nints, C1, C2 = intersectionlinecircle(O, D, O, radius) if nints == 2 circle(C1, 3, :fill) label("C1", :N, C1) end end 600 400 "egg4"

Радиус придаточной окружности определяется ограничением двумя большими окружностями:
@png begin radius=80 setdash("dot") sethue("gray30") A, B = [Point(x, 0) for x in [-radius, radius]] line(A, B, :stroke) circle(O, radius, :stroke) label("A", :NW, A) label("O", :N, O) label("B", :NE, B) circle.([A, O, B], 2, :fill) circle.([A, B], 2radius, :stroke) # >>>> nints, C1, C2 = intersectionlinecircle(O, D, O, radius) if nints == 2 circle(C1, 3, :fill) label("C1", :N, C1) end # >>>> nints, I3, I4 = intersectionlinecircle(A, C1, A, 2radius) nints, I1, I2 = intersectionlinecircle(B, C1, B, 2radius) circle.([I1, I2, I3, I4], 2, :fill) # >>>> if distance(C1, I1) < distance(C1, I2) ip1 = I1 else ip1 = I2 end if distance(C1, I3) < distance(C1, I4) ip2 = I3 else ip2 = I4 end label("ip1", :N, ip1) label("ip2", :N, ip2) circle(C1, distance(C1, ip1), :stroke) end 600 400 "egg5"

Яйцо готово! Осталось его собрать из четырех дуг, задаваемых функцией arc2r() и залить площадь:
@png begin radius=80 setdash("dot") sethue("gray30") A, B = [Point(x, 0) for x in [-radius, radius]] line(A, B, :stroke) circle(O, radius, :stroke) label("A", :NW, A) label("O", :N, O) label("B", :NE, B) circle.([A, O, B], 2, :fill) circle.([A, B], 2radius, :stroke) # >>>> nints, C1, C2 = intersectionlinecircle(O, D, O, radius) if nints == 2 circle(C1, 3, :fill) label("C1", :N, C1) end # >>>> nints, I3, I4 = intersectionlinecircle(A, C1, A, 2radius) nints, I1, I2 = intersectionlinecircle(B, C1, B, 2radius) circle.([I1, I2, I3, I4], 2, :fill) # >>>> if distance(C1, I1) < distance(C1, I2) ip1 = I1 else ip1 = I2 end if distance(C1, I3) < distance(C1, I4) ip2 = I3 else ip2 = I4 end label("ip1", :N, ip1) label("ip2", :N, ip2) circle(C1, distance(C1, ip1), :stroke) # >>>> setline(5) setdash("solid") arc2r(B, A, ip1, :path) # centered at B, from A to ip1 arc2r(C1, ip1, ip2, :path) arc2r(A, ip2, B, :path) arc2r(O, B, A, :path) strokepreserve() setopacity(0.8) sethue("ivory") fillpath() end 600 400 "egg6"

А теперь, чтоб как следует побаловаться занесем свои наработки в
function egg(radius, action=:none) A, B = [Point(x, 0) for x in [-radius, radius]] nints, C, D = intersectionlinecircle(Point(0, -2radius), Point(0, 2radius), A, 2radius) flag, C1 = intersectionlinecircle(C, D, O, radius) nints, I3, I4 = intersectionlinecircle(A, C1, A, 2radius) nints, I1, I2 = intersectionlinecircle(B, C1, B, 2radius) if distance(C1, I1) < distance(C1, I2) ip1 = I1 else ip1 = I2 end if distance(C1, I3) < distance(C1, I4) ip2 = I3 else ip2 = I4 end newpath() arc2r(B, A, ip1, :path) arc2r(C1, ip1, ip2, :path) arc2r(A, ip2, B, :path) arc2r(O, B, A, :path) closepath() do_action(action) end
Используем рандомные цвета, рисование слоями и различные начальные условия:
@png begin setopacity(0.7) for θ in range(0, step=π/6, length=12) @layer begin rotate(θ) translate(100, 50) # translate(0, -150) #rulers() egg(50, :path) setline(10) randomhue() fillpreserve() randomhue() strokepath() end end end 400 400 "eggs2"


Помимо обводки и заливки, вы можете использовать контур в качестве области отсечения (обрезать другое изображение в форму яйца) или в качестве основы для различных конструкторов. Функция egg() создает контур и позволяет применить к нему действие. Также возможно преобразовать наше творение в многоугольник (массив точек). Следующий код преобразует контур яйца в многоугольник, а затем перемещает каждую другую точку многоугольника на полпути к центроиду.
@png begin egg(160, :path) pgon = first(pathtopoly()) pc = polycentroid(pgon) circle(pc, 5, :fill) for pt in 1:2:length(pgon) pgon[pt] = between(pc, pgon[pt], 0.5) end poly(pgon, :stroke) end 350 500 "polyegg"

Неравномерный внешний вид внутренних точек здесь выходит как результат настроек соединения линий по умолчанию. Поэкспериментируйте с setlinejoin("round"), чтобы увидеть, не изменит ли это геометрию. Ну а теперь попробуем offsetpoly() создающую многоугольный контур вне или внутри существующего многоугольника..
@png begin egg(80, :path) pgon = first(pathtopoly()) pc = polycentroid(pgon) for pt in 1:2:length(pgon) pgon[pt] = between(pc, pgon[pt], 0.9) end for i in 30:-3:-8 randomhue() op = offsetpoly(pgon, i) poly(op, :stroke, close=true) end end 350 500 "polyeggs"

Небольшие изменения в регулярности точек, создаваемых преобразованием пути в многоугольник, и разное количество выборок, которые оно делало, постоянно усиливаются в последовательных контурах.
Анимация
Для начала зададим функции реализующие фон и отрисовку яйца в зависимости от номера кадра:
using Colors demo = Movie(400, 400, "test") function backdrop(scene, framenumber) background("black") end function frame(scene, framenumber) setopacity(0.7) θ = framenumber * π/6 @layer begin rotate(θ) translate(100, 50) egg(50, :path) setline(10) randomhue() fillpreserve() randomhue() strokepath() end end
Анимация реализуется простым набором команд:
animate(demo, [ Scene(demo, backdrop, 0:12), Scene(demo, frame, 0:12, easingfunction=easeinoutcubic, optarg="made with Julia") ], framerate=10, tempdirectory="C:\\Users\\User\\Desktop\\mycop", creategif=true)
Что на самом деле вызывает наш ffmpeg
run(`ffmpeg -f image2 -i $(tempdirectory)/%10d.png -vf palettegen -y $(seq.stitle)-palette.png`) run(`ffmpeg -framerate 30 -f image2 -i $(tempdirectory)/%10d.png -i $(seq.stitle)-palette.png -lavfi paletteuse -y /tmp/$(seq.stitle).gif`)
То есть, создается серия изображений, а потом из этих фрэймов собирается гифка:

Пентахор
Он же пятиячейник — правильный четырехмерный симплекс. Чтобы рисовать и манипулировать на двумерных картинках 4-мерные объекты, для начала определим
struct Point4D <: AbstractArray{Float64, 1} x::Float64 y::Float64 z::Float64 w::Float64 end Point4D(a::Array{Float64, 1}) = Point4D(a...) Base.size(pt::Point4D) = (4, ) Base.getindex(pt::Point4D, i) = [pt.x, pt.y, pt.z, pt.w][i] struct Point3D <: AbstractArray{Float64, 1} x::Float64 y::Float64 z::Float64 end Base.size(pt::Point3D) = (3, )
Вместо того, чтобы определять множество операций вручную, мы можем задать нашу структуру как подтип AbstractArray (Подробней про классы как интерфейсы)
Основная задача, которую мы должны решить, — это как преобразовать 4D точку в 2D точку. Давайте начнем с более простой задачи: как преобразовать 3D-точку в 2D-точку, т.е. как мы можем нарисовать 3D-фигуру на плоской поверхности? Рассмотрим простой куб. Передняя и задняя поверхности могут иметь одинаковые координаты X и Y и изменяться только по своим значениям Z.
@png begin fontface("Menlo") fontsize(8) setblend(blend( boxtopcenter(BoundingBox()), boxmiddlecenter(BoundingBox()), "skyblue", "white")) box(boxtopleft(BoundingBox()), boxmiddleright(BoundingBox()), :fill) setblend(blend( boxmiddlecenter(BoundingBox()), boxbottomcenter(BoundingBox()), "grey95", "grey45" )) box(boxmiddleleft(BoundingBox()), boxbottomright(BoundingBox()), :fill) sethue("black") setline(2) bx1 = box(O, 250, 250, vertices=true) poly(bx1, :stroke, close=true) label.(["-1 1 1", "-1 -1 1", "1 -1 1", "1 1 1"], slope.(O, bx1), bx1) setline(1) bx2 = box(O, 150, 150, vertices=true) poly(bx2, :stroke, close=true) label.(["-1 1 0", "-1 -1 0", "1 -1 0", "1 1 0"], slope.(O, bx2), bx2, offset=-45) map((x, y) -> line(x, y, :stroke), bx1, bx2) end 400 400 "cube.png"

Поэтому идея состоит в том, чтобы спроецировать куб из 3D в 2D, сохранив первые ��ва значения и умножив или изменив их на третье значение. Проверим
const K = 4.0 function convert(Point, pt3::Point3D) k = 1/(K - pt3.z) return Point(pt3.x * k, pt3.y * k) end @png begin cube = Point3D[ Point3D(-1, -1, 1), Point3D(-1, 1, 1), Point3D( 1, -1, 1), Point3D( 1, 1, 1), Point3D(-1, -1, -1), Point3D(-1, 1, -1), Point3D( 1, -1, -1), Point3D( 1, 1, -1), ] circle.(convert.(Point, cube) * 300, 5, :fill) end 220 220 "points"

Используя тот же принцип, давайте создадим метод для преобразования 4D-точки и функцию, которая берет список четырехмерных точек и дважды отображает их в список двухмерных точек, подходящих для рисования.
function convert(Point3D, pt4::Point4D) k = 1/(K - pt4.w) return Point3D(pt4.x * k, pt4.y * k, pt4.z * k) end function flatten(shape4) return map(pt3 -> convert(Point, pt3), map(pt4 -> convert(Point3D, pt4), shape4)) end
Далее задаем вершины и грани и проверяем, как оно работает в цвете
const n = -1/√5 const pentachoron = [Point4D(vertex...) for vertex in [ [ 1.0, 1.0, 1.0, n], [ 1.0, -1.0, -1.0, n], [-1.0, 1.0, -1.0, n], [-1.0, -1.0, 1.0, n], [ 0.0, 0.0, 0.0, n + √5]]]; const pentachoronfaces = [ [1, 2, 3], [1, 2, 4], [1, 2, 5], [1, 3, 4], [1, 3, 5], [1, 4, 5], [2, 3, 4], [2, 3, 5], [2, 4, 5], [3, 4, 5]]; @png begin setopacity(0.2) pentachoron2D = flatten(pentachoron) for (n, face) in enumerate(pentachoronfaces) randomhue() poly(1500 * pentachoron2D[face], :fillpreserve, close=true) sethue("black") strokepath() end end 300 250 "5ceil"

Каждый уважающий себя разработчик игр должен знать Математические основы машинной графики. Если же вы никогда не пытались сжимать, вращать, отражать чайники в OpenGL — не пугайтесь, всё довольно просто. Чтоб отразить точку относительно прямой, или чтобы повернуть плоскость вокруг определенной оси, нужно домножить координаты на специальную матрицу. Собственно далее мы и определим нужные нам матрицы преобразований:
function XY(θ) [cos(θ) -sin(θ) 0 0; sin(θ) cos(θ) 0 0; 0 0 1 0; 0 0 0 1] end function XW(θ) [cos(θ) 0 0 -sin(θ); 0 1 0 0; 0 0 1 0; sin(θ) 0 0 cos(θ)] end function XZ(θ) [cos(θ) 0 -sin(θ) 0; 0 1 0 0; sin(θ) 0 cos(θ) 0; 0 0 0 1] end function YZ(θ) [1 0 0 0; 0 cos(θ) -sin(θ) 0; 0 sin(θ) cos(θ) 0; 0 0 0 1] end function YW(θ) [1 0 0 0; 0 cos(θ) 0 -sin(θ); 0 0 1 0; 0 sin(θ) 0 cos(θ)] end function ZW(θ) [1 0 0 0; 0 1 0 0; 0 0 cos(θ) -sin(θ); 0 0 sin(θ) cos(θ)]; end function rotate4(A, matrixfunction) return map(A) do pt4 Point4D(matrixfunction * pt4) end end
Обычно вы поворачиваете точки на плоскости относительно одномерного объекта. 3D-точки — вокруг 2D-линии (часто это одна из осей XYZ). Таким образом, логично что 4D точки поворачиваются относительно 3D-плоскости. Мы определили матрицы, которые выполняют четырехмерное вращение относительно плоскости, определяемой двумя осями X, Y, Z и W. Плоскость XY обычно является плоскостью поверхности рисования. Если вы воспринимаете плоскость XY как экран компьютера то, плоскость XZ параллельна вашему столу или полу, а плоскость YZ — это стены рядом с вашим столом справа или слева. А как же XW, YW и ZW? Это тайна четырехмерных фигур: мы не можем видеть эти плоскости, мы можем только представить их существование, наблюдая, как формы движутся сквозь них и вокруг них.
Теперь задаем функции для фрэймов и сшиваем анимацию:
using ColorSchemes function frame(scene, framenumber, scalefactor=1000) background("white") # antiquewhite setlinejoin("bevel") setline(1.0) sethue("black") eased_n = scene.easingfunction(framenumber, 0, 1, scene.framerange.stop) pentachoron′ = rotate4(pentachoron, XZ(eased_n * 2π)) pentachoron2D = flatten(pentachoron′) setopacity(0.2) for (n, face) in enumerate(pentachoronfaces) sethue(get(ColorSchemes.diverging_rainbow_bgymr_45_85_c67_n256, n/length(pentachoronfaces))) poly(scalefactor * pentachoron2D[face], :fillpreserve, close=true) sethue("black") strokepath() end end function makemovie(w, h, fname; scalefactor=1000) movie1 = Movie(w, h, "4D movie") animate(movie1, Scene(movie1, (s, f) -> frame(s, f, scalefactor), 1:300, easingfunction=easeinoutsine), #framerate=10, tempdirectory="C:\\Users\\User\\Desktop\\mycop", creategif=true, pathname="C:\\Users\\User\\Desktop\\mycop\\$(fname)") end makemovie(320, 320, "pentachoron-xz.gif", scalefactor=2000)

Ну, и еще ракурс:
function frame(scene, framenumber, scalefactor=1000) background("antiquewhite") setlinejoin("bevel") setline(1.0) setopacity(0.2) eased_n = scene.easingfunction(framenumber, 0, 1, scene.framerange.stop) pentachoron2D = flatten( rotate4( pentachoron, XZ(eased_n * 2π) * YW(eased_n * 2π))) for (n, face) in enumerate(pentachoronfaces) sethue(get(ColorSchemes.diverging_rainbow_bgymr_45_85_c67_n256, n/length(pentachoronfaces))) poly(scalefactor * pentachoron2D[face], :fillpreserve, close=true) sethue("black") strokepath() end end makemovie(500, 500, "pentachoron-xz-yw.gif", scalefactor=2000)

Совершено естественно желание реализовать более популярный четырехмерный объект — Тессеракт
const tesseract = [Point4D(vertex...) for vertex in [ [-1, -1, -1, 1], [ 1, -1, -1, 1], [ 1, 1, -1, 1], [-1, 1, -1, 1], [-1, -1, 1, 1], [ 1, -1, 1, 1], [ 1, 1, 1, 1], [-1, 1, 1, 1], [-1, -1, -1, -1], [ 1, -1, -1, -1], [ 1, 1, -1, -1], [-1, 1, -1, -1], [-1, -1, 1, -1], [ 1, -1, 1, -1], [ 1, 1, 1, -1], [-1, 1, 1, -1]]] const tesseractfaces = [ [1, 2, 3, 4], [1, 2, 10, 9], [1, 4, 8, 5], [1, 5, 6, 2], [1, 9, 12, 4], [2, 3, 11, 10], [2, 3, 7, 6], [3, 4, 8, 7], [5, 6, 14, 13], [5, 6, 7, 8], [5, 8, 16, 13], [6, 7, 15, 14], [7, 8, 16, 15], [9, 10, 11, 12], [9, 10, 14, 13], [9, 13, 16, 12], [10, 11, 15, 14], [13, 14, 15, 16]];
function frame(scene, framenumber, scalefactor=1000) background("black") setlinejoin("bevel") setline(10.0) setopacity(0.7) eased_n = scene.easingfunction(framenumber, 0, 1, scene.framerange.stop) tesseract2D = flatten( rotate4( tesseract, XZ(eased_n * 2π) * YW(eased_n * 2π))) for (n, face) in enumerate(tesseractfaces) sethue([Luxor.lighter_blue, Luxor.lighter_green, Luxor.lighter_purple, Luxor.lighter_red][mod1(n, 4)]...) poly(scalefactor * tesseract2D[face], :fillpreserve, close=true) sethue([Luxor.darker_blue, Luxor.darker_green, Luxor.darker_purple, Luxor.darker_red][mod1(n, 4)]...) strokepath() end end makemovie(500, 500, "tesseract-xz-yw.gif", scalefactor=1000)

Домашнее задание: автоматизируйте создание массивов координат и номе��ов вершин (перестановки с повторениями и без повторений соответственно). Также мы использовали не все транслирующие матрицы; каждый новый ракурс вызывает новый "Ух-тыж!", но я решил не перегружать страницу. Ну и можно поэкспериментировать с большим количеством граней и измерений.
Ссылки
- Luxor — страница на гитхабе
- Luxor docs — руководство с примерами
- Cairo — низкоуровневая графическая библиотека; используется Люксором как окружение
- Блог автора библиотеки — там много всякой крутотени и более расширенных примеров, включая четырехмерные фигуры.

