18

I have a function r(x) that I want to rotate around the x axis to get a solid of revolution that I want to add to an existing plot_ly plot using add_surface (colored by x).

Here is an example:

library(dplyr)
library(plotly)

# radius depends on x
r <- function(x) x^2

# interval of interest
int <- c(1, 3)

# number of points along the x-axis
nx <- 20

# number of points along the rotation
ntheta <- 36

# set x points and get corresponding radii
coords <- data_frame(x = seq(int[1], int[2], length.out = nx), r = r(x))

# for each x: rotate r to get y and z coordinates
# edit: ensure 0 and pi are both amongst the angles used
coords %<>%
  rowwise() %>%
  do(data_frame(x = .$x, r = .$r,
                theta = seq(0, pi, length.out = ntheta / 2 + 1) %>%
                c(pi + .[-c(1, length(.))]))) %>%

  ungroup %>%
  mutate(y = r * cos(theta), z = r * sin(theta))

# plot points to make sure the coordinates define the desired shape
coords %>%
  plot_ly(x = ~x, y = ~y, z = ~z, color = ~x) %>%
  add_markers()

3D scatter plot

How can I generate the shape indicated by the points above as a plotly surface (ideally open on both ends)?


edit (1):

Here is my best attempt so far:

# get all x & y values used (sort to connect halves on the side)
xs <-
  unique(coords$x) %>%
  sort
ys <-
  unique(coords$y) %>%
  sort

# for each possible x/y pair: get z^2 value
coords <-
  expand.grid(x = xs, y = ys) %>%
  as_data_frame %>%
  mutate(r = r(x), z2 = r^2 - y^2)

# format z coordinates above x/y plane as matrix where columns
# represent x and rows y
zs <- matrix(sqrt(coords$z2), ncol = length(xs), byrow = TRUE)

# format x coordiantes as matrix as above (for color gradient)
gradient <-
  rep(xs, length(ys)) %>%
  matrix(ncol = length(xs), byrow = TRUE)
  
# plot upper half of shape as surface
p <- plot_ly(x = xs, y = ys, z = zs, surfacecolor = gradient,
             type = "surface", colorbar = list(title = 'x'))

# plot lower have of shape as second surface
p %>%
  add_surface(z = -zs, showscale = FALSE)

two 3D surfaces attempt

While this gives the desired shape,

  1. It has 'razor teeth' close to the x/y plane.
  2. The halves parts don't touch. (resolved by including 0 and pi in the theta vectors)
  3. I didn't figure out how to color it by x instead of z (though I didn't look much into this so far). (resolved by gradient matrix)

edit (2):

Here is an attempt using a single surface:

# close circle in y-direction
ys <- c(ys, rev(ys), ys[1])

# get corresponding z-values
zs <- rbind(zs, -zs[nrow(zs):1, ], zs[1, ])

# as above, but for color gradient
gradient <-
  rbind(gradient, gradient[nrow(gradient):1, ], gradient[1, ])

# plot single surface
plot_ly(x = xs, y = ys, z = zs, surfacecolor = gradient,
        type = "surface", colorbar = list(title = 'x'))

Surprisingly, while this should connect the two halves orthogonal to the x / y plane to create the full shape, it still suffers from the same 'razor teeth' effect as the above solution:

single 3D surface attempt


edit (3):

It turns out the missing parts result from z-values being NaN when close to 0:

# color points 'outside' the solid purple
gradient[is.nan(zs)] <- -1

# show those previously hidden points
zs[is.nan(zs)] <- 0

# plot exactly as before
plot_ly(x = xs, y = ys, z = zs, surfacecolor = gradient,
        type = "surface", colorbar = list(title = 'x'))

NaN-override attempt

This could be caused by numerical instability of the substraction when r^2 and y get too close, resulting in negative input for sqrt where the actual input is still non-negative.

This seams unrelated to numerical issues as even when considering +-4 'close' to zero, the 'razor teeth' effect can not be avoided completely:

