A Geometry Engine Interface for 'grid'

by Paul Murrell http://orcid.org/0000-0002-3224-8858

Version 4: Tuesday 14 April 2020

Version 1: original publication
Version 2: added mention of 'ggforce' and 'ggraph'
Version 3: Wednesday 01 May 2019; 'gridGeometry' now depends on 'polyclip' 1.10-0
Version 4: start adding dates to history


Creative Commons License
This document by Paul Murrell is licensed under a Creative Commons Attribution 4.0 International License.


This report describes a new function in 'grid' called grobCoords and a new package called 'gridGeometry' that combines grobCoords with the 'polyclip' package to provide a geometry engine interface for 'grid'.

Table of Contents:

1. Introduction

The 'grid' package for R (R Core Team, 2018) provides some useful tools, such as units, viewports, and layouts, that make it easy to express certain arrangements of output when drawing (e.g., 'ggplot2' plots; Wickham, 2016). However, there are many graphical results that are difficult to produce with 'grid'. A simple example is drawing an edge between two nodes on a graph or diagram, as shown below.

plot of chunk unnamed-chunk-4

To be more specific, the line between the two nodes in the above diagram is a Bezier curve that would originate and terminate at the centre of the two nodes, if it were not cut off at the node boundaries. This means that 'grid' functions like grobX and grobY are no use, because it is difficult to determine the angle of incidence of the lines to the nodes. We could use grid.bezier, starting at the boundaries of the nodes, but that would produce a different curve entirely, and one that would not be aiming at the node centres.

Another detail about the diagram is that the background of the nodes is transparent (as shown below). This means that the standard R graphics "painters model" cannot help us out; we cannot just draw a Bezier curve that starts and ends at the node centres and then draw opaque nodes over the top to obscure the intersection of the curve with the nodes.

plot of chunk unnamed-chunk-5

The photo in the image above is © Mick Garratt (Loch na Leitreach, Sunday, 7 June, 2009, cc-by-sa/2.0). It is read into R using the 'jpeg' package (Urbanek, 2014)

One way to produce the diagram above is using "constructive geometry", where we create a shape that is difficult to describe by combining shapes that are easier to describe. In the example above, the curve between the nodes is difficult to describe, but a curve between the node centres is easy and the nodes themselves are easy. By "subtracting" the nodes from a curve between the node centres we can easily obtain the curve between the nodes. This document describes a new package for R called 'gridGeometry' (Murrell, 2019) that provides tools for performing this sort of constructive geometry with 'grid' graphics.

library(gridGeometry)

The following code uses standard 'grid' functions to describe two nodes (a rectangle and a circle) at locations A and B and a Bezier curve from A to B.

Ax <- .3
Ay <- .5
Bx <- .7
By <- .5
node1 <- rectGrob(Ax, Ay, .2, .2,
                  gp=gpar(lwd=5, fill=NA))
node2 <- circleGrob(Bx, By, r=.1,
                    gp=gpar(lwd=5, fill=NA))
line <- bezierGrob(c(Ax, .4, .6, Bx),
                   c(Ay, .2, .2, By),
                   gp=gpar(col=rgb(0,0,0,.2), lwd=5))

The polyclipGrob function from the 'gridGeometry' package can be used to create a new grob that is the Bezier curve "minus" the two nodes.

p <- polyclipGrob(line, gList(node1, node2), op="minus",
                  gp=gpar(lwd=5))

The diagram below shows the two nodes, the original Bezier curve (in grey), and the constructed Bezier curve (the grey curve minus the nodes).

plot of chunk unnamed-chunk-9

The next section describes the 'gridGeometry' package in more detail and later sections provide some more examples of its use.

2. The 'gridGeometry' package

The 'gridGeometry' package provides functions for combining or transforming 'grid' grobs to create new grobs.

The grid.polyclip function, and the polyclipGrob function, allow us to combine grobs using operations supported by the polyclip function from the 'polyclip' package (Johnson and Baddeley, 2019) (which is an R interface to the C Clipper library; Johnson, 2019).

The following code demonstrates the four polyclip operations: intersection, union, minus, and xor. In each case, we combine an overlapping rectangle and circle (both drawn with thick black outlines) and the result is drawn as a filled grey shape with thick black outline.

r <- rectGrob(.4, .4, .4, .4, gp=gpar(lwd=5))
c <- circleGrob(.6, .6, r=.2, gp=gpar(lwd=5))
grid.draw(r)
grid.draw(c)
grid.polyclip(r, c, op="intersection",
              gp=gpar(lwd=5, fill="grey"))
plot of chunk unnamed-chunk-11
grid.draw(r)
grid.draw(c)
grid.polyclip(r, c, op="union",
              gp=gpar(lwd=5, fill="grey"))
plot of chunk unnamed-chunk-12
grid.draw(r)
grid.draw(c)
grid.polyclip(r, c, op="minus",
              gp=gpar(lwd=5, fill="grey"))
plot of chunk unnamed-chunk-13
grid.draw(r)
grid.draw(c)
grid.polyclip(r, c, op="xor",
              gp=gpar(lwd=5, fill="grey"))
plot of chunk unnamed-chunk-14

The 'gridGeometry' package is based on a fork of the 'polyclip' package that allows these operations to occur on (open) lines as well as (closed) polygons. The following code demonstrates the four polyclip operations when combining a Bezier curve with an overlapping circle. In each case, the curve and the circle are drawn with a thin black line and the result is drawn with a thick black line.

l <- bezierGrob(c(.2, .4, .6, .8),
                c(.2, .8, .8, .2))
