using Luxor
using Colors
using LinearAlgebra
Recreating the Makie logo with Luxor.jl
This is the logo of Makie.jl:
I designed it by hand in a vector graphics editor a couple years ago, however, I always wanted to have a programmatic version of it.
First of all, because with a program it’s easier to make variations of it or play with it, for example to make animations. The other reason was that the original vector graphics file always seemed a bit large for what it was, at 118KB. The gradient mesh from the editor is flattened to a relatively large inline image for SVG, because SVG doesn’t support meshes. I wanted to have a programmatic version where I could make this image as small as possible while still looking good.
I decided to make it with Luxor.jl because it’s a relatively thin wrapper around Cairo and nicely documented.
The basic structure of the logo is a simple cube consisting of three diamonds because Makie is a 3D visualization package.
@drawsvg begin
scale(150, -150)
for i in 1:3
= Point(1, 0)
p1 = Point(0, 0)
p3 = Point(0.5, sqrt(3) / 2)
p2 = Point(0.5, -sqrt(3) / 2)
p4
= rotatepoint.((p1, p2, p3, p4), i * 2pi / 3 + 2pi / 12)
(p1, p2, p3, p4)
move(p1)
line(p2)
line(p3)
line(p4)
closepath()
strokepath()
end
end 400 400
Next, we move each diamond outwards, which gives the cube an “exploded” look.
@drawsvg begin
= 0.045 / cosd(30)
inner_gap
scale(150, -150)
for i in 1:3
= Point(1, 0)
p1 = Point(0, 0)
p3 = Point(0.5, sqrt(3) / 2)
p2 = Point(0.5, -sqrt(3) / 2)
p4
= rotatepoint.(Point(inner_gap, 0) .+ (p1, p2, p3, p4), i * 2pi / 3 + 2pi / 12)
(p1, p2, p3, p4)
move(p1)
line(p2)
line(p3)
line(p4)
closepath()
strokepath()
end
end 400 400
Now we have to round the corners of the diamonds to make them more petal-like (the petals are a reference to the floral patterns sometimes seen with the Maki-e painting technique).
To get rounded corners, we need a function which takes in three points that form the sharp corner plus a radius, and calculates where the circular arc with that radius is connected to the adjacent line segments.
function rounded_corner(p1, p2, p3, radius)
= p2 - p1
d1 = normalize(Point(-d1.y, d1.x))
d1_ortho = p3 - p2
d2 = normalize(Point(-d2.y, d2.x))
d2_ortho
= Luxor.intersectionlines(
_, circle_center + radius * d1_ortho,
p1 + radius * d1_ortho,
p2 + radius * d2_ortho,
p2 + radius * d2_ortho,
p3
)
circle_center= circle_center - radius * d1_ortho
start = circle_center - radius * d2_ortho
stop
circle_center, start, stoparc2r(circle_center, start, stop)
end
rounded_corner (generic function with 1 method)
Here’s an example of such a rounded corner, the dotted lines show the original sharp corner.
@drawsvg begin
scale(150, -150)
= Point(-0.3, -0.6)
p1 = Point(0.5, 0.8)
p2 = Point(-0.5, 0.3)
p3 = 0.1
r
move(p1)
rounded_corner(p1, p2, p3, r)
line(p3)
sethue("black")
setopacity(0.5)
strokepath()
setdash("dot")
move(p1)
line(p2)
strokepath()
move(p2)
line(p3)
strokepath()
sethue("red")
circle(p1, 0.03, :fill)
circle(p2, 0.03, :fill)
circle(p3, 0.03, :fill)
end 400 400
Now, we can apply different corner radii to the diamonds and turn them into petals.
@drawsvg begin
= 0.045 / cosd(30)
inner_gap
scale(150, -150)
for i in 1:3
= Point(1, 0)
p1 = Point(0, 0)
p3 = Point(0.5, sqrt(3) / 2)
p2 = Point(0.5, -sqrt(3) / 2)
p4
= rotatepoint.(Point(inner_gap, 0) .+ (p1, p2, p3, p4), i * 2pi / 3 + 2pi / 12)
(p1, p2, p3, p4)
move(p1)
rounded_corner(p1, p2, p3, 0.17)
rounded_corner(p2, p3, p4, 0.06)
rounded_corner(p3, p4, p1, 0.17)
closepath()
strokepath()
end
end 400 400
There are three negative-space circles cut out of the petals. They resemble scatter plot markers and are a nod to the three circle logo of the Julia language.
We can first visualize their location by drawing them on top of what we already have.
@drawsvg begin
= 0.045 / cosd(30)
inner_gap
scale(150, -150)
= []
cornerpoints
= [
cs Point(0, 0.45),
rotatepoint(Point(0, 0.45), 2pi / 3),
rotatepoint(Point(0, 0.45), 2 * 2pi / 3),
]= [0.15, 0.235, 0.195]
rs
for i in 1:3
= Point(1, 0)
p1 = Point(0, 0)
p3 = Point(0.5, sqrt(3) / 2)
p2 = Point(0.5, -sqrt(3) / 2)
p4
= rotatepoint.(Point(inner_gap, 0) .+ (p1, p2, p3, p4), i * 2pi / 3 + 2pi / 12)
(p1, p2, p3, p4)
move(p1)
rounded_corner(p1, p2, p3, 0.17)
rounded_corner(p2, p3, p4, 0.06)
rounded_corner(p3, p4, p1, 0.17)
closepath()
strokepath()
circle(cs[i], rs[i], :stroke)
end
end 400 400
We can now intersect each petal with its two adjacent circles and draw the corresponding circular arcs. With that, we are done with the shape of the logo.
@drawsvg begin
= 0.045 / cosd(30)
inner_gap
scale(150, -150)
= [
cs Point(0, 0.45),
rotatepoint(Point(0, 0.45), 2pi / 3),
rotatepoint(Point(0, 0.45), 2 * 2pi / 3),
]= [0.15, 0.235, 0.195]
rs
for i in 1:3
= Point(1, 0)
p1 = Point(0, 0)
p3 = Point(0.5, sqrt(3) / 2)
p2 = Point(0.5, -sqrt(3) / 2)
p4
= rotatepoint.(Point(inner_gap, 0) .+ (p1, p2, p3, p4), i * 2pi / 3 + 2pi / 12)
(p1, p2, p3, p4)
= cs[mod1(i + 1, 3)]
c1 = cs[mod1(i + 0, 3)]
c2
= intersectionlinecircle(p2, p3, c1, rs[mod1(i + 1, 3)])
n, ip1, ip2 if n != 2
error()
end
= intersectionlinecircle(p3, p4, c2, rs[mod1(i + 0, 3)])
n, ip3, ip4 if n != 2
error()
end
move(p1)
rounded_corner(p1, p2, p3, 0.17)
line(ip2)
carc2r(c1, ip2, ip1)
rounded_corner(p2, p3, p4, 0.06)
line(ip4)
carc2r(c2, ip4, ip3)
rounded_corner(p3, p4, p1, 0.17)
closepath()
strokepath()
end
end 400 400
The coloring actually needed a bit more thought, because in the original this was done in a messy, freehand way with a four-cornered mesh gradient, two corners of which I overlaid to simulate a triangular shape. First I didn’t have an idea how to transform the three Makie colors into a similar gradient programmatically, linear and radial gradients which are inbuilt into SVG do not work.
= colorant"#e8cb26"
makieyellow = colorant"#3182bb"
makieblue = colorant"#dd3366"
makiered
[makieyellow, makieblue, makiered]
Then I realized that there’s a pretty obvious way to compute the mixture of the colors, just use the same math that shaders use to combine vertex colors of triangles in a mesh, which is what Makie itself does. This is called barycentric interpolation.
This function computes barycentric weights for three vertices given some point p
:
function bary_weights(p, v1, v2, v3)
= ((v2[2] - v3[2]) * (v1[1] - v3[1]) + (v3[1] - v2[1]) * (v1[2] - v3[2]))
den = ((v2[2] - v3[2]) * (p[1] - v3[1]) + (v3[1] - v2[1]) * (p[2] - v3[2])) / den
w1 = ((v3[2] - v1[2]) * (p[1] - v3[1]) + (v1[1] - v3[1]) * (p[2] - v3[2])) / den
w2 = 1 - w1 - w2
w3
(w1, w2, w3)end
bary_weights (generic function with 1 method)
We also need some function to mix three rgb colors together, I only found weighted_color_mean
in Colors.jl which could only handle two colors, so I wrote some separate function which I don’t remember why it ended up looking this complex.
Code
_tuple(l::Lab) = (l.l, l.a, l.b)
_tuple(r::RGB) = (r.r, r.g, r.b)
_tuple(l::LCHuv) = (l.l, l.c, l.h)
# could reduce weighted_color_mean with 1/i
function mix(cfs...)
= typeof(first(first(cfs)))
T isempty(cfs) && return T(RGBf(1, 1, 1))
if any(cf -> cf[2] == 0, cfs)
mix(filter(cf -> cf[2] != 0, cfs)...)
else
if length(cfs) == 1
return cfs[1][1]
else
= map(cfs) do (c, f)
scaled .* _tuple(c)
f end
= foldl((a, b) -> a .+ b, scaled)
_sum = _sum ./ sum(last.(cfs))
_sum_scaled return T(_sum_scaled...)
end
end
end
mix (generic function with 1 method)
So with that, we can give it a first try, we determine the bounding box of the outline and compute barycentrically weighted mixtures of the three Makie colors with vertices placed at the petal corners.
Below, I just clip that grid to the petal corner triangle so it is easier to see the barycentric mixture.
@drawsvg begin
= 0.045 / cosd(30)
inner_gap
scale(150, -150)
= []
cornerpoints
= [
cs Point(0, 0.45),
rotatepoint(Point(0, 0.45), 2pi / 3),
rotatepoint(Point(0, 0.45), 2 * 2pi / 3),
]= [0.15, 0.235, 0.195]
rs
for i in 1:3
= Point(1, 0)
p1 = Point(0, 0)
p3 = Point(0.5, sqrt(3) / 2)
p2 = Point(0.5, -sqrt(3) / 2)
p4
= rotatepoint.(Point(inner_gap, 0) .+ (p1, p2, p3, p4), i * 2pi / 3 + 2pi / 12)
(p1, p2, p3, p4)
push!(cornerpoints, p1)
end
= range(-1, 1, length=10)
xrange = range(-1.2, 0.8, length=10)
yrange
= broadcast(xrange, yrange') do i, j
pixels = Point(i, j)
p = clamp.(bary_weights(p, cornerpoints...), 0, 1)
f_yellow, f_blue, f_red mix(((makieyellow), f_yellow), ((makieblue), f_blue), ((makiered), f_red))
end
move(cornerpoints[1])
line.(cornerpoints[2:3])
closepath()
clip()
@layer begin
translate(first(xrange), first(yrange))
scale(1 / length(xrange) * (last(xrange) - first(xrange)), 1 / length(yrange) * (last(yrange) - first(yrange)))
= 0.5 * (first(xrange) + last(xrange))
midx = 0.5 * (first(yrange) + last(yrange))
midy placeimage(pixels', O, centered=false)
end
end 400 400
If you compare that gradient to the original logo:
You notice that three Makie colors take more space there, each petal is mostly one color but fades into the neighboring petal at the edges.
I could solve this by exponentiating the barycentric weights. I experimented with different numbers and arrived at 2.1 as a pretty good fit.
Code
@drawsvg begin
= 0.045 / cosd(30)
inner_gap
scale(150, -150)
= []
cornerpoints
= [
cs Point(0, 0.45),
rotatepoint(Point(0, 0.45), 2pi / 3),
rotatepoint(Point(0, 0.45), 2 * 2pi / 3),
]= [0.15, 0.235, 0.195]
rs
for i in 1:3
= Point(1, 0)
p1 = Point(0, 0)
p3 = Point(0.5, sqrt(3) / 2)
p2 = Point(0.5, -sqrt(3) / 2)
p4
= rotatepoint.(Point(inner_gap, 0) .+ (p1, p2, p3, p4), i * 2pi / 3 + 2pi / 12)
(p1, p2, p3, p4)
push!(cornerpoints, p1)
end
= range(-1, 1, length=10)
xrange = range(-1.2, 0.8, length=10)
yrange
= broadcast(xrange, yrange') do i, j
pixels = Point(i, j)
p = clamp.(bary_weights(p, cornerpoints...), 0, 1) .^ 2.1
f_yellow, f_blue, f_red mix(((makieyellow), f_yellow), ((makieblue), f_blue), ((makiered), f_red))
end
move(cornerpoints[1])
line.(cornerpoints[2:3])
closepath()
clip()
@layer begin
translate(first(xrange), first(yrange))
scale(1 / length(xrange) * (last(xrange) - first(xrange)), 1 / length(yrange) * (last(yrange) - first(yrange)))
= 0.5 * (first(xrange) + last(xrange))
midx = 0.5 * (first(yrange) + last(yrange))
midy placeimage(pixels', O, centered=false)
end
end 400 400
And this is how that triangle looks overlaid on the logo outline:
Code
@drawsvg begin
= 0.045 / cosd(30)
inner_gap
scale(150, -150)
= []
cornerpoints
= [
cs Point(0, 0.45),
rotatepoint(Point(0, 0.45), 2pi / 3),
rotatepoint(Point(0, 0.45), 2 * 2pi / 3),
]= [0.15, 0.235, 0.195]
rs
for i in 1:3
= Point(1, 0)
p1 = Point(0, 0)
p3 = Point(0.5, sqrt(3) / 2)
p2 = Point(0.5, -sqrt(3) / 2)
p4
= rotatepoint.(Point(inner_gap, 0) .+ (p1, p2, p3, p4), i * 2pi / 3 + 2pi / 12)
(p1, p2, p3, p4)
= cs[mod1(i + 1, 3)]
c1 = cs[mod1(i + 0, 3)]
c2
= intersectionlinecircle(p2, p3, c1, rs[mod1(i + 1, 3)])
n, ip1, ip2 if n != 2
error()
end
= intersectionlinecircle(p3, p4, c2, rs[mod1(i + 0, 3)])
n, ip3, ip4 if n != 2
error()
end
push!(cornerpoints, p1)
move(p1)
rounded_corner(p1, p2, p3, 0.17)
line(ip2)
carc2r(c1, ip2, ip1)
rounded_corner(p2, p3, p4, 0.06)
line(ip4)
carc2r(c2, ip4, ip3)
rounded_corner(p3, p4, p1, 0.17)
closepath()
end
= pathtopoly()
path
strokepath()
move(cornerpoints[1])
line.(cornerpoints[2:3])
closepath()
clip()
= range(extrema(x -> x.x, Iterators.flatten(path))..., length=10)
xrange = range(extrema(x -> x.y, Iterators.flatten(path))..., length=10)
yrange
= broadcast(xrange, yrange') do i, j
pixels = Point(i, j)
p = clamp.(bary_weights(p, cornerpoints...), 0, 1) .^ 2.1
f_yellow, f_blue, f_red mix(((makieyellow), f_yellow), ((makieblue), f_blue), ((makiered), f_red))
end
translate(first(xrange), first(yrange))
scale(1 / length(xrange) * (last(xrange) - first(xrange)), 1 / length(yrange) * (last(yrange) - first(yrange)))
= 0.5 * (first(xrange) + last(xrange))
midx = 0.5 * (first(yrange) + last(yrange))
midy placeimage(pixels', O, centered=false)
end 400 400
To arrive at the final result, I simply remove the triangle and switch the logo shape from a stroked outline to a clipping mask for the full gradient mesh. Outside of the triangle negative barycentric weights are simply clipped to zero.
@drawsvg begin
= 0.045 / cosd(30)
inner_gap
scale(150, -150)
= []
cornerpoints
= [
cs Point(0, 0.45),
rotatepoint(Point(0, 0.45), 2pi / 3),
rotatepoint(Point(0, 0.45), 2 * 2pi / 3),
]= [0.15, 0.235, 0.195]
rs
for i in 1:3
= Point(1, 0)
p1 = Point(0, 0)
p3 = Point(0.5, sqrt(3) / 2)
p2 = Point(0.5, -sqrt(3) / 2)
p4
= rotatepoint.(Point(inner_gap, 0) .+ (p1, p2, p3, p4), i * 2pi / 3 + 2pi / 12)
(p1, p2, p3, p4)
= cs[mod1(i + 1, 3)]
c1 = cs[mod1(i + 0, 3)]
c2
= intersectionlinecircle(p2, p3, c1, rs[mod1(i + 1, 3)])
n, ip1, ip2 if n != 2
error()
end
= intersectionlinecircle(p3, p4, c2, rs[mod1(i + 0, 3)])
n, ip3, ip4 if n != 2
error()
end
push!(cornerpoints, p1)
move(p1)
rounded_corner(p1, p2, p3, 0.17)
line(ip2)
carc2r(c1, ip2, ip1)
rounded_corner(p2, p3, p4, 0.06)
line(ip4)
carc2r(c2, ip4, ip3)
rounded_corner(p3, p4, p1, 0.17)
closepath()
end
= pathtopoly()
path
clip()
= range(extrema(x -> x.x, Iterators.flatten(path))..., length=10)
xrange = range(extrema(x -> x.y, Iterators.flatten(path))..., length=10)
yrange
= broadcast(xrange, yrange') do i, j
pixels = Point(i, j)
p = clamp.(bary_weights(p, cornerpoints...), 0, 1) .^ 2.1
f_yellow, f_blue, f_red mix(((makieyellow), f_yellow), ((makieblue), f_blue), ((makiered), f_red))
end
translate(first(xrange), first(yrange))
scale(1 / length(xrange) * (last(xrange) - first(xrange)), 1 / length(yrange) * (last(yrange) - first(yrange)))
= 0.5 * (first(xrange) + last(xrange))
midx = 0.5 * (first(yrange) + last(yrange))
midy placeimage(pixels', O, centered=false)
end 400 400
And that’s it! We can compare once more to the original:
I think that’s a pretty good match, and the size of the new version is just 3KB with a 10x10 pixel gradient.
Finally, I have to do one animation, just because I can do it now!
Code
function frame(scene, framenumber)
background("white")
= 0.045 / cosd(30)
inner_gap
scale(250, -250)
= []
cornerpoints
= [
cs Point(0, 0.45),
rotatepoint(Point(0, 0.45), 2pi / 3),
rotatepoint(Point(0, 0.45), 2 * 2pi / 3),
]= [0.15, 0.235, 0.195]
rs
for i in 1:3
= Point(1, 0)
p1 = Point(0, 0)
p3 = Point(0.5, sqrt(3) / 2)
p2 = Point(0.5, -sqrt(3) / 2)
p4
= rotatepoint.(Point(inner_gap, 0) .+ (p1, p2, p3, p4), i * 2pi / 3 + 2pi / 12)
(p1, p2, p3, p4)
= cs[mod1(i + 1, 3)]
c1 = cs[mod1(i + 0, 3)]
c2
= intersectionlinecircle(p2, p3, c1, rs[mod1(i + 1, 3)])
n, ip1, ip2 if n != 2
error()
end
= intersectionlinecircle(p3, p4, c2, rs[mod1(i + 0, 3)])
n, ip3, ip4 if n != 2
error()
end
push!(cornerpoints, rotatepoint(p1, (framenumber - 1) / 99 * 2pi))
move(p1)
rounded_corner(p1, p2, p3, 0.17)
line(ip2)
carc2r(c1, ip2, ip1)
rounded_corner(p2, p3, p4, 0.06)
line(ip4)
carc2r(c2, ip4, ip3)
rounded_corner(p3, p4, p1, 0.17)
closepath()
end
= pathtopoly()
path
clip()
= range(extrema(x -> x.x, Iterators.flatten(path))..., length=50)
xrange = range(extrema(x -> x.y, Iterators.flatten(path))..., length=50)
yrange
= broadcast(xrange, yrange') do i, j
pixels = Point(i, j)
p = clamp.(bary_weights(p, cornerpoints...), 0, 1) .^ 2.1
f_yellow, f_blue, f_red mix(((makieyellow), f_yellow), ((makieblue), f_blue), ((makiered), f_red))
end
translate(first(xrange), first(yrange))
scale(1 / length(xrange) * (last(xrange) - first(xrange)), 1 / length(yrange) * (last(yrange) - first(yrange)))
= 0.5 * (first(xrange) + last(xrange))
midx = 0.5 * (first(yrange) + last(yrange))
midy placeimage(pixels', O, centered=false)
end
= Movie(600, 600, "makielogo")
movie
mktempdir() do dir
animate(movie, [Scene(movie, frame, 0:100)]; tempdirectory = dir)
run(`ffmpeg -i $(dir)/%10d.png -y -pix_fmt yuv420p -c:v libx264 -movflags +faststart -filter:v crop='floor(in_w/2)*2:floor(in_h/2)*2' makie.mp4`)
end