# re-calculate z-values rounding to zero if 'close'
eps <- 4
zs <- with(coords, ifelse(abs(z2) < eps, 0, sqrt(z2))) %>%
      matrix(ncol = length(xs), byrow = TRUE) %>%
      rbind(-.[nrow(.):1, ], .[1, ])

# plot exactly as before
plot_ly(x = xs, y = ys, z = zs, surfacecolor = gradient,
        type = "surface", colorbar = list(title = 'x'))

eps-attempt

mschilli
  • 1,884
  • 1
  • 26
  • 56
  • It's kind of interesting that I could "see" all these graphs _until_ I set Chrome to enable both webGL and hardware acceleration. Now I cannot see them. But I can now see the examples on a browser page spawned by the code. Very kewl rotation effects possible. Go Figure! (On Mac El Cap with rather ancient hardware. Apparently allowing hardware accel breaks the display of these pngs.) – IRTFM Mar 30 '18 at 22:47

4 Answers4

8

interesting question, I've struggled to use the surface density to improve on your solution. There is a hack you could do with layering multiple lines, that comes out nice for this e.g. Only changes made to the original eg is to use lots more x points: nx to 1000, and change add_markers to add_lines. Might not be scalable, but works fine for this size of data :)

library(dplyr)
library(plotly)

# radius depends on x
r <- function(x) x^2

# interval of interest
int <- c(1, 3)

# number of points along the x-axis
nx <- 1000

# number of points along the rotation
ntheta <- 36

# set x points and get corresponding radii
coords <- data_frame(x = seq(int[1], int[2], length.out = nx), r = r(x))

# for each x: rotate r to get y and z coordinates
# edit: ensure 0 and pi are both amongst the angles used
coords %<>%
  rowwise() %>%
  do(data_frame(x = .$x, r = .$r,
                theta = seq(0, pi, length.out = ntheta / 2 + 1) %>%
                  c(pi + .[-c(1, length(.))]))) %>%

  ungroup %>%
  mutate(y = r * cos(theta), z = r * sin(theta))

# plot points to make sure the coordinates define the desired shape
coords %>%
  plot_ly(x = ~x, y = ~y, z = ~z, color = ~x) %>%
  add_lines()

enter image description here

Best, Jonny

Jonny Phelps
  • 2,687
  • 1
  • 11
  • 20
  • Great hack! Unfortunately my real data is a bit more complex so I need several thousand lines, plus when zooming in, the gaps become visible. I'll leave the bounty open in case someone finds a better solution resulting in an acutally closed surface (see my failed attempts). – mschilli Apr 01 '18 at 09:03
8

This doesn't answer your question, but it will give a result you can interact with in a web page: don't use plot_ly, use rgl. For example,

library(rgl)

# Your initial values...

r <- function(x) x^2
int <- c(1, 3)
nx <- 20
ntheta <- 36

# Set up x and colours for each x

x <- seq(int[1], int[2], length.out = nx)
cols <- colorRampPalette(c("blue", "yellow"), space = "Lab")(nx)

clear3d()
shade3d(turn3d(x, r(x), n = ntheta,  smooth = TRUE, 
        material = list(color = rep(cols, each = 4*ntheta))))
aspect3d(1,1,1)
decorate3d()
rglwidget()

You could do better on the colours with some fiddling: you probably want to create a function that uses x or r(x) to set the colour instead of just repeating the colours the way I did.

Here's the result:

enter image description here

user2554330
  • 37,248
  • 4
  • 43
  • 90
  • Thank you for your answer, but as the question states, I need to add this to an existing `plot_ly` plot so I'd rather stick with that package. Basically, I'm looking for the `plot_ly` equivalent of `rgl::turn3d`. – mschilli Apr 04 '18 at 06:06
6

I have had another crack at it and have a closer solution, using the "surface" type. What helped was looking at the results of your first surface plot with nx = 5 and ntheta = 18. The reason it's jaggardy is because of the way its linking up the columns in zs (across the x points). It's having to link from part way up the larger ring around it, and this causes the density to spike up to meet this point.

