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.
The concept map
| 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 |
Things grid has no equivalent for
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.
Side by side
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.
What is different, and why
The builder is functional, not stateful
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.
Metrics are eager, so there is no draw-time hook protocol
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.
Units are records, not expression trees
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.
The tree is a value, and it can report geometry
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 cascade
vl_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.
Do I have to rewrite my grid code?
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. ```