c <- circleGrob(.6, .6, r=.2)
grid.draw(l)
grid.draw(c)
grid.polyclip(l, c, op="intersection",
              gp=gpar(lwd=5))
plot of chunk unnamed-chunk-16
grid.draw(l)
grid.draw(c)
grid.polyclip(l, c, op="union",
              gp=gpar(lwd=5))
plot of chunk unnamed-chunk-17
grid.draw(l)
grid.draw(c)
grid.polyclip(l, c, op="minus",
              gp=gpar(lwd=5))
plot of chunk unnamed-chunk-18
grid.draw(l)
grid.draw(c)
grid.polyclip(l, c, op="xor",
              gp=gpar(lwd=5))
plot of chunk unnamed-chunk-19

The other high-level functions in 'gridGeometry' are grid.trim and trimGrob. These allow us to extract subsets of a line by specifying from and to arguments. The following code demonstrates its use by creating a variety of subsets of a Bezier curve. In each case, the complete curve is drawn as a thick grey line and the subsets are drawn as thick black lines.

l <- bezierGrob(c(.2, .4, .6, .8),
                c(.2, .8, .8, .2),
                gp=gpar(col="grey", lwd=5))

The from and to arguments specify proportions of the length of the line. In the example below, we create a subset that starts 20% of the way along the curve and finishes 80% of the way along the curve.

grid.draw(l)
grid.trim(l, .2, .8, gp=gpar(lwd=5))
plot of chunk unnamed-chunk-21

Both from and to can be vectors, in which case multiple subsets are created. In the example below, we generate a subset from 20% to 40% and another subset from 60% to 80% of the way along the curve.

grid.draw(l)
grid.trim(l, c(.2, .6), c(.4, .8), gp=gpar(lwd=5))
plot of chunk unnamed-chunk-22

Both from and to can also be negative, in which case the distance is measured from the end of the line. In the example below, we create a subset that starts 20% of the way along the curve and finishes 10% from the end of the curve.

grid.draw(l)
grid.trim(l, .2, -.1, gp=gpar(lwd=5))
plot of chunk unnamed-chunk-23

Both from and to can be 'grid' units. In the example below, we create a subset that starts 5mm along the curve and finishes 1in from the end of the curve.

grid.draw(l)
grid.trim(l, unit(5, "mm"), unit(-1, "in"), gp=gpar(lwd=5))
plot of chunk unnamed-chunk-24

When from and to are units, the "npc" coordinate system represents 0 to 1 along the line. This allows distances along the line to be specified as a combination of a proportions and units. In the example below, we create a subset that starts 20% of the way along the curve and finishes 5mm past 50% of the way along the curve.

grid.draw(l)
grid.trim(l, .2, unit(.5, "npc") + unit(5, "mm"), gp=gpar(lwd=5))
plot of chunk unnamed-chunk-25

There is also a rep argument that can be used to repeat from and to until the end of the line. The values are repeated by adding the from and to values onto the largest to value. In other words, the line that remains after the last to value is then used to generate more subsets using the original from and to values, and that process is repeated until there is no line left. In the example below, we create a subset that starts at 10% and finishes at 20%, then we create a subset that starts at 30% and finishes at 40%, and so on until a final subset that starts at 90% and finishes at 100%.

grid.draw(l)
grid.trim(l, .1, .2, rep=TRUE, gp=gpar(lwd=5))
plot of chunk unnamed-chunk-26

3. Grob coordinates

The high-level functions like grid.polyclip begin with 'grid' grobs and generate 'grid' grobs as the result (and draw them). In between, they have to convert 'grid' grobs into a set of coordinates that can be fed to polyclip from the 'polyclip' package and then convert the result from polyclip back to a 'grid' grob. This section and the next section look at some functions that allow us to perform these conversions directly. This is useful if we want more control over the conversions between grobs and coordinates and if we want to perform further transformations on coordinates before converting back to grobs.

The conversion from a grob to a set of coordinates is made possible by the new function grobCoords in the 'grid' package. This function converts a grob into a list containing lists of x/y coordinates, with all values in inches relative to the current 'grid' viewport. There is also a closed argument to indicate whether we are looking for the coordinates of a closed shape or an open shape. This is straightforward for something like a single line. In the example below, we have a grob that specifies a line from the point (2in, 2in) to (3in, 3in) within the current viewport. Converting that to a set of coordinates in inches within the current viewport produces the expected result.

l <- linesGrob(unit(2:3, "in"), unit(2:3, "in"))
lcoords <- grobCoords(l, closed=FALSE)
lcoords
  [[1]]
  [[1]]$x
  [1] 2 3
  
  [[1]]$y
  [1] 2 3

However, some grobs generate many more coordinates. For example, in the case of a circle, the grob contains only centre and radius information, but the grob coordinates is a list of explicit points on the circumference of the circle. In the example below, we describe a circle centred at (2in, 2in) with a radius of 1in. The set of coordinates in inches within the current viewport consists of 100 (x, y) locations. (There is a argument n to control how many coordinates are generated for a circle).

