GIS for 3D

GIS data structures are not well suited for generalization, and visualizations and models in 3D require pretty forceful and ad hoc approaches.

Here I describe a simple example, showing several ways of visualizing a simple polygon data set. I use the programming environment R for the data manipulation and the creation of this document via several extensions (packages) to base R.

Polygon “layer”

The R package maptools contains an in-built data set called wrld_simpl, which is a basic (and out of date) set of polygons describing the land masses of the world by country. This code loads the data set and plots it with a basic grey-scale scheme for individual countries.

library(maptools)
data(wrld_simpl)
print(wrld_simpl)
## class       : SpatialPolygonsDataFrame 
## features    : 246 
## extent      : -180, 180, -90, 83.57027  (xmin, xmax, ymin, ymax)
## coord. ref. : +proj=longlat +ellps=WGS84 +datum=WGS84 +no_defs +towgs84=0,0,0 
## variables   : 11
## names       : FIPS, ISO2, ISO3,  UN,           NAME,    AREA,    POP2005, REGION, SUBREGION,      LON,     LAT 
## min values  :     ,   AD,  ABW,   4, Aaland Islands,       0,          0,      0,         0, -102.535, -10.444 
## max values  :   ZI,   ZW,  ZWE, 894,       Zimbabwe, 1638094, 1312978855,    150,       155,  179.219,  78.830
plot(wrld_simpl, col = grey(sample(seq(0, 1, length = nrow(wrld_simpl)))))

We also include a print statement to get a description of the data set, this is a SpatialPolgyonsDataFrame which is basically a table of attributes with one row for each country, linked to a recursive data structure holding sets of arrays of coordinates for each individual piece of these complex polygons.

These structures are quite complicated, involving nested lists of matrices with X-Y coordinates. I can use class coercion from polygons, to lines, then to points as the most straightforward way of obtaining every XY coordinate by dropping the recursive hierarchy structure to get at every single vertex in one matrix.

allcoords <- coordinates(as(as(wrld_simpl, "SpatialLines"), "SpatialPoints"))
dim(allcoords)
## [1] 26264     2
head(allcoords)  ## print top few rows
##      coords.x1 coords.x2
## [1,] -61.68667  17.02444
## [2,] -61.88722  17.10527
## [3,] -61.79445  17.16333
## [4,] -61.68667  17.02444
## [5,] -61.72917  17.60861
## [6,] -61.85306  17.58305

(There are other methods to obtain all coordinates while retaining information about the country objects and their component “pieces”, but I’m ignoring that for now.)

We need to put these “X/Y” coordinates in 3D so I simply add another column filled with zeroes.

allcoords <- cbind(allcoords, 0)
head(allcoords)
##      coords.x1 coords.x2  
## [1,] -61.68667  17.02444 0
## [2,] -61.88722  17.10527 0
## [3,] -61.79445  17.16333 0
## [4,] -61.68667  17.02444 0
## [5,] -61.72917  17.60861 0
## [6,] -61.85306  17.58305 0

(Note for non-R users: in R expressions that don’t include assignment to an object with “<-” are generally just a side-effect, here the side effect of the head(allcoords) here is to print the top few rows of allcoords, just for illustration, there’s no other consequence of this code).

OpenGL in R

In R we have access to 3D visualizations in OpenGL via the rgl package, but the model for data representation is very different so I first plot the vertices of the wrld_simpl layer as points only.

library(rgl)
library(rglwidget)  ## allows embedding OpenGL vis in RMarkdown HTML
plot3d(allcoords, xlab = "", ylab = "") ## smart enough to treat 3-columns as X,Y,Z
subid <- currentSubscene3d()
rglwidget(elementId="plot3d_wrld")

rgl.close()

Plotting in the plane is one thing, but more striking is to convert the vertices from planar longitude-latitude to Cartesizan XYZ. Define an R function to take “longitude-latitude-height” and return spherical coordinates (we can leave WGS84 for another day).

llh2xyz <- 
function (lonlatheight, rad = 6378137, exag = 1) 
{
    cosLat = cos(lonlatheight[, 2] * pi/180)
    sinLat = sin(lonlatheight[, 2] * pi/180)
    cosLon = cos(lonlatheight[, 1] * pi/180)
    sinLon = sin(lonlatheight[, 1] * pi/180)
    rad <- (exag * lonlatheight[, 3] + rad)
    x = rad * cosLat * cosLon
    y = rad * cosLat * sinLon
    z = rad * sinLat
    cbind(x, y, z)
}

## deploy our custom function on the longitude-latitude values
xyzcoords <- llh2xyz(allcoords)

Now we can visualize these XYZ coordinates in a more natural setting, and even add a blue sphere for visual effect.

plot3d(xyzcoords, xlab = "", ylab = "")
spheres3d(0, 0, 0, radius = 6370000, col = "lightblue")
subid <- currentSubscene3d()
rglwidget(elementId="plot3d_wrldxyz")

rgl.close()

This is still not very exciting, since our plot knows nothing about the connectivity between vertices.

Organization of polygons

