--- title: "The scene graph and the paint model" output: rmarkdown::html_vignette vignette: > %\VignetteIndexEntry{The scene graph and the paint model} %\VignetteEngine{knitr::rmarkdown} %\VignetteEncoding{UTF-8} --- ```{r} #| include: false knitr::opts_chunk$set( collapse = TRUE, comment = "#>", fig.width = 6, fig.height = 2.6, dpi = 96, dev = "png" ) library(vellum) ``` This article covers the two pieces you spend most of your time with in vellum: the **scene graph** (units, viewports, and the tree they form) and the **paint model** (gradients, patterns, and masks) shared across every backend. ## The scene graph A vellum scene is a tree. The root is the page created by `vl_scene()`; every `push()` adds a `vl_viewport()` child and descends into it; every `draw()` appends a grob at the current level; `pop()` climbs back up. The tree is retained rather than drawn-and-forgotten, which is what enables the queries in `vignette("retained-mode")`. Because it is a tree, viewports nest, and a child's geometry is expressed relative to its parent. That is the whole mechanism behind panels, insets, and faceting: push a viewport for a sub-region, draw inside it in local coordinates, then pop. ```{r} #| label: nesting #| fig.width: 6 #| fig.height: 2.4 vl_scene(6, 2.4, bg = "white") |> # a full-width band draw(rect_grob(height = 0.6, gp = vl_gpar(fill = "#eef2f6", col = NA))) |> # an inset viewport occupying the middle third push(vl_viewport(x = 0.5, width = 1 / 3, height = 0.8)) |> draw(rect_grob(gp = vl_gpar(fill = "#3a7bd5", col = NA))) |> draw(text_grob("inset", gp = vl_gpar(col = "white", fontface = "bold"))) |> pop() ``` ## Units Coordinates and sizes are `vl_unit()` vectors: a value paired with a unit name. Each element carries its own unit, so one vector can mix coordinate systems, and a grob can even use different units on its x and y axes. The units you reach for most: - `"npc"` (the default): normalised parent coordinates, `0` at bottom/left and `1` at top/right of the current viewport. - `"native"`: the enclosing viewport's `xscale` / `yscale`, so data values map directly. This is what you use for plotted data. - `"mm"`, `"cm"`, `"in"`, `"pt"`: absolute physical lengths that keep their size regardless of the viewport. A bare number is interpreted in the grob's default units (usually `"npc"`), so `x = 0.5` and `x = vl_unit(0.5, "npc")` are the same thing. ```{r} #| label: units vl_unit(1:3, "native") vl_unit(c(0.5, 1), c("npc", "in")) ``` `"native"` units need a viewport with scales to resolve against. Set `xscale` and `yscale` when you push: ```{r} #| label: native #| fig.width: 5 #| fig.height: 3 vl_scene(5, 3, bg = "white") |> push(vl_viewport( width = 0.86, height = 0.82, xscale = c(0, 10), yscale = c(-5, 25) )) |> 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() ``` Absolute and relative units compose within a viewport, and font- or string-relative units (`"char"`, `"line"`, `"strwidth"`) resolve to millimetres at construction. Mixing a relative and an absolute unit in a single arithmetic expression (say `vl_unit(1, "npc") - vl_unit(2, "mm")`) is deferred and reported rather than silently guessed, so an ambiguous offset fails loudly instead of drawing in the wrong place. ## The paint model Any `fill` in `vl_gpar()` can be more than a flat colour. The same three paint types work identically on the raster, SVG, and PDF backends (with the documented exception that the PDF backend does not yet rasterise patterns). ### Gradients `linear_gradient()` and `radial_gradient()` interpolate between colour stops. Their geometry is given in a coordinate system (`"npc"` by default) and is resolved against the viewport at draw time, so a gradient transforms with its grob just like the outline does. ```{r} #| label: gradients #| fig.width: 6 #| fig.height: 2.2 vl_scene(6, 2.2, bg = "white") |> push(vl_viewport(x = 0.28, width = 0.44)) |> draw(rect_grob( width = 0.8, height = 0.7, gp = vl_gpar(fill = linear_gradient(c("#1b2a4a", "#3a7bd5")), col = NA) )) |> pop() |> push(vl_viewport(x = 0.72, width = 0.44)) |> draw(circle_grob( r = 0.34, gp = vl_gpar(fill = radial_gradient(c("#f6d365", "#fda085")), col = NA) )) |> pop() ``` ### Patterns `vl_pattern()` fills a shape by tiling a grob (or a list of grobs). The tile is authored in the unit square and repeated across a cell whose size you choose. ```{r} #| label: pattern #| fig.width: 4 #| fig.height: 2.4 tile <- list( rect_grob(gp = vl_gpar(fill = "#ecf0f1", col = NA)), circle_grob(r = 0.32, gp = vl_gpar(fill = "#e74c3c", col = NA)) ) vl_scene(4, 2.4, bg = "white") |> draw(rect_grob( width = 0.84, height = 0.84, gp = vl_gpar(fill = vl_pattern(tile, width = 0.18, height = 0.3), col = NA) )) ``` ### Masks and group opacity A mask is a grob whose coverage modulates the visibility of a viewport's contents. Wrap it with `as_mask()` and pass it to `vl_viewport(mask = ...)`. Here a linear gradient is clipped to a circular alpha mask. ```{r} #| label: mask #| fig.width: 4 #| fig.height: 2.4 vl_scene(4, 2.4, bg = "white") |> push(vl_viewport( mask = as_mask(circle_grob(r = 0.42, gp = vl_gpar(fill = "white", col = NA))) )) |> draw(rect_grob(gp = vl_gpar(fill = linear_gradient(c("#7f53ac", "#647dee")), col = NA))) |> pop() ``` Related to masks is **group opacity**. Setting `vl_viewport(alpha = ...)` composites the viewport's contents as a single isolated layer at that opacity, so overlapping elements do not accumulate the way per-element `vl_gpar(alpha = )` would. That distinction (compositing a group versus fading each mark) is exactly the kind of control a grammar layer needs from its backend. ## Recap - A scene is a retained tree of nested viewports and grobs, built with `push()` / `draw()` / `pop()`. - `vl_unit()` vectors express geometry; `"npc"` is relative to the viewport, `"native"` follows the data scales, and `"mm"` and friends are absolute. - `vl_gpar(fill = )` accepts gradients and patterns, and `vl_viewport()` accepts masks, group opacity, and blend modes, all consistent across backends. Next, see `vignette("retained-mode")` for what the retained tree lets you do after it is built. ```