I can't get rid of this jaggardy behaviour 100%. I've made these changes:

  1. add some small points to theta around the edges: where the two densities are joined. This reduces the size of the jaggardy part as there are some more points close to the boundary
  2. calculation to mod zs to zs2: ensure that each ring has an equal dimension to the ring outside, by adding the 0's in.
  3. increased nx to 40 and reduced ntheta to 18 - more x's makes step smaller. reduce ntheta for run time, as I've added on more points

the steps come in how it tries to join up the x rings. In theory if you have more x rings it should remove this jaggardiness, but that's time consuming to run.

I don't think this answers the Q 100%, and I'm unsure if this library is the best for this job. Get in touch if any Q's.

library(dplyr)
library(plotly)

# radius depends on x
r <- function(x) x^2

# interval of interest
int <- c(1, 3)

# number of points along the x-axis
nx <- 40

# number of points along the rotation
ntheta <- 18

# set x points and get corresponding radii
coords <- data_frame(x = seq(int[1], int[2], length.out = nx), r = r(x))

# theta: add small increments at the extremities for the density plot
theta <- seq(0, pi, length.out = ntheta / 2 + 1)
theta <- c(theta, pi + theta)
theta <- theta[theta != 2*pi]
inc <- 0.00001
theta <- c(theta, inc, pi + inc, pi - inc, 2*pi - inc)
theta <- sort(theta)

coords %<>%
  rowwise() %>%
  do(data_frame(x = .$x, r = .$r, theta = theta)) %>%
  ungroup %>%
  mutate(y = r * cos(theta), z = r * sin(theta))

# get all x & y values used (sort to connect halves on the side)
xs <-
  unique(coords$x) %>%
  sort
ys <-
  unique(coords$y) %>%
  sort

# for each possible x/y pair: get z^2 value
coords <-
  expand.grid(x = xs, y = ys) %>%
  as_data_frame %>%
  mutate(r = r(x), z2 = r^2 - y^2)

# format z coordinates above x/y plane as matrix where columns
# represent x and rows y
zs <- matrix(sqrt(coords$z2), ncol = length(xs), byrow = TRUE)
zs2 <- zs

L <- ncol(zs)
for(i in (L-1):1){
  w <- which(!is.na(zs[, (i+1)]) & is.na(zs[, i]))
  zs2[w, i] <- 0
}

# format x coordiantes as matrix as above (for color gradient)
gradient <-
  rep(xs, length(ys)) %>%
  matrix(ncol = length(xs), byrow = TRUE)

# plot upper half of shape as surface
p <- plot_ly(x = xs, y = ys, z = zs2, surfacecolor = gradient,
             type = "surface", colorbar = list(title = 'x'))

# plot lower have of shape as second surface
p %>%
  add_surface(z = -zs2, showscale = FALSE)

enter image description here

Jonny Phelps
  • 2,687
  • 1
  • 11
  • 20
  • This is very promising. The problem is that this way, the gaps are closed by drawing the bigger radius at the `x` of the smaller circle, resulting in 'teeth' pointing outwards: https://i.stack.imgur.com/d4JU3.png My feeling is the points I have are all correct and sufficient, but one might have to repeat some of them in the correct order to get the triangles closed... – mschilli Apr 03 '18 at 05:45
4

One solution would be to flip your axes so that you are rotating around the z axis rather than the x axis. I don't know if this is feasible, given the existing chart that you are adding this figure to, but it does easily solve the 'teeth' problem.

xs <- seq(-9,9,length.out = 20)
ys <- seq(-9,9,length.out = 20)

coords <-
  expand.grid(x = xs, y = ys) %>%
  mutate(z2 = (x^2 + y^2)^(1/4))

zs <- matrix(coords$z2, ncol = length(xs), byrow = TRUE)

plot_ly(x = xs, y = ys, z = zs, surfacecolor = zs,
             type = "surface", colorbar = list(title = 'x')) %>% 
  layout(scene = list(zaxis = list(range = c(1,3))))

enter image description here

Andrew Gustar
  • 17,295
  • 1
  • 22
  • 32