The in-development R package gris provides a way to represent spatial objects as a set of relational tables. I’m leaving out the details because it’s not the point I want to make, but in short a gris object has tables “o” (objects), “b” (for branches), “bXv” (links between branches and vertices) and “v” the vertices.

If we ingest the wrld_simpl layer we get a list with several tables.

library(gris)  ## devtools::install_github("mdsumner/gris")
gobject <- gris(wrld_simpl)
## [1] 22496

The objects, these are individual countries with several attributes including the NAME.

gobject$o
## Source: local data frame [246 x 12]
## 
##      FIPS   ISO2   ISO3    UN                NAME   AREA  POP2005 REGION
##    (fctr) (fctr) (fctr) (int)              (fctr)  (int)    (int)  (int)
## 1      AC     AG    ATG    28 Antigua and Barbuda     44    83039     19
## 2      AG     DZ    DZA    12             Algeria 238174 32854159      2
## 3      AJ     AZ    AZE    31          Azerbaijan   8260  8352021    142
## 4      AL     AL    ALB     8             Albania   2740  3153731    150
## 5      AM     AM    ARM    51             Armenia   2820  3017661    142
## 6      AO     AO    AGO    24              Angola 124670 16095214      2
## 7      AQ     AS    ASM    16      American Samoa     20    64051      9
## 8      AR     AR    ARG    32           Argentina 273669 38747148     19
## 9      AS     AU    AUS    36           Australia 768230 20310208      9
## 10     BA     BH    BHR    48             Bahrain     71   724788    142
## ..    ...    ...    ...   ...                 ...    ...      ...    ...
## Variables not shown: SUBREGION (int), LON (dbl), LAT (dbl), .ob0 (int)

The branches, these are individual simple, one-piece “ring polygons”. Every object may have one or more branches (branches may be an “island” or a “hole” but this is not currently recorded). Note how branch 1 and 2 (.br0) both belong to object 1, but branch 3 is the only piece of object 2.

gobject$b
## Source: local data frame [3,768 x 2]
## 
##     .br0  .ob0
##    (int) (int)
## 1      1     1
## 2      2     1
## 3      3     2
## 4      4     3
## 5      5     3
## 6      6     3
## 7      7     3
## 8      8     3
## 9      9     4
## 10    10     5
## ..   ...   ...
plot(gobject[1, ], col = "#333333")
title(gobject$o$NAME[1])

plot(gobject[2, ], col = "#909090")
title(gobject$o$NAME[2])

(Antigua and Barbuda sadly don’t get a particularly good picture here, but this is not the point of the story.)

The links between branches and vertices.

gobject$bXv
## Source: local data frame [22,496 x 2]
## 
##     .br0  .vx0
##    (int) (int)
## 1      1  7032
## 2      1  7001
## 3      1  7016
## 4      2  7025
## 5      2  7010
## 6      2  7008
## 7      3 10194
## 8      3 10247
## 9      3 10317
## 10     3 10438
## ..   ...   ...

This table is required so that we can normalize the vertices by removing any duplicates based on X/Y pairs. This is required for the triangulation engine used below, although not by the visualization strictly. (Note that we could also normalize branches for objects, since multiple objects might use the same branch - but again off-topic).

Finally, the vertices themselves. Here we only have X and Y, but these table structures can hold any number of attributes and of many types.

gobject$v
## Source: local data frame [21,165 x 3]
## 
##             x        y  .vx0
##         (dbl)    (dbl) (int)
## 1  -61.686668 17.02444  7032
## 2  -61.887222 17.10527  7001
## 3  -61.794449 17.16333  7016
## 4  -61.729172 17.60861  7025
## 5  -61.853058 17.58305  7010
## 6  -61.873062 17.70389  7008
## 7    2.963610 36.80222 10194
## 8    4.785832 36.89472 10247
## 9    5.328055 36.64027 10317
## 10   6.398333 37.08639 10438
## ..        ...      ...   ...

The normalization is only relevant for particular choices of vertices, so if we had X/Y/Z in use there might be a different version of “unique”. I think this is a key point for flexibility, some of these tasks must be done on-demand and some ahead of time.

Indices here are numeric, but there’s actually no reason that they couldn’t be character or other identifier. Under the hood the dplyr package is in use for doing straightforward (and fast!) table manipulations including joins between tables and filtering on values.

More 3D already!

Why go to all this effort just for a few polygons? The structure of the gris objects gives us much more flexibility, so I can for example store the XYZ Cartesian coordinates right on the same data set. I don’t need to recursively visit nested objects, it’s just a straightforward calculation and update - although we’re only making a simple point, this could be generalized a lot more for user code.

gobject$v$zlonlat <- 0
do_xyz <- function(table) {
  xyz <- llh2xyz(dplyr::select(table, x, y, zlonlat))
  table$X <- xyz[,1]
  table$Y <- xyz[,2]
  table$Z <- xyz[,3]
  table
}

gobject$v <- do_xyz(gobject$v)

