Tiling

Overview

You can use PoincareDisk.jl to tile the hyperbolic plane. As with Euclidean spaces, the tiling pattern consists of similar shapes of the same size. On the Poincaré disk, the shapes look smaller as they move further towards the edge of the disk

You use the hyperbolic_tiling(p, q) function to generate an array of the individual tiles' coordinates. Each tile is a p-sided polygon, and q lines join at each vertex.

If you wish, you can then use the provided draw_tiling() function to draw the tiles, using a simple coloring method.

Alternatively, you can write your own code that processes each of the tiles in the array using your preferred graphic styling.

The simple example

@drawsvg begin    sethue("grey50")    draw_poincare_disk(action = :fillstroke)    tiles = hyperbolic_tiling(3, 10) # default depth is 8    draw_tiling(tiles,        action=:fill,        colors=["purple","white"])end
Example block output

tiles is an array of tuples. Each tuple contains:

  • an array of complex coordinates for the hyperbolic polygon for this tile
  • a generation number (an integer), which is a record of when this tile was generated during the tile-construction process

The hyperbolic_tiling() function has a few keyword options:

  • depth = 8
  • hcenter = 0.0 + 0.0im
  • rotation = 0.0
  • maxtiles = 4000

These let you control other aspects of the tile generation process.

The provided draw_tiling() function also provides a few keywords:

  • radius=DEFAULT_DISK_RADIUS
  • diskcenter=O
  • action=:stroke
  • steps=16
  • colors=nothing

Tiling specifications

To define the tiling, you provide two numbers to hyperbolic_tiling(p, q), p and q. These determine the number of points in each polygon, and the number of lines meeting at a vertex. So (3, 10) in the above example made triangular tiles, tiled such that 10 lines meet at each vertex.

p and q must satisfy 1/p + 1/q < 1/2 (the hyperbolic condition).

The simpler tilings are listed in this table:

p:q123456789101112
1
2
3✓✓✓✓✓✓
4✓✓✓✓✓✓✓✓
5✓✓✓✓✓✓✓✓✓
6✓✓✓✓✓✓✓✓✓
7✓✓✓✓✓✓✓✓✓✓
8✓✓✓✓✓✓✓✓✓✓
9✓✓✓✓✓✓✓✓✓✓
10✓✓✓✓✓✓✓✓✓✓
11✓✓✓✓✓✓✓✓✓✓
12✓✓✓✓✓✓✓✓✓✓

For example, (5, 4), (7, 3), and (4, 5) are all acceptable. The 'simplest' available tiling is (3, 7), such that 7 lines connect at each of the triangles' vertices.

More examples

Instead of using the built-in draw_tiling() function, it's easy to write a few functions that process and display the tiles generated by hyperbolic_tiling(p, q). Iterate over the tiles and use hyperbolic_poly() to make suitable graphics for each polygon.

Drawing(600, 600, :svg)origin()sethue("grey15")draw_poincare_disk(action = :fill)tiles = hyperbolic_tiling(4, 5, depth=7)setline(2)for (tile, _) in tiles # don't need generation    randomhue()    hyperbolic_poly(tile, action=:stroke,)endfinish()preview()
Example block output

The hyperbolic_poly() function returns the Luxor coordinates for the tile's border, and the default for the :action keyword is :stroke. So if you want something other than a simple stroke/fill, pass action=:none and use the returned points instead.

Note

If you use SVG drawings, be careful as you increase the depth of the tiling - the files get very large, and the edges get very ragged, since the coordinates are so small. The steps= keyword for hyperbolic_poly might be useful to reduce some of the unnecessary detail.

The next example defines a coloured blend for each tile.

Often, when you put lots of colours close together, it's a good idea to put a small visible gap between adjacent colours (such as a white line). This avoids upsetting the viewer's eyes.

