Skip to contents

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 60mm

The 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. ```