c <- circleGrob(2, 2, 1, default.units="in")
ccoords <- grobCoords(c, closed=TRUE)
ccoords
  [[1]]
  [[1]]$x
    [1] 3.000000 2.998027 2.992115 2.982287 2.968583 2.951057 2.929776 2.904827 2.876307 2.844328
   [11] 2.809017 2.770513 2.728969 2.684547 2.637424 2.587785 2.535827 2.481754 2.425779 2.368125
   [21] 2.309017 2.248690 2.187381 2.125333 2.062791 2.000000 1.937209 1.874667 1.812619 1.751310
   [31] 1.690983 1.631875 1.574221 1.518246 1.464173 1.412215 1.362576 1.315453 1.271031 1.229487
   [41] 1.190983 1.155672 1.123693 1.095173 1.070224 1.048943 1.031417 1.017713 1.007885 1.001973
   [51] 1.000000 1.001973 1.007885 1.017713 1.031417 1.048943 1.070224 1.095173 1.123693 1.155672
   [61] 1.190983 1.229487 1.271031 1.315453 1.362576 1.412215 1.464173 1.518246 1.574221 1.631875
   [71] 1.690983 1.751310 1.812619 1.874667 1.937209 2.000000 2.062791 2.125333 2.187381 2.248690
   [81] 2.309017 2.368125 2.425779 2.481754 2.535827 2.587785 2.637424 2.684547 2.728969 2.770513
   [91] 2.809017 2.844328 2.876307 2.904827 2.929776 2.951057 2.968583 2.982287 2.992115 2.998027
  
  [[1]]$y
    [1] 2.000000 2.062791 2.125333 2.187381 2.248690 2.309017 2.368125 2.425779 2.481754 2.535827
   [11] 2.587785 2.637424 2.684547 2.728969 2.770513 2.809017 2.844328 2.876307 2.904827 2.929776
   [21] 2.951057 2.968583 2.982287 2.992115 2.998027 3.000000 2.998027 2.992115 2.982287 2.968583
   [31] 2.951057 2.929776 2.904827 2.876307 2.844328 2.809017 2.770513 2.728969 2.684547 2.637424
   [41] 2.587785 2.535827 2.481754 2.425779 2.368125 2.309017 2.248690 2.187381 2.125333 2.062791
   [51] 2.000000 1.937209 1.874667 1.812619 1.751310 1.690983 1.631875 1.574221 1.518246 1.464173
   [61] 1.412215 1.362576 1.315453 1.271031 1.229487 1.190983 1.155672 1.123693 1.095173 1.070224
   [71] 1.048943 1.031417 1.017713 1.007885 1.001973 1.000000 1.001973 1.007885 1.017713 1.031417
   [81] 1.048943 1.070224 1.095173 1.123693 1.155672 1.190983 1.229487 1.271031 1.315453 1.362576
   [91] 1.412215 1.464173 1.518246 1.574221 1.631875 1.690983 1.751310 1.812619 1.874667 1.937209

It is also possible for a single 'grid' grob to describe several shapes. In this case, the set of coordinates is a list with more than one component. In the example below, the grob describes two straight lines, one from (1in, 1in) to (2in, 2in) and one from (3in, 3in) to (4in, 4in). The result from grobCoords is a list of two sets of (x, y) coordinates.

p <- polylineGrob(1:4, 1:4, default.units="in", id=rep(1:2, each=2))
grobCoords(p, closed=FALSE)
  $`1`
    x y
  1 1 1
  2 2 2
  
  $`2`
    x y
  3 3 3
  4 4 4

If we ask an open shape for closed coordinates, or a closed shape for open coordinates, we get an empty result. In the example below, we are asking an (open) line segment for closed coordinates.

grobCoords(l, closed=TRUE)
  $x
  [1] 0
  
  $y
  [1] 0
isEmptyCoords(grobCoords(l, closed=TRUE))
  [1] TRUE

The reason for distinguishing between open and closed coordinates is because it is possible to create a 'grid' grob that consists of both open and closed components. In the example below, we create a gTree grob that has an (open) line and a (closed) circle as its children. When we ask this gTree for closed coordinates, we get an empty result from the line, but a full set of coordinates from the circle.

gt <- gTree(children=gList(l, c))
grobCoords(gt, closed=TRUE)
  $GRID.lines.34
  $GRID.lines.34$x
  [1] 0
  
  $GRID.lines.34$y
  [1] 0
  
  
  $GRID.circle.35
  $GRID.circle.35$x
    [1] 3.000000 2.998027 2.992115 2.982287 2.968583 2.951057 2.929776 2.904827 2.876307 2.844328
   [11] 2.809017 2.770513 2.728969 2.684547 2.637424 2.587785 2.535827 2.481754 2.425779 2.368125
   [21] 2.309017 2.248690 2.187381 2.125333 2.062791 2.000000 1.937209 1.874667 1.812619 1.751310
   [31] 1.690983 1.631875 1.574221 1.518246 1.464173 1.412215 1.362576 1.315453 1.271031 1.229487
   [41] 1.190983 1.155672 1.123693 1.095173 1.070224 1.048943 1.031417 1.017713 1.007885 1.001973
   [51] 1.000000 1.001973 1.007885 1.017713 1.031417 1.048943 1.070224 1.095173 1.123693 1.155672
   [61] 1.190983 1.229487 1.271031 1.315453 1.362576 1.412215 1.464173 1.518246 1.574221 1.631875
   [71] 1.690983 1.751310 1.812619 1.874667 1.937209 2.000000 2.062791 2.125333 2.187381 2.248690
   [81] 2.309017 2.368125 2.425779 2.481754 2.535827 2.587785 2.637424 2.684547 2.728969 2.770513
   [91] 2.809017 2.844328 2.876307 2.904827 2.929776 2.951057 2.968583 2.982287 2.992115 2.998027
  
  $GRID.circle.35$y
    [1] 2.000000 2.062791 2.125333 2.187381 2.248690 2.309017 2.368125 2.425779 2.481754 2.535827
   [11] 2.587785 2.637424 2.684547 2.728969 2.770513 2.809017 2.844328 2.876307 2.904827 2.929776
   [21] 2.951057 2.968583 2.982287 2.992115 2.998027 3.000000 2.998027 2.992115 2.982287 2.968583
   [31] 2.951057 2.929776 2.904827 2.876307 2.844328 2.809017 2.770513 2.728969 2.684547 2.637424
   [41] 2.587785 2.535827 2.481754 2.425779 2.368125 2.309017 2.248690 2.187381 2.125333 2.062791
   [51] 2.000000 1.937209 1.874667 1.812619 1.751310 1.690983 1.631875 1.574221 1.518246 1.464173
   [61] 1.412215 1.362576 1.315453 1.271031 1.229487 1.190983 1.155672 1.123693 1.095173 1.070224
   [71] 1.048943 1.031417 1.017713 1.007885 1.001973 1.000000 1.001973 1.007885 1.017713 1.031417
   [81] 1.048943 1.070224 1.095173 1.123693 1.155672 1.190983 1.229487 1.271031 1.315453 1.362576
   [91] 1.412215 1.464173 1.518246 1.574221 1.631875 1.690983 1.751310 1.812619 1.874667 1.937209

