Curves and stones

Recreating the elemental stones with {ggplot2}
R
procedural-art
Author

Charlotte Jane Hadley

Published

December 29, 2025

There aren’t many simple geometric objects from cinema that I remember more clearly (and often) than the elemental stones from the Fifth Element movie. I’m trying to flex my skills in procedural art with {ggplot2} and stumbled across this “Waves Patch” piece from Megan Harris’ incredible “rtristy gallery” which inspired me to try and craft something. Let’s give it a go.

Elemental stones from the Fifth Element.

Elemental stones from the Fifth Element

'Wave Patch' procedural art by Meghan Harris.

“Wave Patch” procedural art by Meghan Harris

Sine curves

Meghan visualises sine curves using by sampling along a sin() curve using seq() and then visualises that curve with geom_path(). Here’s a reproducition of that at it’s simplest:

Code
library("tidyverse")

theta <- seq(from = 0,
             to = 2*pi, 
             length.out = 100)

sine <- tibble(x = theta,
               y = sin(theta),
               label = 1:length(theta))

wave_theta <- seq(from = 0,
                  to = 2 * pi, 
                  by = .1) 

curve_top <- tibble(x = wave_theta,
                    y = sin(x)) %>%
  arrange(x)

curve_top %>%
  ggplot(aes(x=x, y=y))+
  geom_path(arrow = arrow(type="closed"), linewidth = 3) +
  coord_fixed(xlim = c(0, 2 * pi),
              ratio = 1 / 2) +
  theme_void()

Nice! We can stack six of these curves on top of one another using the rep() function.

Side note: do you know the difference between rep() and replicate()? If were interested in adding noise to procedural art replicate() has big advantages we’ll look at in the near future.

Code
wave_theta <- seq(from = 0,
                  to = 2 * pi, 
                  by = .1) 

tibble(x = rep(seq(from = 0,
                  to = 2 * pi, 
                  by = .1) , 6),
       line = rep(1:6, each = length(wave_theta))) %>% 
  mutate(y = line + sin(x),
         line = as.character(line)) %>% 
  ggplot(aes(x=x, y=y, group = line))+
  geom_path(arrow = arrow(type="closed"), linewidth = 5, show.legend = FALSE) +
  scale_x_continuous(expand = expansion(mult = 0, add = c(-0.1, 0.1))) +
  coord_fixed(ratio = 1 / 2) +
  theme_void() +
  theme(panel.background = element_rect(fill = "#9A7D66", colour = "transparent"),
        panel.border = element_blank())

Great! Now to reflect the arrows we need to switch to using a cos() function:

Code
library("patchwork")

gg_L2R_sin_arrowed <- tibble(x = rep(seq(from = 0,
                  to = 2 * pi, 
                  by = .1) , 6),
       line = rep(1:6, each = length(wave_theta))) %>% 
  mutate(y = line + sin(x),
         line = as.character(line)) %>% 
  ggplot(aes(x=x, y=y, group = line))+
  geom_path(arrow = arrow(type="closed", ends = "last"), linewidth = 5, show.legend = FALSE) +
  scale_x_continuous(expand = expansion(mult = 0, add = c(-0.1, 0.1))) +
  scale_y_continuous(expand = expansion(0, c(0.3, 0.3))) +
  coord_fixed(ratio = 1 / 2,
              ylim = c(0, 7)) +
  theme_void() +
  theme(panel.background = element_rect(fill = "#9A7D66", colour = "transparent"),
        panel.border = element_blank()) 

gg_R2L_cos_arrowed <- tibble(x = rep(seq(from = 0,
                  to = 2 * pi, 
                  by = .1) , 6),
       line = rep(-1:4, each = length(wave_theta))) %>% 
  mutate(y = line + cos(x),
         line = as.character(line)) %>% 
  ggplot(aes(x=x, y=y, group = line))+
  geom_path(arrow = arrow(type="closed", ends = "first"), linewidth = 5, show.legend = FALSE) +
  scale_x_continuous(expand = expansion(mult = 0, add = c(0.1, -0.1))) +
  scale_y_continuous(expand = expansion(0, c(0.3, 0.3))) +
  coord_fixed(ratio = 1 / 2,
              ylim = c(-2, 5.2)) +
  theme_void() +
  theme(panel.background = element_rect(fill = "#9A7D66", colour = "transparent"),
        panel.border = element_blank())

