An experimental and in development R package for the production of isometric pseudo 3-D images.
Pseudo, because (for images) the height of the cuboids can be mapped to different aspects of the pixel colour (or position), creating a ‘fake 3D’ effect which may have no correlation to any form of ‘real’ height. Shading of the cuboid faces is also done naively - it’s a simple reduction in brightness, so there is no clever physics going on here!
I am developing this project for fun and personal learning. If it’s useful to you in any way, that’s great news, but please bear the following in mind…
- There are some incredibly cool R packages for doing this type of thing properly…
- All of my knowledge on isometric projections has come from this wikipedia article
- This package is written purely in R (there is no C++ code) so it is
slow to run for large numbers of cuboids
- Anything above a few hundred cuboids square becomes slow to render
- I don’t know what I’m doing!
- For images/pictures
- use
cuboid_image()
- Each pixel is turned into a cuboid that has its height (from the zero ground plane) modified in proportion to some (chosen) aspect of the pixel colour.
- use
- For matrices
cuboid_matrix()
- The height of the cuboid is mapped to the value of the individual matrix elements.
library(tidyverse)
library(magick)
library(isocuboids)
Read an image
i <- 'https://tatianamowry.files.wordpress.com/2018/06/skull-dm.png'
image_read(i)
By default, images are resized to be 60 cuboids wide res = 60
and
rendered as an isometric view. Cuboid heights are mapped to the
brightness value of their corresponding pixel and scaled to range
between 1 and 10 units high
cuboid_image(i)
The orientation applied to the image before projection. This changes the perspective of the final output. Note that this is a transformation of the incoming image - the origin of the coordinate system is unchanged.
cuboid_image(i, orientation = 1)
cuboid_image(i, orientation = 2)
cuboid_image(i, orientation = 3)
cuboid_image(i, orientation = 4)
The fill colour and degree of side shading can be modified with the
cuboid_fill
and shading
values
cuboid_image(i, cuboid_fill = hcl.colors(20, "viridis"))
cuboid_image(i, cuboid_fill = "antiquewhite")
cuboid_image(i, cuboid_fill = hcl.colors(20, "plasma"), shading = c(0, 0, 0.7))
cuboid_image(i, cuboid_fill = hcl.colors(20, "plasma"), shading = c(0, 0.7, 0))
The overall scaling of the cuboid height can be set with height_scale
cuboid_image(i, height_scale = c(0, 0))
cuboid_image(i, height_scale = c(0, 10))
cuboid_image(i, height_scale = c(30, 40))
cuboid_image(i, height_scale = c(40, 30), cuboid_fill = hcl.colors(20, "plasma"))
For fine control, a dataframe of the projected coordinates and polygon groups/colours can be returned
df <- cuboid_image(i, return_data = TRUE, height_scale = c(1, 10))
head(df)
#> # A tibble: 6 × 21
#> x z col r g b h s v cuboid…¹ y face
#> <dbl> <dbl> <chr> <int> <int> <int> <dbl> <dbl> <dbl> <int> <dbl> <chr>
#> 1 59 59 #000000ff 0 0 0 0 0 0 3600 1 right
#> 2 59 59 #000000ff 0 0 0 0 0 0 3600 1 right
#> 3 59 59 #000000ff 0 0 0 0 0 0 3600 1 right
#> 4 59 59 #000000ff 0 0 0 0 0 0 3600 1 right
#> 5 59 59 #000000ff 0 0 0 0 0 0 3600 1 left
#> 6 59 59 #000000ff 0 0 0 0 0 0 3600 1 left
#> # … with 9 more variables: ux <dbl>, uy <dbl>, uz <dbl>, px <dbl>, py <dbl>,
#> # pz <dbl>, v_adjusted <dbl>, col_adjusted <chr>, plot_group <fct>, and
#> # abbreviated variable name ¹cuboid_id
#> # ℹ Use `colnames()` to see all variable names
Filter rows and columns
# Slices in z
df |>
filter(z %in% c(40, 20)) |>
ggplot()+
geom_polygon(aes(x = px, y = py, fill = I(col_adjusted), group = plot_group), col = NA)+
coord_equal()
# A ring of coordinates
df |>
mutate(
xn = x - max(x)/2,
zn = z - max(z)/2,
r = sqrt(xn^2 + zn^2)) |>
filter(between(r, 22, 24)) |>
ggplot()+
geom_polygon(aes(x = px, y = py, fill = I(col_adjusted), group = plot_group), col = NA)+
coord_equal()
Highlight specific cuboid faces
df |>
mutate(
xn = x - max(x)/2,
zn = z - max(z)/2,
r = sqrt(xn^2 + zn^2),
col_adjusted =
case_when(
y == max(y) & face == "top" ~ "red",
y == max(y) & face == "left" ~ "red3",
y == max(y) & face == "right" ~ "red4",
between(r, 22, 24) & face == "top" ~ "blue",
between(r, 22, 24) & face == "left" ~ "blue3",
between(r, 22, 24) & face == "right" ~ "blue4",
TRUE ~col_adjusted)) |>
ggplot() +
geom_polygon(
aes(x = px, y = py, fill = I(col_adjusted), group = plot_group),
col = NA) +
coord_equal()
Split the plot through facetting
df |>
ggplot() +
geom_polygon(
aes(x = px, y = py, fill = I(col_adjusted), group = plot_group),
col = NA) +
coord_fixed()+
facet_wrap(~z > 30, ncol=1)
df |>
ggplot() +
geom_polygon(
aes(x = px, y = py, fill = I(col_adjusted), group = plot_group),
col = NA) +
coord_fixed()+
facet_wrap(~ x < 30 & y > 5, ncol = 1)
Cuboid height is mapped from the image pixel values. By default,
brightness v
is used. Any function of the following parameters can be
passed to height_map
- red r
- green g
- blue b
- hue h
-
saturation s
- brightness v
- x-position x
- z-position z
i2 <-
image_read('https://www.r-project.org/logo/Rlogo.png') |>
image_background("white")
i2
Various functions of the x and z coordinates
cuboid_image(i2,
height_map = sin(scales::rescale(x,c(0,4*pi))),
crop_square = FALSE,
height_scale = c(1, 10))
cuboid_image(i2,
height_map = z^3,
crop_square = FALSE,
height_scale = c(1, 30), a1 = 80)
cuboid_image(i2,
height_map = ((x - (max(x)/2))^2) + ((z - (max(z)/2))^2),
crop_square = FALSE,
height_scale = c(1, 30))
cuboid_image(i2,
crop_square = FALSE,
height_map = (x-(max(x)/2))^3,
height_scale = c(1, 50))
Demonstration of mapping various different colour values to height.
# Create a 'rainbow' colour image
i3 <-
rep(viridis::turbo(100), each = 40) |>
matrix(ncol = 100) |>
image_read()
i3
Map hue, red, green and blue to height
cuboid_image(i3, res = NULL, height_map = h, crop_square = FALSE)
cuboid_image(i3, res = NULL, height_map = r, crop_square = FALSE)
cuboid_image(i3, res = NULL, height_map = g, crop_square = FALSE)
cuboid_image(i3, res = NULL, height_map = b, crop_square = FALSE)
Edit the image before passing into cuboid_image()
image_read(i) |>
image_resize("60x60") |>
image_motion_blur(angle = 45, radius = 6, sigma = 20) |>
cuboid_image(res = NULL)
image_read(i) |>
image_resize("60x60") |>
image_canny() |>
cuboid_image(res = NULL, orientation = 2)
Scan through an image creating cross sectional plots
# Set aesthetics for visualisation
res <- 60
from <- 1 # height scale min
to <- 10 # height scale max
img <-
image_read('https://tatianamowry.files.wordpress.com/2018/06/skull-dm.png') |>
image_resize(paste0(res, "x", res, "^")) |>
image_crop(paste0(res, "x", res), gravity = "center")
# Overall isometric angles plot
df1 <-
cuboid_image(
img,
res = NULL,
height_scale = c(from, to),
return_data = TRUE)
# Cross section 1
df2 <-
cuboid_image(
img,
res = NULL,
height_scale = c(from, to),
return_data = TRUE,
a1 = 0,
a2 = 0,
shading = c(0,0,0))
# Cross section 2
df3 <-
cuboid_image(
img,
res = NULL,
height_scale = c(from, to),
return_data = TRUE,
a1 = 90,
a2 = 0,
shading = c(0,0,0))
for(i in df1$z |> unique() |> sort()){
# Isometric plot
p1 <-
df1 |>
mutate(col_adjusted = case_when(
z == i & x == i & face == "top" ~ "purple",
z == i & x == i & face == "left" ~ "purple3",
z == i & x == i & face == "right" ~ "purple4",
z == i & face == "top" ~ "red",
z == i & face == "left" ~ "red3",
z == i & face == "right" ~ "red4",
x == i & face == "top" ~ "blue",
x == i & face == "left" ~ "blue3",
x == i & face == "right" ~ "blue4",
TRUE ~ col_adjusted)) |>
ggplot() +
geom_polygon(aes(px, py, fill = I(col_adjusted), group = plot_group))+
coord_equal()+
theme(axis.title = element_blank())
# Cross section 1
p2 <-
df2 |>
filter(z == i) |>
ggplot() +
geom_polygon(aes(px, py, fill = I(col_adjusted), group = plot_group))+
coord_equal(ylim = c(from, to))+
theme(panel.border = element_rect(colour = "red", fill = NA, size = 1),
axis.title = element_blank())
# Cross section 2
p3 <-
df3 |>
filter(x == i) |>
ggplot() +
geom_polygon(aes(px, py, fill = I(col_adjusted), group = plot_group))+
coord_equal(ylim = c(from, to))+
theme(panel.border = element_rect(colour = "blue", fill = NA, size = 1),
axis.title = element_blank())
# Output
p4 <- patchwork::wrap_plots(p1, p2, p3, ncol =1)
ggsave(paste0("data-raw/animation/",i,".jpg"), width = 6, height = 6, bg = "white")
}
# Read image files in correct order!
s <-
tibble(f = list.files('data-raw/animation/', pattern = '.jpg', full.names = T)) |>
mutate(n = str_extract(f, "[0-9]+(?=\\.jpg)") |> as.integer()) |>
arrange(n) |>
pull(f) |>
image_read()
# Make smaller
s_small <- image_resize(s, "600x")
# Save animated gif
image_write_gif(s_small, 'data-raw/animation/anim.gif', delay = 0.15)
For matrices, the matrix is not resized and the height of the cuboids is mapped directly to the values contained in the matrix.
cuboid_matrix(matrix(1))
cuboid_matrix(matrix(1:5), show_height_plane = TRUE)
cuboid_matrix(matrix(1:5), show_height_plane = TRUE, orientation = 4)
cuboid_matrix(matrix(seq(0,2,l=25), nrow=5), show_height_plane = TRUE, cuboid_col = 1)
Some examples with the volcano
data
cuboid_matrix(volcano |> scales::rescale(c(0,20)))
cuboid_matrix(volcano |> scales::rescale(c(20,0)))
Generate fake terrain using {ambient}
noise. Shamelessly stolen from
coolbutuseless
s <- 50
set.seed(s)
expand_grid(x=1:s, y=1:s) |>
mutate(n = ambient::gen_perlin(x, y, frequency = 0.06)) |>
pull(n) |>
cut(5, labels=FALSE) |>
matrix(ncol = s) |>
cuboid_matrix(cuboid_fill = topo.colors(20))
An approximation of the Penrose stairs. This is not exact, I used trial and error to get the correct spacing for the stairs to line up!
d <- seq(2, by=0.214, l=14)
m <-
matrix(
c(rev(d[1:6]),
d[7], 0, 0, 0, 0, 0,
d[8], 0, 0, 0, 0, 0,
d[9], 0, 0, 0, 0, 0,
d[10], 0, d[14], 0, 0, 0,
d[11:13], 0, 0, 0), ncol = 6, byrow = T)
cuboid_matrix(m, cuboid_col = 1, show_axes = F, return_data = T) |>
filter(!(x > 2 & z < 5)) |>
ggplot()+
geom_polygon(aes(px, py, group = plot_group, fill=face), col = 1)+
coord_equal()+
theme(legend.position = "")