If we ask the gTree for open coordinates, we get coordinates from the line and an empty result from the circle.

grobCoords(gt, closed=FALSE)
  $GRID.lines.34
  $GRID.lines.34$x
  [1] 2 3
  
  $GRID.lines.34$y
  [1] 2 3
  
  
  $GRID.circle.35
  $GRID.circle.35$x
  [1] 0
  
  $GRID.circle.35$y
  [1] 0

Once we have grob coordinates, we can call functions like polyclip from the 'polyclip' package to combine the coordinates. The result is a new set of coordinates. In the example below, we intersect the line with the circle and the result is a truncated line (up to the edge of the circle). The closed argument (which has been added in the fork of the 'polyclip' package) tells polyclip whether its first argument is open or closed (polyclip requires the second argument to always be a closed shape).

polyclip(lcoords, ccoords, closed=FALSE)
  [[1]]
  [[1]]$x
  [1] 2.000000 2.706758
  
  [[1]]$y
  [1] 2.000000 2.706758

We can also use a set of coordinates in other calculations. For example, the following code draws a variable-width line around the circumference of the circle (using the 'vwline' package; , Murrell, 2018). The value of grobCoords here is to convert a circle specification into (essentially) a series of straight line segments, which we can then use with the grid.vwcurve function to draw a variable-width line.

library(vwline)
cx <- ccoords[[1]]$x
cy <- ccoords[[1]]$y
grid.vwcurve(cx, cy, default.units="in",
             w=seq(0, 1, length.out=length(cx)))
plot of chunk unnamed-chunk-34

The 'gridGeometry' package also defines a generic version of the polyclip function, with polyclip::polyclip as the default and a new method for 'grid' grobs. This means that we can generate coordinates for a combination of grobs directly. The following code sends polyclip the original grobs rather than their coordinates. The result is still a set of coordinates.

polyclip(l, c, closed=FALSE)
  [[1]]
  [[1]]$x
  [1] 2.000000 2.706758
  
  [[1]]$y
  [1] 2.000000 2.706758

There is also a polyclip method for gPaths, so that it is possible to refer by name to grobs that have already been drawn. The following code demonstrates this by drawing a line named "l" and a circle named "c" and then calling polyclip to produce coordinates for the intersection of the grobs named "l" and "c".

grid.lines(unit(2:3, "in"), unit(2:3, "in"), name="l")
grid.circle(2, 2, 1, default.units="in", name="c")
polyclip("l", "c", closed=FALSE)
  [[1]]
  [[1]]$x
  [1] 2.000000 2.706758
  
  [[1]]$y
  [1] 2.000000 2.706758

There is also a low-level function that underlies the grid.trim and trimGrob functions. The trim function takes either a grob, or a set of coordinates, and generates a new set of coordinates, in this case representing one or more subsets of the original grob coordinates. For example, in the code below, we generate an X-spline grob that starts at (0in, 0in), ends at (2in, 01in) and passes through (1in, 1in) and then we call trim to calculate the coordinates of two subsets of the X-spline curve: the first half of the curve and the last half of the curve.

xg <- xsplineGrob(0:2, c(0, 1, 0), default.units="in", shape=-1)
xcoords <- trim(xg, from=0:1/2, to=1:2/2)
xcoords
  [[1]]
  [[1]]$x
   [1] 0.000 0.000 0.003 0.010 0.023 0.043 0.070 0.103 0.142 0.186 0.235 0.287 0.342 0.401 0.463 0.529
  [17] 0.600 0.676 0.759 0.848 0.942 1.000
  
  [[1]]$y
   [1] 0.000 0.001 0.006 0.020 0.045 0.082 0.132 0.192 0.260 0.335 0.414 0.493 0.572 0.648 0.720 0.787
  [17] 0.848 0.901 0.945 0.978 0.997 1.000
  
  
  [[2]]
  [[2]]$x
   [1] 1.000 1.000 1.096 1.189 1.275 1.355 1.429 1.498 1.562 1.623 1.680 1.734 1.785 1.832 1.875 1.911
  [17] 1.942 1.966 1.983 1.993 1.998 2.000 2.000
  
  [[2]]$y
   [1] 1.000 1.000 0.991 0.966 0.929 0.881 0.824 0.761 0.692 0.618 0.541 0.461 0.382 0.305 0.232 0.166
  [17] 0.110 0.066 0.034 0.014 0.003 0.000 0.000

The following code draws the two halves of the curve with different line widths.

pushViewport(viewport(width=2/3, height=2/3))
grid.lines(xcoords[[1]]$x, xcoords[[1]]$y, default.units="in",
           gp=gpar(lwd=3, lineend="butt"))