using PoincareDiskusing Luxorusing Colorsfunction lighten(col::Colorant, f)    c = convert(RGB, col)    return RGB(f * c.r, f * c.g, f * c.b)endfunction blend_render(pts, color::Luxor.Colorant;        action=:stroke)    cpt = polycentroid(pts)    d = min(boxwidth(BoundingBox(pts)), boxheight(BoundingBox(pts)))    setblend(        blend(            cpt, 0, cpt, d / 2,            lighten(color, 1.4),            lighten(color, 0.7),        )    )    poly(pts, action)    returnendDrawing(1000, 1000, :png)origin()setline(2)# remember to define the radius according to the drawing sizedraw_poincare_disk(action = :fill, radius=500)tiles = hyperbolic_tiling(5, 4;    depth = 10,    hcenter = 0.0 + 0.0im,    rotation = π/2,    maxtiles = 4000)for (tile, _) in tiles    sethue(Oklch(0.5, 0.3, rand(1:360)))    pts = hyperbolic_poly(tile, radius=500, action=:none)    blend_render(pts, getcolor(), action=:fillpreserve)    sethue("white")    strokepath()endfinish()preview()
Example block output

Another way to do this is to rescale each polygon to leave a gap between adjacent tiles.

using ColorsDrawing(1000, 1000, :svg)origin()sethue("black")draw_poincare_disk(action = :fill, radius = 500)tiles = hyperbolic_tiling(3, 10, depth=10, rotation = π/2)setline(3)for (tile, g) in tiles # don't need generation    pts = hyperbolic_poly(tile, action=:none, steps = 30, radius = 500)    pc = polycentroid(pts)    dist = distance(pc, O)    sethue(Oklch(0.7, 0.6, rescale(dist, 0, boxdiagonal(BoundingBox())/2, 0, 360)))    # reduce scaling as tiles get smaller    polyscale!(pts, rescale(g, 1, 10, 0.97, 0.9), center=pc)    poly(pts, :fill, close=true)endfinish()preview()
Example block output

The next example places an image inside each tile. To rotate the image away from the vertical, we'd need to calculate the correct orientation of each tile. Well, it's on my to-do list...

img = readpng("assets/MonaLisa.png")imgside = max(img.width, img.height)Drawing(1000, 1000, :png)origin()background("grey10")sethue("black")draw_poincare_disk(action = :fill, radius=500)tiles = hyperbolic_tiling(5, 4;depth = 8,hcenter = 0.0 + 0.0im,rotation = π/2,maxtiles = 2000)for (tile, _) in tiles    sethue(Oklch(0.5, 0.3, rand(1:360)))    @layer begin        pts = hyperbolic_poly(tile, radius=500, action=:none)        translate(polycentroid(pts))        polymove!(pts, O, -polycentroid(pts))        poly(pts, :clip)        scale(boxdiagonal(BoundingBox(pts))/imgside)        placeimage(img, O, centered=true)        clipreset()    endendfinish()preview()
Example block output

You don't have to use the geodesic edges of each tile. This next example uses just the tile's corners rather than hyperbolic_poly. Some tidying is required though.

sethue("grey10")draw_poincare_disk(action = :fill)setline(0.1)tiles = hyperbolic_tiling(6, 4, depth=4)for tile in tiles    vertices, generation = tile    shape = complex_to_point.(vertices)    if !ispolyclockwise(shape)        reverse!(shape)    end    if boxwidth(BoundingBox(shape)) < 25        continue    end    sl = slope(O, polycentroid(shape))    sethue(Oklch(0.5, 0.6, rescale(sl, 0, 2π, 0, 360)))    poly(offsetpoly(shape, -5), :fill, close=true)end
Example block output

The example uses a mesh gradient to make the image look whatever.

using PoincareDiskusing Luxorusing ColorsDrawing(1000, 1000, :png)origin()background("black")sethue("grey5")draw_poincare_disk(action = :fill)sethue("white")setline(2)tiles = hyperbolic_tiling(7, 3, hcenter = 0.000001 + 0im, depth = 4)setmesh(mesh(box(BoundingBox()), ["blue", "white", "purple", "maroon"]))setfillrule(:even_odd)for (tile, _) in tiles    pc = polycentroid(complex_to_point.(tile, radius=500))    poly(complex_to_point.(tile, radius=500), close = true, :path)    strokepath()    d = distance(O, pc)    circle.(complex_to_point.(tile, radius=500),        rescale(d, 0, 500, 50, 1), :fill)endfinish()preview()
Example block output