Constructive Geometry for Complex Grobs

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

Version 1: cat(format(Sys.Date(), "%A %d %B %Y"))

opts_chunk$set(comment=" ", tidy=FALSE, dev="png", ## dev="svg", dpi=96) options(width=100) ## For wonky desktop set up options(bitmapType="cairo") library(grid)

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


This document describes developments in the grobCoords() function in the R package 'grid', plus improvements to the 'gridGeometry' package that makes use of the grobCoords() function.

These features are available in R version 4.2.0 and in 'gridGeometry' version 0.3-0.

Introduction

The grobCoords() function (introduced in R version 3.6.0) generates a set of coordinates from a 'grid' grob. For example, the following code defines a 'grid' rectangle grob (and draws it) then calculates a set of coordinates from this rectangle grob (the vertices of the rectangle). The rectangle is centred 1 inch in from the left of the image and 1 inch up form the bottom of the image and is 1 inch square, so the vertices are at (0.5, 0.5), (0.5, 1.5), (1.5, 1.5), and (1.5, 0.5).

library(grid) rectangle <- rectGrob(1, 1, 1, 1, default.units = "in", name = "r") grid.draw(rectangle)

coords <- grobCoords(rectangle, closed = TRUE) coords

The 'gridGeometry' package () combines 'grid' grobs using operators like "union" and "intersection". For example, in the following code we define a triangle shape (and draw it) then we call grid.polyclip() from 'gridGeometry' to draw the union of the rectangle from above with this triangle.

library(gridGeometry) triangle <- polygonGrob(c(1, 2, 2), c(1, 1.5, .5), default.units="in") grid.draw(triangle) grid.polyclip(rectangle, triangle, "union")

The 'gridGeometry' package works by getting coordinates for each grob, via grobCoords(), and combining those coordinates using the 'polyclip' package (). Any changes to the grobCoords() function require changes to the 'gridGeometry' package.

The examples above are straightforward because, in the first case, we calculated coordinates from a grob that draws a single shape, so the result was a single set of (x, y) coordinates. In the second case, we combined two grobs that both draw a single shape, so 'gridGeometry' only had to combine one shape with another.

This report explores scenarios that are more complex, where we want to use grobCoords() to obtain the coordinates for a grob that draws more than one shape and where we want to use 'gridGeometry' to combine grobs that each draw more than one shape.

Improving grobCoords()

Before we consider more complex scenarios, we need to take a closer look at the coordinates generated for a grob that draws a single shape (shown again below).

coords

We have a single set of (x, y) coordinates (four points representing the vertices of the rectangle), but we can also see that those coordinates belong to "shape 1" and that shape belongs to "grob r". The "r" comes from the name argument that we supplied in the original call to rectGrob().

This simple example demonstrates one of the changes to grobCoords() in R 4.2.0: the return value used to be just a list of lists with components x and y (a list of "xy-lists"), but now the return value is a "GridGrobCoords" object, with additional information (such as names), and a print method.

class(coords)

That additional information becomes more important when we work with a grob that draws more than one shape. For example, the following code defines a grob that describes two rectangles (and draws them) and then calculates the coordinates from that grob. The result shows coordinates for two shapes, both of which belong to "grob r2".

rectangles <- rectGrob(c(1, 2.5), 1, 1, 1, default.units = "in", name = "r2") grid.draw(rectangles) grobCoords(rectangles, closed = TRUE)

The following code demonstrates a different scenario. Here we define a path grob that consists of two rectangles, but the two rectangles together define a single shape; the inner rectangle creates a hole in the outer rectangle. There are two sets of (x, y) coordinates again, but this time they both belong to "shape 1". We can also see that the fill rule ("evenodd") has been recorded in the "GridGrobCoords" object.

x <- c(.5, .5, 1.5, 1.5, .75, .75, 1.25, 1.25) y <- c(.5, 1.5, 1.5, .5, .75, 1.25, 1.25, .75) path <- pathGrob(x, y, id = rep(1:2, each=4), rule = "evenodd", default.units = "in", name = "p", gp = gpar(fill = "grey")) grid.draw(path) grobCoords(path, closed = TRUE)

Of course, it is also possible to have a grob that describes multiple paths, each of which consists of multiple sets of coordinates, as shown below. In this case we have a path grob that describes two shapes, each of which consists of two rectangles, with an inner rectangle that creates a hole in an outer rectangle. There are four sets of coordinates corresponding to the four rectangles, but two sets of coordinates belong to shape 1 and two sets of coordinates belong to shape 2.

paths <- pathGrob(c(x, x + 1.5), c(y, y), id = rep(rep(1:2, each=4), 2), pathId = rep(1:2, each=8), rule = "evenodd", default.units = "in", name = "p2", gp = gpar(fill = "grey")) grid.draw(paths) grobCoords(paths, closed = TRUE)

We can add another level of complexity by considering 'grid' gTrees, which are collections of grobs. For example, the following code defines a gTree consisting of a rectangle and a path, draws the gTree, and calculates its coordinates. The result has an extra level: one set of coordinates belongs to a "shape 1" that belongs to "grob r", two other sets of coordinates belong to a "shape 1" that belongs to "grob p3", and both "grob r" and "grob p3" belong to "gTree parent".

path2 <- pathGrob(x + 1.5, y, id = rep(1:2, each=4), rule = "evenodd", default.units = "in", name = "p3", gp = gpar(fill = "grey")) gt <- gTree(children=gList(rectangle, path2), name = "parent") grid.draw(gt) grobCoords(gt, closed = TRUE)

We could keep going and add further layers, because the children of a gTree can themselves be gTrees, but hopefully it is now clear how that would go. We will turn instead to an example of how the coordinates that grobCoords() returns can be used, by looking at the latest changes to the 'gridGeometry' package.

Improving 'gridGeometry'

As we saw at the start, the 'gridGeometry' package can be used to combine grobs. The following code shows another simple example where we create a new shape by subtracting a circle from a rectangle. We first draw the two grobs separately to show what they look like and then we draw the result of the rectangle "minus" the circle. We fill the result with grey to show that the circle has punched a hole in the rectangle.

circle <- circleGrob(1, 1, r = .3, default.units = "in", gp=gpar(fill = NA)) grid.draw(rectangle) grid.draw(circle) grid.polyclip(rectangle, circle, "minus", gp=gpar(fill = "grey"))

The following code demonstrates a more complex scenario with a circle grob that draws three circles. We first draw the circles on top of the rectangle to show the shapes that we are dealing with.

circle <- circleGrob(c(.5, 1, 1.5), 1, r = .3, default.units = "in", gp=gpar(fill = NA)) grid.draw(rectangle) grid.draw(circle)

When we subtract this circle grob from the rectangle grob, the result is obtained by first "reducing" the three circles to a single shape and then subtracting that result from the rectangle. The result in this case is the rectangle with the three circles removed from it. More accurately, the result is the rectangle with the union of the three circles subtracted from it.

grid.polyclip(rectangle, circle, "minus", gp=gpar(fill = "grey"))

When we give grid.polyclip() a grob that draws multiple shapes, the grob is first reduced to a single shape before being combined with the other argument. By default, this reduction occurs by using polyclip() to combine the shapes using a "union" operator. In the example above, the three circles are reduced via "union" to the single shape below.

grid.reduce(circle, "union")

There are two new arguments to grid.polyclip(), reduceA and reduceB, which can be used to control how multiple shapes are reduced to a single shape. For example, the following code reduces the three circles using "xor" before subtracting them from the rectangle.

grid.polyclip(rectangle, circle, "minus", reduceB = "xor", gp=gpar(fill = "grey"))

There is also a new function, grid.reduce(), that just performs the grob reduction. This function takes a grob and reduces it to a new grob that describes a single shape (either a path or a line). For example, the following code reduces the grob that draws three circles into a single shape using "xor". The grey filled area below is the shape that was subtracted from the rectangle to produce the grid.polyclip() result above.

grid.reduce(circle, "xor", gp=gpar(fill="grey"))

In the more complex example above, only the second argument to grid.polyclip() was a grob that draws more than one shape. It is also possible for the first argument to grid.polyclip() to be a grob that draws more than one shape and it is possible for either or both arguments to be gTrees. In the case of gTrees, each child grob is reduced and then all of the reduced children are reduced together.

The following example provides a demonstration of collapsing a more complex grob. For this example, we will work with the SVG version of the R logo (shown below).

The R logo

The following code uses the 'grImport2' package () to import the R logo into a 'grid' gTree. We create a gTree that will just draw the outlines of the imported logo and fill it with a semitrasparent grey. The 'rsvg' package () is used to convert the original SVG logo into a Cairo graphics version in preparation for import.

library(rsvg) rsvg_svg("Rlogo.svg", "Rlogo-cairo.svg") library(grImport2) Rlogo <- readPicture("Rlogo-cairo.svg") semigrey <- rgb(.5, .5, .5, .5) logoGrob <- pictureGrob(Rlogo, gpFUN = function(gp) gpar(col="black", fill=semigrey))

The resulting gTree contains another gTree, which contains two further gTrees ("picComplexPath" gTrees), each of which contains two grobs (a "picPath" grob and a "picPolyline" grob).

grid.ls(logoGrob)

Drawing the logo shows that there are two main shapes in the logo, the "R" itself and the ellipse that encircles the top of the "R" and that both of those shapes consist of two curves with the inner curve creating a hole in the outer curve.

grid.draw(logoGrob)

The following code calls grid.reduce() to convert that complicated gTree to a single shape (using the default "union" operator). This forms the union of the "R" shape with the ellipse shape.

grid.reduce(logoGrob, gp=gpar(fill="grey"))

The following code "xor"s the R logo gTree with the rectangle that we used in previous examples. This implicitly performs the reduction of the R logo (that we just did explicitly) before subtracting it from the rectangle.

grid.polyclip(rectangle, logoGrob, "xor", gp=gpar(fill="grey"))

Combining "open" shapes

All of the examples so far have involved "closed" shapes - shapes that have an interior that can be filled, like polygons and paths. It is also possible for the A argument to grid.polyclip() to be an "open" shape, like a line segment or a Bezier curve.

The following code demonstrates a simple example where we subtract a single circle from a single line segment. As before, we first draw both shapes separately and then we draw the result of grid.polyclip().

line <- segmentsGrob() circle <- circleGrob(1, 1, r = .3, default.units = "in", gp=gpar(fill = NA)) grid.draw(circle) grid.draw(line) grid.polyclip(line, circle, "minus")

The next example shows what happens when we have multiple shapes from an open grob. In this case we have a segments grob that draws two lines that criss-cross each other and we are subtracting a single circle.

lines <- segmentsGrob(0:1, 0, 1:0, 1) circle <- circleGrob(1, 1, r = .3, default.units = "in", gp=gpar(fill = NA)) grid.draw(circle) grid.draw(lines) grid.polyclip(lines, circle, "minus")

Although that result may be what we expected, it hides a detail about how the A argument is reduced. This result does not come from reducing the two line segments with a "union" operator (the default that we saw happening for the B argument in previous examples). If we try to take the union of two open shapes, the result is empty, as shown below.

grid.reduce(lines, "union")

When the A argument is open, by default, it is reduced using the "flatten" operator rather than the "union" operator, which is the default for closed shapes. The "flatten" operator just combines all of the open shapes into a single grob.

grid.reduce(lines)

The "flatten" operator can also be used for the B argument and for open shapes. The following code provides a simple demonstration. The segments grob is the same as in the last example, but we subtract a circle grob that draws two circles. We specify reduceB = "flatten" so that the two circles are reduced to a list of two sets of coordinates (one for each circle) and we specify fillB = "evenodd" so that the flattened circles are interpreted using an even-odd fill rule (so the inner circle creates a hole in the outer circle). The result is that we subtract a donut from the two line segments.

lines <- segmentsGrob(0:1, 0, 1:0, 1) circle <- circleGrob(1, 1, r = c(.3, .1), default.units = "in", gp=gpar(fill = NA)) grid.draw(circle) grid.draw(lines) grid.polyclip(lines, circle, "minus", reduceB = "flatten", fillB = "evenodd")

The trim() function

The 'gridGeometry' package also has a trim() function for extracting subsets of open shapes. For example, the following code extracts a subset of a line segment starting from .2 of the distance along the segment at ending at halfway along the line segment. The original line is drawn in grey and the subset is drawn in (thick) black.

line <- segmentsGrob(.2 ,.2, .8, .8, gp=gpar(lwd=2, col="grey")) grid.draw(line) grid.trim(line, .2, .5, gp=gpar(lwd=5))

The grid.trim() function has also been updated to handle grobs that draw more than one shape: all shapes are trimmed using the same set of from and to arguments. For example, the following code trims a segments grob that draws two line segments, using the same from and to as in the previous example.

lines <- segmentsGrob(c(.2, .4), .2, c(.6, .8), .8, gp=gpar(lwd=2, col="grey")) grid.draw(lines) grid.trim(lines, .2, .5, gp=gpar(lwd=5))

The following code shows a more complex example where we trim a gTree that has the segments grob as its child, plus a circle grob, plus another gTree that has two lines grobs as its children. Again, all children are trimmed using the same set of from and to arguments. This also shows that closed shapes, like the circle, produce no output when trimmed. Only the open shapes are include in the result.

gt <- gTree(children=gList(lines, circleGrob(), gTree(children=gList(linesGrob(c(.2, .2, .4), c(.6, .8, .8)), linesGrob(c(.6, .8, .8), c(.2, .2, .4))))), gp=gpar(lwd=2, col="grey", fill=NA)) grid.draw(gt) grid.trim(gt, .2, .5, gp=gpar(lwd=5))

More information about the changes to grobCoords()

The main change to the 'grid' functions grobCoords() and grobPoints() is that they now return more complex values. This section describes the format of those data structures for anyone who wants to write code that either generates or consumes these new values.

There are three new classes of object:

The reason for introducing these more complex structures is that more information about the original grob or gTree is retained in the grobCoords() result. This makes it possible to identify which coordinates correspond to which shape within the grob or gTree. The mentions one example where this used within 'grid' itself (to resolve pattern fills).

More information about the changes to 'gridGeometry'

The 'gridGeometry' package provides an interface between the 'grid' package and the 'polyclip' package. This requires converting from a 'grid' grob to a list of xy-lists that the 'polyclip' package can work with (and back again).

In R versions prior to 4.2.0, the grobCoords() function generated a list of xy-lists as its output, so the result from grobCoords() could be fed directly to functions in the 'polyclip' package. From R 4.2.0, the result from grobCoords() is more complex, so 'gridGeometry' has some additional functions to to convert grobCoords() output to a list of xy-lists.

The grid.polyclip() function and the grid.reduce() function accept 'grid' grobs and return 'grid' grobs. This is the simplest user interface.

The xyListFromGrob() function converts a grob into a list of xy-lists. It first converts a grob to a "GridGrobCoords" (or "GridGTreeCoords") object using grobCoords(). The "GridGrobCoords" (or "GridGTreeCoords") object is then reduced to a list of xy-lists either by "flatten"ing all of the "GridCoords" from the grob to a single list or by combining shapes (one or more "GridCoords" from the same grob) using polyclip::polyclip() and the operator specified in reduceA or reduceB. A gTree reduces each of its children and then combines the reduced children together. This function provides a to enter the 'polyclip' world of lists of xy-lists, starting from a 'grid' grob.

The functions xyListToPath(), xyListtoPolygon(), and xyListToLine() convert back from a list of xy-lists to a grob. There may be more than one xy-list, in which case, xyListToPolygon() creates a grob that draws a separate polygon for each xy-list, xyListToPath() creates a grob that draws a single path (using rule to determine the interior of the path), and xyListToLine() creates a grob that draws a separate line for each xy-list. These functions allow the user to return from the 'polyclip' world back to the world of 'grid' grobs.

The polyclip() function takes a list of xy-lists and returns a list of xy-lists. This allows the user to perform calculations in the 'polyclip' world. For example, we can use xyListFromGrob() to generate coordinates from a closed 'grid' grob, but then work with them as if they are coordinates from an open shape.

Fill rules

When a list of xy-lists is fed to polyclip::polyclip(), a fill rule is specified to determine the interior of the shape that is described by the list of xy-lists. When we convert a grob to a list of xy-lists, the fill rule may be included in the grobCoords() result (e.g., if we are converting a path grob), but it may not. The fill rule that gets sent to polyclip::polyclip() is determined as follows: if the user specifies fillA or fillB explicitly, that fill rule is used; otherwise, if the grobCoords() result contains a fill rule, that is used; otherwise the fill rule is "nonzero" (the 'polyclip' way of saying "winding"). Note that this is different from the default of "evenodd" that polyclip::polyclip() itself uses.

Combinations of open and closed shapes

The grobCoords() function has a closed argument to indicate whether we want the coordinates of a closed shape or an open shape. From R 4.3.0 or from 'gridGeometry' 0.3-1, where it can be determined that the grob is open, closed defaults to FALSE. Otherwise, closed defaults to TRUE. Prior to that, the closed argument must be specified explicitly.

When we ask for the coordinates from a polygon grob, we get the coordinates of the polygon if closed=TRUE, but we get nothing ("empty" coordinates) if closed=FALSE. Similarly, if we ask for the coordinates from a line grob, we get the coordinates of the line if closed=FALSE, but we get nothing if closed=TRUE. A gTree presents a problem because it can contain grobs that draw both open and closed shapes.

The grid.polyclip() function handles this problem by generating open coordinates for A and combining them with B and then also generating closed coordinates for A and combining them with B. The final result is then a gTree that combines the open result and the closed result.

The following code shows an example where A is a gTree consisting of a line and a rectangle and B is a circle grob (and the operator is "minus"). The result is a combination of the (closed) rectangle minus the (closed) circle and the (open) line minus the (closed) circle.

grid.polyclip(gTree(children=gList(rectGrob(width=.5, height=.5), segmentsGrob(0, .5, 1, .5))), circleGrob(r=.2), "minus", gp=gpar(fill="grey"))

Note that B cannot be open, a limitation imposed by the underlying Clipper library (). On the other hand, the Clipper library does allow A to be a combination of open and closed shapes (though the semantics of that can be tortuous), whereas 'gridGeometry' only ever calls polyclip::polyclip() with either A entirely closed or A entirely open.

To reduce or not to reduce

By default, any grob (including gTrees) that draws more than one shape will be reduced. When closed=TRUE, the result will be a single shape based on the union of the multiple shapes. The 'polyclip' package (and the Clipper library) will accept a list of xy-lists, i.e., multiple shapes, so should we always reduce multiple shapes to a single shape? By default, we do always reduce, but the user has the option of specifying op="flatten", which will result in sending multiple shapes to 'polyclip'.

Discussion

The main idea behind the changes to grobCoords is to provide more detailed and comprehensive information about the coordinates for a 'grid' grob. We want to retain information about where the sets of coordinates came from through both a hierarchical structure and labelling of components within that structure.

The 'gridGeometry' package makes some use of that extra information, e.g., to determine the fill rule that it sends to 'polyclip' functions.

The grobCoords() function is also used by 'grid' itself for resolving fill patterns (), by making use of the names on grobCoords() output. In that case, the extra information is important because it allows us to resolve a pattern relative to individual shapes within a grob as well as relative to the bounding box around all shapes within a grob.

It is also hoped that the extra information provided by grobCoords() may prove useful to code writers and package developers that make use of grobCoords() output. One speculative application is for graphics device packages that do not natively support some of the new graphics engine features, like affine transformations, to add support for some features by working with grob coordinates. For example, a transformed circle could be produce by calculating the coordinates of the original circle, transforming the coordinates, and drawing the transformed coordinates as a polygon.

Technical requirements

The examples and discussion in this report relate to R version 4.2.0 and 'gridGeometry' version 0.3-1.

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

Resources

How to cite this report

Murrell, P. (2022). "Constructive Geometry for Complex Grobs" Technical Report 2022-02, Department of Statistics, The University of Auckland. Version 1. [ bib | DOI | http ]

References


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