grid.lines(xcoords[[2]]$x, xcoords[[2]]$y, default.units="in",
           gp=gpar(lwd=10, lineend="butt"))
plot of chunk unnamed-chunk-40

4. Grobs from coordinates

If we have worked directly in the world of grob coordinates, using functions from the previous section, we usually want to get back to the world of grobs, so that we can draw the final result.

We have seen a couple of examples of drawing shapes from coordinates by calling 'grid' functions and taking into account that the coordinates in are inches. The 'gridGeometry' package provides several convenience functions that make this job a little less work: xyListLine, xyListPath and xyListPolygon.

The main benefit of these functions is that they automatically assume inches as the coordinate system and they automatically handle multiple shapes. For example, in the following code we subtract a thin rectangle from a circle using polyclip directly, so the result is a set of coordinates.

coords <- polyclip(c, rectGrob(width=.2), op="minus")
coords
  [[1]]
  [[1]]$x
   [1] 1.600000 1.574221 1.518246 1.464173 1.412215 1.362576 1.315453 1.271031 1.229487 1.190983
  [11] 1.155672 1.123693 1.095173 1.070224 1.048943 1.031417 1.017713 1.007885 1.001973 1.000000
  [21] 1.001973 1.007885 1.017713 1.031417 1.048943 1.070224 1.095173 1.123693 1.155672 1.190983
  [31] 1.229487 1.271031 1.315453 1.362576 1.412215 1.464173 1.518246 1.574221 1.600000
  
  [[1]]$y
   [1] 2.915983 2.904827 2.876307 2.844328 2.809017 2.770513 2.728969 2.684547 2.637424 2.587785
  [11] 2.535827 2.481754 2.425779 2.368125 2.309017 2.248690 2.187381 2.125333 2.062791 2.000000
  [21] 1.937209 1.874667 1.812619 1.751310 1.690983 1.631875 1.574221 1.518246 1.464173 1.412215
  [31] 1.362576 1.315453 1.271031 1.229487 1.190983 1.155672 1.123693 1.095173 1.084017
  
  
  [[2]]
  [[2]]$x
   [1] 2.425779 2.481754 2.535827 2.587785 2.637424 2.684547 2.728969 2.770513 2.809017 2.844328
  [11] 2.876307 2.904827 2.929776 2.951057 2.968583 2.982287 2.992115 2.998027 3.000000 2.998027
  [21] 2.992115 2.982287 2.968583 2.951057 2.929776 2.904827 2.876307 2.844328 2.809017 2.770513
  [31] 2.728969 2.684547 2.637424 2.587785 2.535827 2.481754 2.425779 2.400000 2.400000
  
  [[2]]$y
   [1] 1.095173 1.123693 1.155672 1.190983 1.229487 1.271031 1.315453 1.362576 1.412215 1.464173
  [11] 1.518246 1.574221 1.631875 1.690983 1.751310 1.812619 1.874667 1.937209 2.000000 2.062791
  [21] 2.125333 2.187381 2.248690 2.309017 2.368125 2.425779 2.481754 2.535827 2.587785 2.637424
  [31] 2.684547 2.728969 2.770513 2.809017 2.844328 2.876307 2.904827 2.915983 1.084017

The xyListPolygon function automatically copes with the fact that the result is two separate shapes and generates a polygon grob that we can draw.

grid.draw(xyListPolygon(coords,
                        gp=gpar(lwd=5, fill="grey")))
plot of chunk unnamed-chunk-42

It is important to note that a call to the grobCoords function is when grob coordinates get converted to inches within the current 'grid' viewport. This means that the coordinates that are generated are relative to the viewport in effect when grobCoords is called.

In the code below, we generate a rectangle grob and call grobCoords to calculate its coordinates. We then draw the rectangle grob and a polygon based on its coordinates, in the same viewport that was in effect when we calculated the coordinates, so the rectangle (grey) and the polygon (dotted black) match up.

r <- rectGrob(width=.8, height=.8, gp=gpar(lwd=5, col="grey"))
coords <- grobCoords(r, closed=TRUE)
grid.draw(r)
grid.draw(xyListPolygon(coords,
                        gp=gpar(lwd=5, lty="dotted")))
plot of chunk unnamed-chunk-43

Next, we push a viewport (in the central quarter of the image) and draw the grob and the polygon within this viewport. This is not the viewport that the coordinates were calculated within, so the polygon (red dotted) does not match the rectangle (inner grey).

pushViewport(viewport(width=.5, height=.5))
grid.draw(r)
grid.draw(xyListPolygon(coords,
                        gp=gpar(lwd=5, lty="dotted", col="red")))
plot of chunk unnamed-chunk-44

Finally, we call grobCoords again, this time within the new viewport, and now the rectangle (inner grey) and the polygon (dotted blue) match up again.

grid.draw(xyListPolygon(grobCoords(r, closed=TRUE),
                        gp=gpar(lwd=5, lty="dotted", col="blue")))
plot of chunk unnamed-chunk-45

On the other hand, the grobCoords function automatically takes into account any vp setting (and for gTrees any childrenvp settings). For example, in the following code we create a rectangle grob with a vp argument that means that the rectangle will be drawn in the central quarter of the image. If we draw the rectangle (grey) and call grobCoords and draw a polygon from the resulting coordinates (dotted black) in the same viewport, we get a matching result.

r <- rectGrob(width=.8, height=.8,
              gp=gpar(lwd=5, col="grey"),
              vp=viewport(width=.5, height=.5))
grid.draw(r)
grid.draw(xyListPolygon(grobCoords(r, closed=TRUE),
                        gp=gpar(lwd=5, lty="dotted")))
plot of chunk unnamed-chunk-46

