by Paul Murrell http://orcid.org/0000-0002-3224-8858
Version 1: Tuesday 31 May 2022
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.
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
grob r shape 1 x: 0.5 0.5 1.5 ... [4 values] y: 0.5 1.5 1.5 ... [4 values]
The 'gridGeometry' package (Murrell, 2022a)
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 (Johnson and Baddeley, 2019).
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.
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
grob r shape 1 x: 0.5 0.5 1.5 ... [4 values] y: 0.5 1.5 1.5 ... [4 values]
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)
[1] "GridGrobCoords"
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)
grob r2 shape 1 x: 0.5 0.5 1.5 ... [4 values] y: 0.5 1.5 1.5 ... [4 values] shape 2 x: 2 2 3 ... [4 values] y: 0.5 1.5 1.5 ... [4 values]
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)
grob p (fill: evenodd) shape 1 x: 0.5 0.5 1.5 ... [4 values] y: 0.5 1.5 1.5 ... [4 values] shape 1 x: 0.75 0.75 1.25 ... [4 values] y: 0.75 1.25 1.25 ... [4 values]
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)
grob p2 (fill: evenodd) shape 1 x: 0.5 0.5 1.5 ... [4 values] y: 0.5 1.5 1.5 ... [4 values] shape 1 x: 0.75 0.75 1.25 ... [4 values] y: 0.75 1.25 1.25 ... [4 values] shape 2 x: 2 2 3 ... [4 values] y: 0.5 1.5 1.5 ... [4 values] shape 2 x: 2.25 2.25 2.75 ... [4 values] y: 0.75 1.25 1.25 ... [4 values]
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)
gTree parent grob r shape 1 x: 0.5 0.5 1.5 ... [4 values] y: 0.5 1.5 1.5 ... [4 values] grob p3 (fill: evenodd) shape 1 x: 2 2 3 ... [4 values] y: 0.5 1.5 1.5 ... [4 values] shape 1 x: 2.25 2.25 2.75 ... [4 values] y: 0.75 1.25 1.25 ... [4 values]
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.
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.
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 following code uses the 'grImport2' package (Potter and Murrell, 2019) 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 (Ooms, 2022) 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)
import.1.GRID.gTree.35 GRID.gTree.34 GRID.picComplexPath.28 GRID.picPath.29 GRID.picPolyline.30 GRID.picComplexPath.31 GRID.picPath.32 GRID.picPolyline.33
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"))
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")
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))
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:
"GridCoords" is a list with numeric components x
and y
.
This represents a set of coordinates that describe a simple shape or part of a more complex shape.
A "GridCoords" object can be generated with the
gridCoords()
function, which takes x
and y
as arguments.
gc <- gridCoords(x=1:4, y=4:1) gc
x: 1 2 3 ... [4 values] y: 4 3 2 ... [4 values]
"GridGrobCoords" is a list of one or more "GridCoords".
This represents the shapes that are described by a 'grid' grob.
The list can have names to indicate which "GridCoords" belong
to the same shape (e.g., a single path consisting of two concentric
circles).
The list also has a "name"
attribute and
may have a rule
attribute.
A "GridGrobCoords" object can be generated with the
gridGrobCoords()
function, which takes a
list of "GridCoords" and a name
argument
and an optional rule
argument.
ggc <- gridGrobCoords(list("1"=gc), name="A") ggc
grob A shape 1 x: 1 2 3 ... [4 values] y: 4 3 2 ... [4 values]
"GridGTreeCoords" is a list of one or more "GridGrobCoords" or "GridGTreeCoords".
This represents the shapes that are described by a 'grid' gTree.
The list has a "name"
attribute.
A "GridGTreeCoords" object can be generated with the
GridGTreeCoords()
function, which takes a
list of "GridGrobCoords" or "GridGTreeCoords" objects
and a name
argument.
ggtc <- gridGTreeCoords(list(ggc), name="B") ggtc
gTree B grob A shape 1 x: 1 2 3 ... [4 values] y: 4 3 2 ... [4 values]
ggtc2 <- gridGTreeCoords(list(ggtc, ggc), name="C") ggtc2
gTree C gTree B grob A shape 1 x: 1 2 3 ... [4 values] y: 4 3 2 ... [4 values] grob A shape 1 x: 1 2 3 ... [4 values] y: 4 3 2 ... [4 values]
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 Discussion
mentions one example where this used within 'grid' itself
(to resolve pattern fills).
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.
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.
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 (Johnson, 2019).
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.
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'.
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
(Murrell, 2022b),
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.
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).
Murrell, P. (2022). "Constructive Geometry for Complex Grobs" Technical Report 2022-02, Department of Statistics, The University of Auckland. Version 1. [ bib | DOI | http ]
This document
by Paul
Murrell is licensed under a Creative
Commons Attribution 4.0 International License.