vellum sits at grid’s level of the stack: units, viewports, grobs, layout, and rendering. If you know grid, most concepts carry over directly. This guide maps the vocabulary and then flags the handful of places where vellum works differently on purpose.
| grid | vellum | notes |
|---|---|---|
grid.newpage() |
vl_scene() |
vellum’s scene also fixes page size, background, and dpi up front |
unit(1, "native") |
vl_unit(1, "native") |
same idea; each element carries its own unit |
viewport() |
vl_viewport() |
region with its own xscale / yscale |
pushViewport() / popViewport() |
push() / pop() |
functional: they take and return a scene, no global stack |
grid.rect(), grid.circle(), … |
rect_grob(), circle_grob(), … plus
draw() |
the constructor builds a value; draw() adds it |
rectGrob(), gTree() |
rect_grob(), the scene tree |
grobs are immutable S7 values |
gpar() |
vl_gpar() |
familiar fields (including cex); fill also
accepts gradients |
grid.layout() |
grid_layout() |
flexible "null" tracks work the same |
grid.ls() / grid.get() |
node_names() / get_node() |
list and read nodes by name |
grid.edit() / editGrob() |
edit_node() |
editGrob() also copies; grid.edit()
mutates the display list, edit_node() never does |
grid.grabExpr() / display list |
the retained scene | the tree is the model, not something you recover from a device |
grid.locator() |
hit_test() |
picking from a pick-buffer, not one interactive click |
| (no counterpart) | scene_model() |
per-element identity plus resolved device-pixel bbox |
| (no counterpart) | vl_lint() |
static analysis: off-canvas marks, illegible text, low contrast |
convertWidth() / convertX() |
vl_convert() |
one function; axis picks the extent, what
a length vs a position |
device (png(), pdf(), …) |
render(scene, path) |
the extension picks the backend |
A few vl_gpar() fields exist because vellum controls the
font stack and the rasterizer directly, so there is nothing to map them
onto in grid:
| field | what it does |
|---|---|
halo_col / halo_width |
stroke the glyph outlines under the fill, so a label survives a busy
background (see vignette("typography")) |
features |
OpenType feature tags — tabular figures, small caps, ligature and kerning control |
crisp |
snap axis-parallel strokes to the pixel grid, so gridlines are solid rather than two grey rows |
antialias |
hard pixel edges, for pixel art and seamless heatmap cells |
Packages built on grid emulate the first of these by drawing the label eight times at small offsets; the rest have no workaround at all.
A minimal grid plot and its vellum translation. In grid:
library(grid)
grid.newpage()
pushViewport(viewport(width = 0.8, height = 0.8,
xscale = c(0, 10), yscale = c(0, 20)))
grid.rect(gp = gpar(fill = "grey97", col = "grey70"))
grid.lines(x = unit(0:10, "native"), y = unit((0:10) * 2, "native"),
gp = gpar(col = "steelblue", lwd = 2))
popViewport()The same scene in vellum:
vl_scene(5, 3, bg = "white") |>
push(vl_viewport(width = 0.8, height = 0.8,
xscale = c(0, 10), yscale = c(0, 20))) |>
draw(rect_grob(gp = vl_gpar(fill = "grey97", col = "grey70"))) |>
draw(lines_grob(x = vl_unit(0:10, "native"), y = vl_unit((0:10) * 2, "native"),
gp = vl_gpar(col = "steelblue", lwd = 2))) |>
pop()The shapes are the same; the difference is that the vellum version is one expression that returns a scene value, with no global device or viewport stack mutated along the way.
grid keeps a global viewport stack and a display list:
pushViewport() mutates state, and each grid.*
call paints into the current device. vellum’s push(),
draw(), and pop() each take a scene and return
a new one. There is no “current viewport” to lose track of, the pipe
is the tree, and a scene is an ordinary value you can store,
pass around, and branch from.
grid cannot know a grob’s size until a device and viewport exist at
draw time, which is why it has lazy units and the
makeContext / makeContent /
widthDetails hook protocol, and why it replays the whole
display list on resize. vellum resolves text and object metrics in
process, up front, so a grob knows its extent when you build it. You
measure with vl_strwidth() or size a unit by a grob’s
extent with grobwidth() / grobheight()
immediately, without an open device.
A grid unit defers its arithmetic:
unit(1, "npc") - unit(2, "mm") is stored as
sum(1npc, -2mm) and evaluated when a device and viewport
exist. A vellum unit is a flat record — a value, a base code, and an
absolute millimetre offset — so arithmetic reduces at construction
wherever it can.
vl_unit(10, "mm") + vl_unit(1, "in") # absolute + absolute -> mm
#> <vellum_unit[1]>
#> [1] 35.4mm
vl_unit(1, "native") + vl_unit(2, "mm") # a compound unit: base + offset
#> <vellum_unit[1]>
#> [1] 1native+2mm
vl_unit(1:3, "cm") * 2 # scaling is fine
#> <vellum_unit[3]>
#> [1] 20mm 40mm 60mmThe third form is the one worth knowing. A position base plus an absolute is kept as a compound unit that resolves as “the data (or npc) position, displaced by exactly this many millimetres” at any scale, aspect, or dpi — which is the case most grid code that mixes units actually wants.
The one thing grid expresses and vellum does not is a mix of two
different position bases ("npc" plus
"native"), because that cannot reduce to a single record
without a device. It errors, with a message naming the legal forms. Put
the normalised part in the viewport and the data part in the coordinate,
or pre-resolve to absolute units.
grid retains its scene as well, so this is a difference of degree for
the inspect-and-edit half: grid.ls(),
grid.get(), grid.edit() and
editGrob() already let you rewrite a plot after the fact,
and node_names(), get_node() and
edit_node() are the counterparts here. The difference is
that vellum’s tree is an immutable value rather than device state, so
there is no grid.force() step and no in-place mutation of a
display list.
The half with no grid counterpart is geometry read-back:
hit_test() picks the topmost node under a point, and
scene_model() returns every drawn element with its resolved
device-pixel bounding box. See vignette("retained-mode")
for what that enables.
vl_gpar inherits, but there is no cascadevl_gpar() inheritance works as in grid: a field left
NULL is inherited from the enclosing viewport, and
alpha multiplies down the tree. vellum does not add a
CSS-like cascade with selectors or theme objects at this layer; that is
a grammar-layer concern.
Not necessarily. If you already have grid, ggplot2, or lattice
output, you can render it through vellum’s backend without
porting anything, using as_vellum() and
render_grid(). See vignette("grid-interop").
Rewriting in the native API is worth it when you want the retained-tree
features (naming, editing, hit-testing) or deterministic multi-backend
output. ```