ggptch_both_horiz <- gg_L2R_sin_arrowed + theme(plot.margin = margin(r = 10)) | gg_R2L_cos_arrowed

ggptch_both_horiz %>% 
  ggsave(quarto_here("ggptch_both_horiz.png"),
         .,
         width = 5.22 * 2,
         height = 4 )

Arrows in the middle?

It would be a lot more aesthetically pleasing if the direction arrows were in the centre of the image. The easiest way to achieve this is to add geom_segment() into the mix at the centre point with an arrow head. Note that once again we use the geom

Code
seq_x <- seq(from = 0,
                  to = 2 * pi, 
                  by = pi / 100)
n_lines <- 11

data_left_and_right <- tibble(x = rep(seq_x, 9),
       line = rep(seq(-1, 7), each = length(seq_x))) %>% 
  mutate(y = line + sin(x)) 

data_arrows_left_and_right <- data_left_and_right %>% 
  filter(x %in% c(seq_x[c(100, 102)]),
         between(line, 1, 6)) %>% 
  group_by(line) %>%
  summarise(xmin = min(x),
        xmax = max(x),
        ymax = max(y),
        ymin = min(y))
Code
data_left_and_right %>%
  ggplot(aes(x = x, y = y, group = line)) +
  geom_path(linewidth = 5,
            show.legend = FALSE,
            colour = cols_gpcds$graph_tertiary_lighter) +
    geom_segment(data = data_arrows_left_and_right,
               aes(x = xmin, y = ymax, xend = xmax, yend = ymin, group = line),
               arrow = arrow(type="closed", ends = "last"), linewidth = 10, show.legend = FALSE,
               colour = cols_gpcds$story_tertiary_lighter)

Combining this altogether and making it prettier again:

gg_left_to_right <- data_left_and_right %>%
  ggplot(aes(x = x, y = y, group = line)) +
    geom_segment(data = data_arrows_left_and_right,
               aes(x = xmin, y = ymax, xend = xmax, yend = ymin, group = line),
               arrow = arrow(type="closed", ends = "last"), linewidth = 5, show.legend = FALSE) + 
  geom_path(linewidth = 5,
            show.legend = FALSE,
            aes(colour = ifelse(between(line, 1, 6), "main", "background"))) +
  scale_x_continuous(expand = expansion(0, -0.1)) +
  scale_y_continuous(expand = expansion(0, 0)) +
  scale_colour_manual(values = c("main" = "black",
                                 "background" = "grey80")) +
  coord_fixed(ratio = 1 / 2, ylim = c(0, 7), xlim = c(0, 2*pi)) +
  theme_void() +
  theme(
    panel.background = element_rect(fill = "#9A7D66", colour = "transparent"),
    panel.border = element_blank()
  ) 

gg_right_to_left <- data_left_and_right %>%
  ggplot(aes(x = x, y = y, group = line)) +
    geom_segment(data = data_arrows_left_and_right,
               aes(x = xmin, y = ymax, xend = xmax, yend = ymin, group = line),
               arrow = arrow(type="closed", ends = "first"), linewidth = 5, show.legend = FALSE) + 
  geom_path(linewidth = 5,
            show.legend = FALSE,
            aes(colour = ifelse(between(line, 1, 6), "main", "background"))) +
  scale_x_continuous(expand = expansion(0, -0.1)) +
  scale_y_continuous(expand = expansion(0, 0)) +
  scale_colour_manual(values = c("main" = "black",
                                 "background" = "grey80")) +
  coord_fixed(ratio = 1 / 2, ylim = c(0, 7), xlim = c(0, 2*pi)) +
  theme_void() +
  theme(
    panel.background = element_rect(fill = "#9A7D66", colour = "transparent"),
    panel.border = element_blank()
  ) 