It is also important to note that the coordinates generated by grobCoords are "neutral" in the sense that they do not retain any information about whether they were generated from a closed or open shape, or any details like fill rules for more complex shapes. This means that we can impose semantics on the coordinates that are different from the semantics of the original grob. As an example, consider the following lines grob.

lg <- linesGrob(c(.2, .2, .8, .8, .4, .6),
                c(.2, .8, .8, .2, .6, .6),
                gp=gpar(lwd=3))
grid.draw(lg)
plot of chunk unnamed-chunk-47

We can generate coordinates from this line and then a grob from the coordinates and get a filled shape. We must specify closed=FALSE for the call to grobCoords or we will get an empty result, but once we have coordinates we can use them however we wish. In this case, by passing the coordinates to the xyListPath function, we treat the coordinates as a closed path shape.

lgcoords <- grobCoords(lg, closed=FALSE)
lgpath <- xyListPath(lgcoords, rule="evenodd", gp=gpar(fill="black"))
grid.draw(lgpath)
plot of chunk unnamed-chunk-48

It is also possible to dictate how coordinates are interpreted when we call the polyclip function. The following code treats the line coordinates as a closed shape to intersect those coordinates with the coordinates for a circle. We specify closed=TRUE in the call to polyclip so that it interprets the coordinates for the line as a closed shape.

coords <- polyclip(lgcoords,
                   grobCoords(circleGrob(.5, .5, .3), closed=TRUE),
                   closed=TRUE,
                   fillA="evenodd")

The result of polyclip is a new set of coordinates and we can use them to draw an open line ...

grid.draw(xyListLine(coords, gp=gpar(lwd=3)))
plot of chunk unnamed-chunk-50

... or a closed path ...

grid.draw(xyListPath(coords, gp=gpar(fill="black")))
plot of chunk unnamed-chunk-51

5. Grobs versus coordinates

This section looks at some important differences between 'grid' grobs and sets of coordinates.

The grobCoords function generates points on the boundary of a 'grid' grob based on the basic description of the shape of the grob. This does not take into account things like the colour of the grob, whether the grob is filled in or not, or, more importantly, the thickness of the line used to draw the boundary of the grob.

The following code demonstrates this difference by subtracting a rectangle from a circle, both of which are drawn with thick lines. The resulting intersection is drawn as a thin red line. Notice that only the basic circle shape has been subtracted from the rectangle, ignoring the thickness of the circle border and ignoring the thickness of the rectangle border.

r <- rectGrob(.4, .4, .4, .4, gp=gpar(lwd=10))
c <- circleGrob(.6, .6, r=.2, gp=gpar(lwd=10))
grid.draw(r)
grid.draw(c)
grid.polyclip(r, c, op="minus",
              gp=gpar(col="red"))
plot of chunk unnamed-chunk-53

If we draw the result with the same line thickness as the circle and the rectangle, the result looks even worse (it looks like only the interior of the circle has been removed from the rectangle) ...

grid.draw(r)
grid.draw(c)
grid.polyclip(r, c, op="minus",
              gp=gpar(col="red", lwd=10))
plot of chunk unnamed-chunk-54

If we actually want to subtract the outline of the drawn border of the circle, we need coordinates that describe that outline. One way to do that is with the 'vwline' package, which generates a polygon or path to represent a line (potentially with a variable width).

The following code calls grobCoords to get the coordinates of the rectangle and the circle. We then generate a variable-width line (with a constant width) based on the circle coordinates. Next, we generate the coordinates for that variable-width line (which has coordinates for both the inside and outside of the circle border). Finally, we generate a polygon by subtracting the second set of coordinates for the variable-width line from the rectangle coordinates.

rc <- grobCoords(r, closed=TRUE)
cc <- grobCoords(c, closed=TRUE)[[1]]
vwc <- vwcurveGrob(cc$x, cc$y, default.units="in", w=unit(10/96, "in"),
                   open=FALSE)
vwcc <- grobCoords(vwc, closed=TRUE)
result <- xyListPolygon(polyclip(rc, vwcc[2], "minus"), gp=gpar(col="green"))

The figure below shows the original rectangle, the variable-width line and result of the subtraction (in green). Many other variations on this result are possible by playing directly with coordinates of grobs.

grid.draw(r)
grid.draw(vwc)
grid.draw(result)
plot of chunk unnamed-chunk-56

6. Examples

One simple application of 'gridGeometry' is to generate a polygonal shape that is difficult to describe directly, as in the original line between two graph nodes at the start of this document.

The code below shows a closed-shape version of this sort of task (for a fairly arbitrary shape). The goal is a rectangle with circular holes punched in it, including semicircular holes at the edges (the filled grey region below). This shape could be described explicitly as a path in 'grid', but it is much easier to describe a rectangle and a series of circles and subtract the latter from the former.

r <- rectGrob(width=.6, height=.4, gp=gpar(lwd=5))
c <- circleGrob(x=1:4/5, r=unit(5, "mm"), gp=gpar(lwd=5))
p <- polyclipGrob(r, c, op="minus",
                  gp=gpar(fill=rgb(0,0,0,.5), lwd=5))
grid.draw(r)
grid.draw(c)
grid.draw(p)
plot of chunk unnamed-chunk-57

The next example shows a similar construction, but in a larger context (at the risk of inviting the attention of the chart-junk police). The overall plot is a 'lattice' barchart (Sarkar, 2008) of counts of movies in different genres (Wickham, 2015). We have provided a panel function that, after drawing the normal bars, constructs a "flim strip" shape by substracting lots of small rectangles and circles from the normal bars. This demonstrates the use of a gPath as the first argument to grid.polyclip which, by default, replaces the named grob with the result of the polyclip operation (by default retaining the graphical parameters of the original grob).