gobject$v
## Source: local data frame [21,165 x 7]
## 
##             x        y  .vx0 zlonlat       X          Y       Z
##         (dbl)    (dbl) (int)   (dbl)   (dbl)      (dbl)   (dbl)
## 1  -61.686668 17.02444  7032       0 2892546 -5369047.4 1867388
## 2  -61.887222 17.10527  7001       0 2872491 -5376810.3 1875991
## 3  -61.794449 17.16333  7016       0 2880293 -5370474.2 1882167
## 4  -61.729172 17.60861  7025       0 2879394 -5354144.7 1929470
## 5  -61.853058 17.58305  7010       0 2868217 -5361116.3 1926758
## 6  -61.873062 17.70389  7008       0 2864423 -5358521.8 1939577
## 7    2.963610 36.80222 10194       0 5100196   264041.9 3820852
## 8    4.785832 36.89472 10247       0 5083067   425571.3 3829093
## 9    5.328055 36.64027 10317       0 5095693   475229.9 3806402
## 10   6.398333 37.08639 10438       0 5056321   567008.3 3846135
## ..        ...      ...   ...     ...     ...        ...     ...

I now have XYZ coordinates for my data set, and so for example I will extract out a few nearby countries and plot them.

localarea <- gobject[gobject$o$NAME %in% c("Australia", "New Zealand"), ]
## plot in traditional 2d
plot(localarea, col = c("dodgerblue", "firebrick"))

The plot is a bit crazy since parts of NZ that are over the 180 meridian skews everything, and we could fix that easily by modifiying the vertex values for longitude, but it’s more sensible in 3D.

plot3d(localarea$v$X, localarea$v$Y, localarea$v$Z, xlab = "", ylab = "")
subid <- currentSubscene3d()
rglwidget(elementId="plot3d_wrldoznz")

rgl.close()

Finally, to get to the entire point of this discussion let’s triangulate the polygons and make a nice plot of the world.

The R package RTriangle wraps Jonathan Shewchuk’s Triangle library, allowing constrained Delaunay triangulations. To run this we need to make a Planar Straight Line Graph from the polygons, but this is fairly straightforward by tracing through paired vertices in the data set. The key parts of the PSLG are the vertices P and the segment indexes S defining paired vertices for each line segment. This is a “structural” index where the index values are bound to the actual size and shape of the vertices, as opposed to a more general but perhaps less efficient relational index.

pslgraph <- mkpslg(gobject)
dim(pslgraph$P)
## [1] 21165     2
range(pslgraph$S)
## [1]     1 21165
head(pslgraph$P)
##              x        y
## [1,] -61.68667 17.02444
## [2,] -61.88722 17.10527
## [3,] -61.79445 17.16333
## [4,] -61.72917 17.60861
## [5,] -61.85306 17.58305
## [6,] -61.87306 17.70389
head(pslgraph$S)
##      [,1] [,2]
## [1,]    1    2
## [2,]    2    3
## [3,]    3    1
## [4,]    4    5
## [5,]    5    6
## [6,]    6    4

The PSLG is what we need for the triangulation.

tri <- RTriangle::triangulate(pslgraph)

The triangulation vertices (long-lat) can be converted to XYZ, and plotted.

xyz <- llh2xyz(cbind(tri$P, 0))
triangles3d(xyz[t(tri$T), ], col = "grey")
subid <- currentSubscene3d()
rglwidget(elementId="plot3d_wrld_polyxyz")

rgl.close()

These are very ugly polygons since there’s no internal vertices to carry the curvature of this sphere. This is the same problem we’d face if we tried to drape these polygons over topography, as some point we need internal structure.

Luckily Triangle can set a minimum triangle size. We set a constant minimum area, which means no individual triangle can be larger in area than so many “square degrees”. This gives a lot more internal structure so the polygons are more elegantly draped around the surface of the sphere. (There’s not really enough internal structure added with this minimum area, but I’ve kept it simpler to make the size of this document more manageable).

tri <- RTriangle::triangulate(pslgraph, a = 9)  ## a (area) is in degrees, same as our vertices
xyz <- llh2xyz(cbind(tri$P, 0))
triangles3d(xyz[t(tri$T), ], col = "grey")
subid <- currentSubscene3d()
rglwidget(elementId="plot3d_wrld_surfacexyz")

rgl.close()

We still can’t identify individual polygons as we smashed that information after putting the polygon boundary segments through the triangulator. With more careful work we could build a set of tables to store particular triangles between our vertices and objects, but to finish this story I just loop over each object adding them to the scene.

## loop over objects
cols <- sample(grey(seq(0, 1, length = nrow(gobject$o))))
for (iobj in seq(nrow(gobject$o))) {
  pslgraph <- mkpslg(gobject[iobj, ])
  tri <- RTriangle::triangulate(pslgraph, a = 9)  ## a is in units of degrees, same as our vertices
  xyz <- llh2xyz(cbind(tri$P, 0))
  triangles3d(xyz[t(tri$T), ], col = cols[iobj])
}

subid <- currentSubscene3d()
rglwidget(elementId="plot3d_wrld_surfaceobjects")

rgl.close()

Real world topography

TBD

Image textures

TBD