ggptch_left_and_right <- gg_left_to_right + theme(plot.margin = margin(r = 20)) | gg_right_to_left

ggptch_left_and_right %>% 
  ggsave(quarto_here("ggptch_left_and_right.png"),
         .,
         width = 5 * 2 + 0.5,
         height = 2.5 * 2 + 1.5)

Cool! I like those. Now let’s make the vertical versions by swapping the x and y coordinates and combine them altogether with {patchwork}

seq_x_tb <- seq(from = -0.5,
                  to = 2.5 * pi, 
                  by = pi / 100)
n_lines <- 11

data_top_and_bottom <- tibble(x = rep(seq_x_tb, 9),
       line = rep(seq(-1, 7), each = length(seq_x_tb))) %>% 
  mutate(y = line + sin(x)) 

data_arrows_bottom_and_top <- data_top_and_bottom %>% 
  filter(x %in% c(seq_x_tb[c(length(seq_x_tb) / 2 -1 , length(seq_x_tb) / 2 + 1)]),
         between(line, 1, 6)) %>% 
  group_by(line) %>%
  summarise(xmin = min(x),
        xmax = max(x),
        ymax = max(y),
        ymin = min(y))

gg_top_to_bottom <- data_top_and_bottom %>%
  ggplot(aes(y = x, x = y, group = line)) +
    geom_segment(data = data_arrows_bottom_and_top,
               aes(y = xmin, x = ymax, yend = xmax, xend = ymin, group = line),
               arrow = arrow(type="closed", ends = "first"), linewidth = 5, show.legend = FALSE) +
  geom_path(linewidth = 5,
            show.legend = FALSE,
            aes(colour = ifelse(between(line, 1, 6), "main", "background"))) +
  scale_x_continuous(expand = expansion(0, -0.1)) +
  scale_y_continuous(expand = expansion(0, 0)) +
  scale_colour_manual(values = c("main" = "black",
                                 "background" = "grey80")) +
  coord_fixed(ratio = 1 / 2, ylim = c(0, 7), xlim = c(0, 2 * pi)) +
  theme_void() +
  theme(
    panel.background = element_rect(fill = "#9A7D66", colour = "transparent"),
    panel.border = element_blank()
  ) 

gg_bottom_to_top <- data_top_and_bottom %>%
  ggplot(aes(y = x, x = y, group = line)) +
    geom_segment(data = data_arrows_bottom_and_top,
               aes(y = xmin, x = ymax, yend = xmax, xend = ymin, group = line),
               arrow = arrow(type="closed", ends = "last"), linewidth = 5, show.legend = FALSE) +
  geom_path(linewidth = 5,
            show.legend = FALSE,
            aes(colour = ifelse(between(line, 1, 6), "main", "background"))) +
  scale_x_continuous(expand = expansion(0, -0.1)) +
  scale_y_continuous(expand = expansion(0, 0)) +
  scale_colour_manual(values = c("main" = "black",
                                 "background" = "grey80")) +
  coord_fixed(ratio = 1 / 2, ylim = c(0, 7), xlim = c(0, 2 * pi)) +
  theme_void() +
  theme(
    panel.background = element_rect(fill = "#9A7D66", colour = "transparent"),
    panel.border = element_blank()
  ) 


(gg_top_to_bottom + theme(plot.margin = margin(r = 20)) | gg_bottom_to_top)

ggptch_all_directions <- (gg_left_to_right + theme(plot.margin = margin(r = 20)) | gg_right_to_left) / 
(gg_top_to_bottom + theme(plot.margin = margin(r = 20)) | gg_bottom_to_top)

ggptch_all_directions %>% 
  ggsave(quarto_here("ggptch_all_directions.png"),
         .,
         width = 5 * 2 + 0.5,
         height = 2.5 * 2 + 1.5)

In an ideal world I’d play around with the vertical images to make them align better, but I can’t quite figure out how to do that today. Hopefully you’ll be seeing these charts again in a project soon. But even if not, I’m quite happy with how they look 😀