library(ggplot2movies)
counts <- apply(movies[-(1:17)], 2, sum)
filmstrip <- function(x, y, box.width=2/3, ...) {
    panel.barchart(x, y, box.width, ...)
    w <- convertWidth(unit(1, "npc"), "cm", valueOnly=TRUE)
    hsteps <- seq(-.1, w, .7)
    hx <- rep(hsteps, length(x))
    hy <- rep(y, each=length(hsteps))
    holes <- rectGrob(x=unit(hx, "cm"), y=unit(hy, "native"),
                      width=unit(.5, "cm"), height=unit(.7, "cm"),
                      just="left")
    esteps <- seq(.1, w, .2)
    ex <- rep(esteps, 2*length(x))
    ey <- rep(c(as.numeric(y) + .4*box.width,
                as.numeric(y) - .4*box.width),
              each=length(esteps))
    edges <- circleGrob(unit(ex, "cm"), unit(ey, "native"),
                        r=unit(.5, "mm"))
    grid.polyclip("plot_01.barchart.rect.panel.1.1",
                  gList(holes, edges), "minus")
}
library(lattice)
barchart(sort(counts),
         panel=function(x, y, ...) {
             filmstrip(x, y, ...)
         })
plot of chunk unnamed-chunk-58

The next example demonstrates an application of the trim function. To motivate the problem, we first draw a Bezier curve with an arrow head with standard 'grid' functions.

x <- c(.2, .5, .5, .8)
y <- c(.2, 0, 1, .8)
grid.bezier(x, y, gp=gpar(lwd=5, fill=NA),
            arrow=arrow(angle=20, length=unit(1, "cm"), type="closed"))
plot of chunk unnamed-chunk-59

There are two problems with the result above: the arrow head is drawn with the same graphical parameters as the line, so it is thick with rounded corners; and the orientation of the arrow head is based on the angle of the last straight line segment in the "curve", so it looks silly (because the end of the line has high curvature).

We are going to improve the arrow head by drawing the curve in separate parts. First, we get the coordinates for the line and split those into three parts: the last 10mm of the curve; 8mm from the end to 12mm from the end of the curve; and all but the last 10mm of the curve.

bg <- bezierGrob(x, y, gp=gpar(lwd=5, col=rgb(0,0,0,.2)))
pts <- grobCoords(bg, closed=FALSE)
segs <- trim(pts,
             from=unit(c(1, -8, -10), c("npc", "mm", "mm")),
             to=unit(c(-10, -12, 0), c("mm", "mm", "npc")))
plot of chunk unnamed-chunk-61

Now we can draw most of the curve as a normal line and then add a variable-width line based on the last 10mm of the curve to produce an arrow head that follows the curve.

line <- xyListLine(segs[3], gp=gpar(lwd=5))
arrow <- offsetXsplineGrob(segs[[1]]$x, segs[[1]]$y, default.units="in",
                           shape=1,
                           w=widthSpline(unit(c(6, 0), "mm")))
grid.draw(line)
grid.draw(arrow)
plot of chunk unnamed-chunk-62

The following code takes this a step further and adds in a bit of constructive geometry. This time we draw all but the last 8mm of the curve as a normal line. We then create two variable-width lines, one based on the last 10mm of the curve as before, and a second based on the section of the curve from 8mm to 12mm from the end. We make the latter variable-width line expand much faster than the former (the two variable-width lines are shown in semitransparent red and semitransparent blue below).

line <- xyListLine(segs[2:3], gp=gpar(lwd=5))
arrow1 <- offsetXsplineGrob(segs[[1]]$x, segs[[1]]$y, default.units="in",
                            shape=1,
                            w=widthSpline(unit(c(6, 0), "mm")),
                            gp=gpar(fill=rgb(1,0,0,.5)))
arrow2 <- offsetXsplineGrob(segs[[2]]$x, segs[[2]]$y, default.units="in",
                            shape=1,
                            w=widthSpline(unit(c(12, 0), "mm")),
                            gp=gpar(fill=rgb(0,0,1,.5)))
grid.draw(line)
grid.draw(arrow1)
grid.draw(arrow2)
plot of chunk unnamed-chunk-63

We construct a final arrow head by subtracting one variable-width line from the other to produce a curve with a notched arrow head that follows the curve (and has nice pointy corners).

arrow <- polyclipGrob(arrow1, arrow2, "minus",
                      gp=gpar(fill="black"))
grid.draw(line)
grid.draw(arrow)
plot of chunk unnamed-chunk-64

That may seem like a lot of work, but because it is code-based, this approach could easily be wrapped into a function to simplify reuse and enhance generality.

7. Discussion

This report has described two new graphics facilities in R. One is the grobCoords function in the 'grid' package, which can be used to convert 'grid' grobs into sets of coordinates that describe the shape of the grob. The other is the 'gridGeometry' package, which provides functions for combining grobs, for example, calculating the intersection of two grobs.

It might reasonably be asked what purpose the 'gridGeometry' package is really serving. The grobCoords function performs the conversion from grobs into coordinates and the 'polyclip' package does all of the hard work of combining sets of coordinates. What does 'gridGeometry' add to that?

The answer is, mostly, just convenience. Most of the functions in the 'gridGeometry' package are there to provide an interface to the process of combining 'grid' grobs. However, the grid.trim function for generating subsets of lines is also a new contribution.

What is the value of having this convenient interface to a geometry engine? The main idea is that this provides access to a new tool. For example, prior to having this tool, we might never have thought of turning a bar plot into a film strip. Some might argue that we should never have that thought and that that is not a good thought, but the important point is that new tools help us to think of new ways of doing things. New tools help us to find new solutions to problems.

