# This notebook is a semi top-down explanation. This cell needs to be
# executed first so that the operators and helper functions are defined
# All of this is explained in the later half of the notebook
using Compose, Interact
Compose.set_default_graphic_size(2inch, 2inch)
points_f = [
(.1, .1),
(.9, .1),
(.9, .2),
(.2, .2),
(.2, .4),
(.6, .4),
(.6, .5),
(.2, .5),
(.2, .9),
(.1, .9),
(.1, .1)
]
f = compose(context(), stroke("black"), line(points_f))
rot(pic) = compose(context(rotation=Rotation(-deg2rad(90))), pic)
flip(pic) = compose(context(mirror=Mirror(deg2rad(90), 0.5w, 0.5h)), pic)
above(m, n, p, q) =
compose(context(),
(context(0, 0, 1, m/(m+n)), p),
(context(0, m/(m+n), 1, n/(m+n)), q))
above(p, q) = above(1, 1, p, q)
beside(m, n, p, q) =
compose(context(),
(context(0, 0, m/(m+n), 1), p),
(context(m/(m+n), 0, n/(m+n), 1), q))
beside(p, q) = beside(1, 1, p, q)
over(p, q) = compose(context(),
(context(), p), (context(), q))
rot45(pic) =
compose(context(0, 0, 1/sqrt(2), 1/sqrt(2),
rotation=Rotation(-deg2rad(45), 0w, 0h)), pic)
# Utility function to zoom out and look at the context
zoomout(pic) = compose(context(),
(context(0.2, 0.2, 0.6, 0.6), pic),
(context(0.2, 0.2, 0.6, 0.6), fill(nothing), stroke("black"), strokedash([0.5mm, 0.5mm]),
polygon([(0, 0), (1, 0), (1, 1), (0, 1)])))
function read_path(p_str)
tokens = [try parsefloat(x) catch symbol(x) end for x in split(p_str, r"[\s,]+")]
path(tokens)
end
fish = compose(context(units=UnitBox(260, 260)), stroke("black"),
read_path(strip(readall("fish.path"))))
rotatable(pic) = @manipulate for θ=0:0.001:2π
compose(context(rotation=Rotation(θ)), pic)
end
blank = compose(context())
fliprot45(pic) = rot45(compose(context(mirror=Mirror(deg2rad(-45))),pic))
# Hide this cell.
display(MIME("text/html"), """<script>
var cell = \$(".container .cell").eq(0), ia = cell.find(".input_area")
if (cell.find(".toggle-button").length == 0) {
ia.after(
\$('<button class="toggle-button">Toggle hidden code</button>').click(
function (){ ia.toggle() }
)
)
ia.hide()
}
</script>""")
Functional Geometry is a paper by Peter Henderson (original (1982), revisited (2002)) which deconstructs the MC Escher woodcut Square Limit
A picture is an example of a complex object that can be described in terms of its parts. Yet a picture needs to be rendered on a printer or a screen by a device that expects to be given a sequence of commands. Programming that sequence of commands directly is much harder than having an application generate the commands automatically from the simpler, denotational description.
A picture
is a denotation of something to draw.
e.g. The value of f here denotes the picture of the letter F
f
We begin specifying the algebra of pictures we will use to describe Square Limit with a few operations that operate on pictures to give other pictures, namely:
rot : picture → picture
flip : picture → picture
rot45 : picture → picture
above : picture × picture → picture
above : int × int × picture × picture → picture
beside : picture × picture → picture
beside : int × int × picture × picture → picture
over : picture → picture
rot : picture → picture
Rotate a picture anti-clockwise by 90°
rot(f)
flip : picture → picture
Flip a picture along its virtical center axis
flip(f)
rot(flip(f))
rotate the picture anti-clockwise by 45°, then flip it across the new virtical axis. In the paper this is implemented as $flip(rot45(fish))$. This function is rather specific to the problem at hand.
fliprot45(fish) |> zoomout # zoomout shows the bounding box
above : picture × picture → picture
place a picture above another.
above(f, f)
above : int × int × picture × picture → picture
given m
, n
, picture1
and picture2
, return a picture where picture1
is placed above picture2
such that their heights occupy the total height in m:n ratio
above(1, 2, f, f)
beside : picture × picture → picture
Similar to above
but in the left-to-right direction.
beside(f, f)
beside : int × int × picture × picture → picture
beside(1, 2, f, f)
above(beside(f, f), f)
over : picture → picture
place a picture upon another
over(f, flip(f))
We will now study some of the properties of the fish.
fish |> zoomout
rotatable(fish |> zoomout)
over(fish, rot(rot(fish))) |> zoomout
There is a certain kind of arrangement that is used to tile parts of the image. We call it t
fish2 = fliprot45(fish)
fish3 = rot(rot(rot(fish2)))
t = over(fish, over(fish2, fish3))
t |> zoomout
There is another kind of arrangement of fish which lies at the very center and regions on the diagonals. We will call it u
.
u = over(over(fish2, rot(fish2)),
over(rot(rot(fish2)), rot(rot(rot(fish2)))))
u |> zoomout
quartet
tiles 4 images in a 2x2 grid
quartet(p, q, r, s) =
above(beside(p, q), beside(r, s))
quartet(f,flip(f),rot(f),f)
# 2inch x 2inch canvas is no more sufficient, so let's blow it up a bit
Compose.set_default_graphic_size(5inch, 5inch)
quartet(u, u, u, u) |> zoomout
Notice how the fish interlock without leaving out any space in between them. Escher FTW.
cycle
is a quartet of the same picture with each successive tile rotated by 90° anti-clockwise
cycle(p) =
quartet(p, rot(p), rot(rot(p)), rot(rot(rot(p))))
cycle(f)
A nonet is a grid of 9 pictures.
nonet(p, q, r,
s, t, u,
v, w, x) =
above(1,2,beside(1,2,p,beside(1,1,q,r)),
above(1,1,beside(1,2,s,beside(1,1,t,u)),
beside(1,2,v,beside(1,1,w,x))))
nonet(f, f, f, f, f, f, f, f, f)
Note: blank
denotes a blank picture
There is a certain pattern which makes up the mid region of each of the four edges of the image. We will call this arrangement side
the 1 in side1
represents 1 level of recursion. This is the simplest side.
side1 = quartet(blank, blank, rot(t), t)
side1 |> zoomout
A side that is 2 levels deep.
side2 = quartet(side1,side1,rot(t),t)
side2 |> zoomout
A side that is n level deep.
side(n) =
if n == 1 side1 # basis
else quartet(side(n-1),side(n-1),rot(t),t) # induction
end
side(3) |> zoomout
# @manipulate lets us watch what happens as the levels increase
@manipulate for level=slider(1:4, value=1)
side(level) |> zoomout
end
Similarly, there is a certain kind of arrangement which makes up the corners of the artwork.
A corner
level 1 deep is simply
corner1 = quartet(blank,blank,blank,u)
corner1 |> zoomout
A corner 2 levels deep, it is built using corner1, side1 and u.
corner2 = quartet(corner1,side1,rot(side1),u)
corner2 |> zoomout
An n level deep corner.
corner(n) =
n == 1 ? corner1 :
quartet(corner(n-1), side(n-1), rot(side(n-1)), u)
corner(3) |> zoomout
# Touring the corners with a slider:
@manipulate for level=slider(1:4, value=1)
corner(level) |> zoomout
end
We are almost there.
Square limit is a nonet of various rotations of corner
at the corners, side
at the sides and u
in the center.
The following equation puts this precisely:
squarelimit(n) =
nonet(corner(n), side(n), rot(rot(rot(corner(n)))),
rot(side(n)), u, rot(rot(rot(side(n)))),
rot(corner(n)), rot(rot(side(n))), rot(rot(corner(n))))
# We render a level-3 square limit on a 10inch x 10inch SVG for maximum awesome.
draw(SVG(10inch, 10inch), squarelimit(3))
We will now implement the basic operators rot
, flip
, fliprot45
, above
, below
and over
with Compose.jl. (this explanation is taken mostly from the Compose website)
Compose graphics are created as a tree data structure. There are 3 types of objects that make up the nodes of the tree:
Context
: An internal node, defines the transformation matrix to be applied for its childrenForm
: A leaf node that defines some geometry, like a line or a polygonProperty
: A leaf node that modifies how its parent's subtree is drawn, like fill color, font family, or line width.The all-important function in Compose, is called, not surprisingly, compose
. Calling compose(a, b)
will return a new tree rooted at a
and with b
attached as a child.
That's enough to start drawing some simple shapes.
Compose.set_default_graphic_size(2inch, 2inch) # switch back to smaller output
compose(context(), rectangle(), fill("tomato"))
Furthermore, more complex trees can be formed by grouping subtrees with parenthesis.
tomato_bisque =
compose(context(),
(context(), circle(), fill("bisque")),
(context(), rectangle(), fill("tomato")))
We can introspect this object with the introspect
function. It returns a tree depiction of the picture
introspect(tomato_bisque)
The circles represent contexts, the triangles represent properties (fill color in our case) and the square represents a Form (one is a circle another is a rectangle)
When a picture needs to be drawn, Compose walks the tree, setting up the correct transformation matrices and resolving the forms into absolute coordinate system depending on the size of the canvas being drawn on.
These trees can be really big, but that's OK. Compose will draw them just fine. Let's introspect the tile .
introspect(u)
Let us now describe the picture of the letter F.
Notice that we use relative coordinates, where the width of the image is 1 width unit, and the height is 1 height unit
points_f = [
(.1, .1),
(.9, .1),
(.9, .2),
(.2, .2),
(.2, .4),
(.6, .4),
(.6, .5),
(.2, .5),
(.2, .9),
(.1, .9),
(.1, .1)
]
# line function takes a list of points and returns a Line form
f = compose(context(), stroke("black"), line(points_f))
Rotating a picture involves wrapping in a context with a rotation.
# rotate anti-clockwise by 90deg
rot(pic) =
compose(context(rotation=Rotation(-Ï€/2)), pic)
rot(f)
Flipping an image about the vertical center axis involves mirroring it across the axis. The axis is defined with a point on the axis and the slope of the axis in radians.
flip(pic) =
compose(context(mirror=Mirror(deg2rad(90), 0.5, 0.5)), pic)
flip(f)
rot45(pic) =
compose(context(0, 0, 1/sqrt(2), 1/sqrt(2),
rotation=Rotation(-Ï€/4, 0, 0)), pic)
rot45(f)
fliprot45
involves wrapping the picture in a context which is mirrored about an axis at 45° (anti-clockwise), and then applying rot45
to it to rotate and scale appropriately.
fliprot45(pic) =
rot45(compose(context(mirror=Mirror(deg2rad(-45))),
pic))
fliprot45(f) |> zoomout
to place a picture above another, we create two child contexts which have their heights in the ratio m:n,
context(x0, y0, w, h)
sets up a context beginning at the point (x0, y0), with width w, and height h.
above(m, n, p, q) =
compose(context(),
(context(0, 0, 1, m/(m+n)), p),
(context(0, m/(m+n), 1, n/(m+n)), q))
above(p, q)
is a specific case of above
where m:n = 1:1
above(p, q) = above(1, 1, p, q)
above(f, f)
beside
is similar but now we divide the width instead of the height in the m:n ratio.
beside(m, n, p, q) =
compose(context(),
(context(0, 0, m/(m+n), 1), p),
(context(m/(m+n), 0, n/(m+n), 1), q))
beside(p, q) = beside(1, 1, p, q)
beside(f, f)
To overlay an image, we simply create two child contexts (contexts fill up the parent context by default)
over(p, q) = compose(context(),
(context(), p), (context(), q))
over(f, flip(f))
The fish is stored as an SVG path in the fish.path
file. We read it, tokenize it and create a path Form
from it.
function read_path(p_str)
tokens = [try parsefloat(x) catch symbol(x) end for x in split(p_str, r"[\s,]+")]
path(tokens)
end
# The fish was drawn on a canvas of 260x260 units, we will specify this
# by wrapping the path in a Unit box of this dimension
fish = compose(context(units=UnitBox(260, 260)), stroke("black"),
read_path(strip(readall("fish.path"))))
The zoomout function draws its argument in a smaller context in the middle of the canvas, and marks the extents of the smaller context with a dotted rectangle.
# Utility function to zoom out and look at the context
zoomout(pic) = compose(context(),
(context(0.2, 0.2, 0.6, 0.6), pic),
(context(0.2, 0.2, 0.6, 0.6), fill(nothing), stroke("black"), strokedash([0.5mm, 0.5mm]),
polygon([(0, 0), (1, 0), (1, 1), (0, 1)])))
zoomout(fish)
rotatable(pic) = @manipulate for θ=0:0.001:2π
compose(context(rotation=Rotation(θ)), pic)
end
rotatable(fish |> zoomout)
We described Escher's Square Limit from the description of its smaller parts, which in turn were described in terms of their smaller parts.
This seemed simple because we chose to talk in terms of an algebra to describe pictures. The primitives rot
, flip
, fliprot45
, above
, beside
and over
fit the job perfectly.
We were able to describe these primitves in terms of compose
contexts
, which the Compose library knows how to render.
Denotation can be an easy way to describe a system as well as a practical implementation method.
Abstraction barriers are useful tools that can reduce the cognitive overhead on the programmer. It entails creating layers consisting of functions which only use functions in the same layer or layers below in their own implementation. The layers in our language were:
------------------[ squarelimit ]------------------
-------------[ quartet, cycle, nonet ]-------------
---[ rot, flip, fliprot45, above, beside, over ]---
-------[ compose, context, line, path,... ]--------
Drawing this diagram out is a useful way to begin building any library.