Related work

There are several other R packages that make use of the geometry engine that is provided by the 'polyclip' package. For example, 'eulerr' (Larsson, 2019) and 'SubgrPlots' (Ballarini and Chiu, 2018) use 'polyclip' for drawing (variations of) venn diagrams. The 'maptools' package (Bivand and Lewin-Koh, 2019) uses 'polyclip' to clip map regions (when they wrap from left-edge to right-edge of map, or vice versa) and 'deldir' (Turner, 2019) uses 'polyclip' for complex clipping of voronoi tesselations. The 'ggforce' (Pedersen, 2019) and 'ggraph' (Pedersen, 2018) packages use 'polyclip' for tasks very similar to those demonstrated in this report (cutting edges between nodes and modifying or combining shapes), but provides these facilities as 'ggplot2' extensions rather than at the level of 'grid'. The 'spatstat' package (Baddeley et al., 2015) uses 'polyclip' for all sorts of things, including polygon offsets, polyline offsets, and polygon intersection. The main difference betweeen 'gridGeometry' and these other packages is in how the shapes that are to be combined are generated. The contribution of 'gridGeometry' (and grid::grobCoords) is to add the ability to generate shapes from 'grid' grobs (which includes anything that is drawn by packages that are built on top of 'grid').

Limitations

The usual issue of speed (or slowness) is a problem for the 'gridGeometry' package. This is not the fastest possible way to perform constructive geometry. However, in some applications and for some users, the convenience may be worth the wait.

Another important limitation of 'gridGeometry', or more specifically, the grobCoords function, is that it is not (currently) possible to generate a set of coordinates for all 'grid' grobs. It is perhaps not surprising that raster grobs, "clip" grobs, and "null" grobs generate an empty set of coordinates, but, in addition, text grobs and point grobs generate an empty set of coordinates. These gaps may be the subject of future work.

8. Technical requirements

The examples and discussion in this document relate to version 0.2-0 of the 'gridGeometry' package, version 1.10-0 of the 'polyclip' package (to allow for open paths), and R version 3.6.0 (for the grobCoords function).

This report was generated within a Docker container (see Resources section below).

9. Resources

How to cite this document

Murrell, P. (2019). "A Geometry Engine Interface for 'grid'" Technical Report 2019-01, Department of Statistics, The University of Auckland. Version 4. [ bib | DOI | http ]

10. References

[Baddeley et al., 2015]
Baddeley, A., Rubak, E., and Turner, R. (2015). Spatial Point Patterns: Methodology and Applications with R. Chapman and Hall/CRC Press, London. [ bib | http ]
[Ballarini and Chiu, 2018]
Ballarini, N. and Chiu, Y.-D. (2018). SubgrPlots: Graphical Displays for Subgroup Analysis in Clinical Trials. R package version 0.1.0. [ bib | http ]
[Bivand and Lewin-Koh, 2019]
Bivand, R. and Lewin-Koh, N. (2019). maptools: Tools for Handling Spatial Objects. R package version 0.9-5. [ bib | http ]
[Johnson, 2019]
Johnson, A. (2019). Clipper - an open source freeware library for clipping and offsetting lines and polygons. http://www.angusj.com/delphi/clipper.php. Accessed: 2019-02-27. [ bib ]
[Johnson and Baddeley, 2019]
Johnson, A. and Baddeley, A. (2019). polyclip: Polygon Clipping. R package version 1.10-0. [ bib | http ]
[Larsson, 2019]
Larsson, J. (2019). eulerr: Area-Proportional Euler and Venn Diagrams with Ellipses. R package version 5.1.0. [ bib | http ]
[Murrell, 2017]
Murrell, P. (2017). Variable-width lines in R. Technical Report 2017-01, Department of Statistics, The University of Auckland. Version 1. [ bib | DOI | http ]
[Murrell, 2018]
Murrell, P. (2018). vwline: Draw variable-width lines. R package version 0.2-1. [ bib ]
[Murrell, 2019]
Murrell, P. (2019). gridGeometry: Polygon Geometry in grid. R package version 0.1-0. [ bib ]
[Pedersen, 2018]
Pedersen, T. L. (2018). ggraph: An Implementation of Grammar of Graphics for Graphs and Networks. R package version 1.0.2. [ bib | http ]
[Pedersen, 2019]
Pedersen, T. L. (2019). ggforce: Accelerating 'ggplot2'. R package version 0.2.0. [ bib | http ]
[R Core Team, 2018]
R Core Team (2018). R: A Language and Environment for Statistical Computing. R Foundation for Statistical Computing, Vienna, Austria. [ bib | http ]
[Sarkar, 2008]
Sarkar, D. (2008). Lattice: Multivariate Data Visualization with R. Springer, New York. ISBN 978-0-387-75968-5. [ bib | http ]
[Turner, 2019]
Turner, R. (2019). deldir: Delaunay Triangulation and Dirichlet (Voronoi) Tessellation. R package version 0.1-16. [ bib | http ]
[Urbanek, 2014]
Urbanek, S. (2014). jpeg: Read and write JPEG images. R package version 0.1-8. [ bib | http ]
[Wickham, 2015]
Wickham, H. (2015). ggplot2movies: Movies Data. R package version 0.0.1. [ bib | http ]
[Wickham, 2016]
Wickham, H. (2016). ggplot2: Elegant Graphics for Data Analysis. Springer-Verlag New York. [ bib | http ]

Creative Commons License
This document by Paul Murrell is licensed under a Creative Commons Attribution 4.0 International License.