Fix: a mitre or bevel join on a strongly tapered stroke left a pinhole at the apex. With a peaked width profile -- a wide corner between narrow neighbours -- a small unfilled island survived the ribbon's self-union right at the join, reading as a speck of dust on the corner of a thick tapered line (about 4 px in a 62 px stroke). Round joins and constant widths were never affected.
Each segment's sweep ends on a tangent chord of the vertex's disc, and when the width varies that chord is tilted -- displaced from the vertex along the centreline in proportion to the width gradient. Between the two chords lies a lens containing the vertex that neither sweep covers. The convex side was already stitched; the concave side relied on the two inner edges crossing, which they only do when the width is near constant. That lens is now filled explicitly, and the wedge's reach-back takes the chord tilt as a floor rather than scaling off the vertex half-width alone.
Affects both entry points, since they share the generator: stroke_to_path()'s
outlines and the new lines_grob(lwd_profile =). A constant-width stroke is
byte-for-byte unchanged -- the new fill is skipped when the chords are not
tilted -- so the only output that moves is a tapered mitre/bevel join, which is
the bug.
lines_grob() takes a lwd_profile: a stroke whose width varies along the
line, resolved at render. stroke_to_path(lwd_profile =) in 0.6.9 solved the
geometry of a variable-width stroke but bakes it at a page size you supply and
hands back absolute mm. That is right for a shape, and no use to anything whose
rect is measured later -- a mark heading into a panel laid out after axes,
strips and legends. This carries the profile into the scene instead and builds
the ribbon inside the grob's viewport at render.
lines_grob(x, y, gp = vl_gpar(col = "steelblue", lwd = 12),
lwd_profile = c(1, 1.6, 0.8, 0))
One multiplier per vertex, scaling the lwd that is resolved at render --
so an lwd inherited from an enclosing viewport is what the profile multiplies,
which is why the vector crosses into the engine as multipliers rather than as
finished widths. Because nothing is baked, the same grob tapers correctly at any
figure size: doubling the page doubles the ribbon's length and leaves its
physical width alone, and doubling the dpi scales both.
The geometry is the one that shipped with stroke_to_path() -- external common
tangents, so a taper reaches its point instead of waisting, and one non-zero
self-union to resolve tight bends and the inner side of each join. Nothing in
ribbon.rs changed; this is the plumbing that was missing.
A varying-width stroke is generated as an outline and filled, since no
backend can stroke one path at several widths -- so PNG, SVG and PDF all get
identical geometry and it degrades nowhere, unlike the group effects. Two
consequences worth knowing: the stroke colour paints the ribbon (a gradient
col ramps along it) while lty and dashing do not apply, and every ribbon is
unique geometry, so it opts out of the batching fast paths -- sized for the low
element counts this is for, not for a million points.
sketch and lwd_profile together are an error rather than a guess: jittering
a ribbon's outline gives a wobbly silhouette, not a wobbly pen, and those are
different pictures. Arc-length spreading (along = "arclength") stays with
stroke_to_path(), which has the resolved device px that measuring it needs.
A scene with no profile is byte-for-byte unchanged.
element_geometry() gains a ring column, so a multi-ring path's
boundaries survive the trip to R. A path element reports all its rings'
vertices concatenated, which on its own is lossy: a host had no way to tell
where one ring ended, and joining the whole run invents a phantom edge from
each ring's last vertex to the next ring's first. For a single-ring path (a
plain polygon, the common case) that was exact; for a polygon with a hole or a
multipart feature it was an approximation.
ring is 1-based within the element, so split() on it recovers exactly the
rings the engine measured against -- pick_table() had nper in hand and was
throwing it away at the point of emitting. Every other kind is a single ring,
so ring is 1 throughout and nothing else changes.
Worth knowing when reproducing the engine's own answer: a path's distance is
the minimum over its rings, each measured as filled, so inside any ring
counts as a hit and the path's fill rule does not enter into it -- a point
in a hole is at distance zero. That is the behaviour to match locally, and the
ring column is what makes matching it possible.
stroke_to_path() can vary the stroke width along the line. The new
lwd_profile argument takes a numeric vector of multipliers of lwd and
returns an outline whose width follows it: c(1, 0.2) is a ribbon tapering to
a fifth of its width, c(0, 1, 0) a leaf, c(1, 0, 1) a stroke pinched to
nothing in the middle. A zero is a legal width — tapering to a point is the
main thing people want this for — and only an all-zero profile is an error.
along chooses how the values are positioned. The default "arclength"
spreads them evenly along the drawn length of the stroke and interpolates, so
the profile is independent of how densely the polyline happens to be sampled
and any profile length works. along = "vertex" reads one value per vertex
instead, which is what you want when the widths come from data attached to the
vertices — a per-observation weight, a pressure trace from a tablet. The two
parameterisations coincide only when the vertices are evenly spaced, which is
why they are separate options rather than one argument that guesses from
length. On a closed polygon_grob() or path_grob() the profile is cyclic: a
ring has no first vertex a reader can see, so wrapping is the only reading
that does not put a width step wherever the input happened to start.
segments_grob(lwd = ) has varied width per element for some time, but it
strokes each segment separately, so the width steps and the joins show.
Variable-width stroke_to_path() is the higher-fidelity alternative: one
continuous filled outline with nothing to give away where the width changed.
The tradeoff is the one that has always applied here — an outline is baked at
one page size and comes back in absolute millimetres.
Two honest notes. The first is a correction to what the documentation used to
promise unconditionally: a plain expansion still uses tiny-skia's own stroker,
the one the rasterizer uses, so the outline is exactly the region that would
have been inked. A varying width has no such reference, because no rasterizer
can stroke a line at a changing width; vellum offsets the polyline itself in
that case, building the region a round nib of varying radius sweeps. The
second is that width varies linearly between vertices, so a taper is only as
smooth as the polyline under it — raise n on bezier_grob()/spline_grob(),
whose flattening happens before stroke_to_path() ever sees the curve.
With lwd_profile = NULL — every call written before this release — the
function takes exactly the code path it always took, and the outline is
byte-identical to 0.6.8, which a test and a PNG baseline both assert. A
constant profile such as lwd_profile = 1 deliberately does not make that
promise: it goes through the new offsetter, so it matches the old expansion as
a picture but not to the last bit.
The tween carries one eased fraction per property class, not one for the
whole scene. vl_render_animation() gains frac_col, frac_size and
frac_alpha alongside frac, each an optional per-frame schedule for one
class of drawn property. This is what lets a grammar layer offer per-aesthetic
easing — position arriving on a cubic curve while colour crossfades linearly.
frac drives coordinate geometry and every discrete attribute's halfway snap;
frac_col drives paints and per-element colour vectors; frac_size drives
lwd, marker size, radii and linemitre; frac_alpha drives opacity and
the enter/exit fade of keyed elements.
All three default to frac, and passing four identical schedules is
byte-identical to the previous single-fraction tween, so existing animations
are unchanged.
role = "presentation", "grid", …) but still
carried metadata such as a provenance id, the per-mark tagging path fired on
that metadata alone — suppressing the single whole-content span — and then, since
decorative nodes are artifacts rather than structure entries, the tagged PDF was
left with no Figure and no Alt at all. Decorative metadata no longer counts
as per-mark metadata: such a scene keeps its single Figure carrying the
describe() text, with the marks as its artifact content. This is what lets a
charting frontend tag its marks role = "presentation" to get one clean figure
description instead of a screen reader announcing every mark's internal id.Fix: invisible_fill and bleed fired on every plot a grammar layer
produces. Found by running the new rules against vellumplot's output, where
each of five ordinary plots collected one of each with no true positives among
them.
invisible_fill now exempts a mark that spans its whole viewport. Something
filling its entire container is a substrate rather than a mark — a theme's page
background, a panel backdrop — and painting the page in the page's own colour
is how you guarantee an opaque export, not an oversight. A mark that merely
sits inside the viewport is still reported.
bleed now exempts text, which is most of what the rule was reporting. A label
is routinely placed in a strip sized from an approximate metric and overhangs
it by a few pixels; measured across that corpus, every escape was an axis or
plot title clearing its strip by 4–8 px and none was a mark. A non-text mark
escaping an unclipped viewport is still reported.
The linter grew from 7 rules to 20, and from something you read to something you
can gate a build on. vignette("inspecting-scenes") covers all of it.
Thirteen new rules. The one worth having first is truncated: offscreen
and clipped_away both require a mark to be entirely gone, so the defect
that actually ships — the axis label with its last characters cut off, the
title chopped by the page edge — had no rule at all. It reports how much was
lost, as a warning for text and a note otherwise.
Then, in rough order of how hard they are to catch by eye:
cvd_collision — two colours a reader is meant to tell apart that a
colour-blind reader cannot. Nobody catches this by looking, because the
person looking can see the difference.font_fallback — a character no font on this machine can draw, which
renders as a tofu box. Deliberately machine-dependent, which makes it the
rule most worth running on CI rather than on the author's laptop.invisible_fill — a mark filled in the page's own background colour with no
outline. Painted, correctly sized, correctly placed, and invisible.occluded — an opaque mark completely hidden behind a later one: ink that
never reaches the page, usually a layer ordering mistake.label_on_mark — a label swallowing the mark it annotates, which is exactly
what vl_repel() exists to fix.overplotted — a batched mark dense enough to hide its own distribution,
measured per layer so it names the one to fix, and suggesting datashade().hairline — a stroke under half a pixel, which the raster backends render as
a dpi-dependent smudge and the vector backends as a crisp line.subpixel — an area mark less than a pixel across.bleed — a mark drawn outside a viewport that does not clip.duplicate_name — two nodes sharing a name, which silently makes all but the
first unaddressable by get_node(), edit_node() and vl_repel().double_draw — the same mark drawn twice in the same place.blank_label — a text mark with no visible characters.Every rule was checked against realistic figures as well as planted defects,
and several were deliberately narrowed as a result: a bar chart with value
labels, a panel with axis text, a scatter grazing its clip and a viewport-filling
background rect all lint clean, and there are tests to keep it that way. The
same applies to cvd_collision's threshold, which is calibrated to report
ggplot2's default red/green pair while staying quiet on the CVD-safe Okabe-Ito
and viridis palettes.
tiny_text gained a point floor and fires on either. font_px scales with
dpi, so the pixel floor alone stopped seeing illegible text on a
print-resolution render: 4 pt at dpi = 300 is 16.7 device px and cleared the
7 px default comfortably. min_text_pt (default 6) asks the other question —
can a human read this. Text between 6 pt and the pixel floor may now be
flagged where it was not before; pass both arguments to move both floors.
vl_lint_assert() turns a lint into a gate for a test suite or a CI job,
without vellum taking a testthat dependency. severity = "note" fails on
anything at all; on = "warn" reports without stopping.
vl_lint_overlay() draws the findings onto the scene — a box round each
one, labelled with the rules that fired, red for warnings and orange for
notes. For a graphics linter this is usually the faster way to read a report: a
message says a mark is clipped, an outline shows you which.
vl_lint(exclude = ) suppresses findings for named nodes. Suppression is
by node, since rules already selects rules, and the usual case is one
deliberate oddity in an otherwise clean figure — without it a project with a
single intentional off-canvas mark could never reach a clean lint to assert on.
An entry matching nothing warns, because a stale exclude list looks exactly
like a working one. vl_lint(severity = ) likewise overrides a rule's own
severity for a project that cares about it more, or less, than vellum does.
Findings carry the node's device-px box, so a caller can point at a defect
rather than only describe it — which is what the overlay is built on. A rule
reporting something with no geometry gets NA.
A failing rule is reported instead of aborting the lint. The registry is
open to downstream packages, and one broken rule used to lose every other
rule's findings behind an opaque
Error in get(id, envir = .lint_rules)$fn(...). Failures come back as a
rule_error finding naming the rule, as does a rule returning a data frame
without the required columns.
Rules can see more, and describe themselves. ctx gained elements() —
the per-element table, the only honest view of a batched mark, since a scatter
is one node whose box is the union over every point — and
region(x0, y0, x1, y1) for a whole block of composited pixels rather than
probing point by point. Both are lazy, like pixel(). The node table gained
the resolved fill and fill_kind, lwd_px, the viewport id and extent, and
a notdef count. vl_lint_rule() takes kinds, needs_pixels and tags: a
rule naming the kinds it reads is skipped on a scene with none of them, and
vl_lint_rules() reports all three.
Fix: the lint node table reported light colours wrongly. col was packed
as 0xRRGGBBAA into a signed 32-bit integer, which overflows as soon as red
reaches 128 — #EEEEEE came back as -286331137 and unpacked to a red channel
of -18. low_contrast therefore mis-measured the luminance of every light or
reddish text colour, landing on the right side of its threshold by luck rather
than by arithmetic. Colours are now packed as doubles, which hold all 32 bits
exactly. Reported contrast ratios change accordingly: the README's #F2F2F2
watermark on white is 1.1:1, not the 1.4:1 previously claimed.
invisible also catches a fill that is present but fully transparent.
fill = "#FF000000" sets a colour and then asks for none of it; has_fill
cannot see that, and the fill's alpha channel can.
label_overlap no longer compares every pair of labels in interpreted R.
The collision search moved to a sort-and-sweep in Rust, which drops a
quadratic term: the detection itself goes from 3.02 s to 0.007 s on 2400
labels, and a full vl_lint() of that scene from 7.78 s to 2.14 s. At a few
hundred labels the win is smaller, because compiling the scene dominates
either way. Results are unchanged, and tests assert the sweep agrees with the
all-pairs check it replaced. occluded, label_on_mark and double_draw were
measured and left alone — their inner loops are already vectorised, and they
cost milliseconds on 400 marks.
Fix: knitting a scene with a dpi chunk option errored. A YAML dpi: 150
parses as an <integer>, and the display path passed it straight to the
scene's @dpi property, which is declared <double> - S7 rejected it with
@dpi must be <double>, not <integer>. The chunk dpi is now coerced.
Internal: the sources are clean under the jarl linter. Dropped a dead
.diff_body() helper, hoisted assignments out of for() heads and out of
on.exit() (via a new .set_debug_reg() setter), added the leading zero to
fractional literals in the vignettes, and removed the redundant vellum:::
prefix from the test suite, which already evaluates inside the package
namespace. The ::: calls that reach Rust kernels from the vignettes and
inst/ scripts are genuinely required and carry a # jarl-ignore with a
reason. No user-visible behaviour changes.
Fix: an animated SVG showed one frame and then near-nothing. Every frame is
rendered separately, so each restarted its <defs> id counters and the whole
document ended up with N copies of id="c0". All N clip-path="url(#c0)"
references therefore resolved to the first frame's clip — which lives inside
a visibility:hidden group, and a hidden <clipPath> child contributes no
geometry, so the clip was empty for the 47/48 of the cycle when frame 0 was
hidden. Everything inside the panel was clipped away, leaving only the axis
labels and legend drawn outside it, and the plot appeared to blink once and
then vanish. Generated <defs> ids now carry a per-frame prefix.
Fix: animated SVG frames played in reverse. The per-frame negative
animation-delay was offset by i rather than n - i, so after frame 0 the
cycle ran backwards (0, n-1, n-2, …).
Fix: a gradient (or pattern) col collapsed to its first stop on circle
outlines. Circles take a batched fast path that draws a unit circle placed
by an affine transform; a gradient stroke is resolved in viewport pixels, so
on the unit circle it sampled a single point of the ramp and rendered one flat
colour. The same gradient stroked rects and polylines correctly. A gradient/
pattern stroke now drops circles to the per-element, real-coordinate build (as
rects already did), so the ramp runs along the outline. Solid strokes keep the
fast path and render byte-identically.
svg_grob() can size an icon to its viewBox, and read a whole <svg>.
Icon sets share one viewBox per family and pad each glyph inside it, so
sizing to the glyph's own ink blew every icon up to size individually — a
sparse glyph rendered far larger than a dense one from the same set, and a lone
icon larger than its nominal box (the reported ~2.5× oversize). svg_grob()
now takes a viewbox argument (c(xmin, ymin, width, height), or the raw
"0 0 24 24" attribute string) and maps the box to size, so glyphs keep
their intended relative and absolute size. Passing a whole <svg>…</svg>
element as d reads its <path> geometry and viewBox automatically (needs
\pkg{xml2}; non-<path> shapes are reported, not silently dropped). With no
viewBox the previous ink-bounds sizing is unchanged.
Fix: element_geometry() and vl_nearest() reported viewport-local
coordinates, not device pixels. The pick table resolved every vertex through
vp.x_pos()/y_pos() — which answer in the viewport's own frame — and never
applied the viewport's transform. So for any viewport not at the page origin,
which is every panel of every real plot:
element_geometry() was off by the viewport's offset, and internally
inconsistent: text and roundrect rows went through node_bbox() and
came back in device px, while segment / line / polygon / path /
point / rect rows stayed local. One table, two coordinate systems.vl_nearest() compared a device-px probe point against those local
coordinates, so its answers in an offset panel were not merely imprecise but
wrong — it would report a mark at distance 0 with the cursor nowhere near it,
and rank the mark actually under the cursor far down the list.Both now report device pixels with y down, matching scene_model()'s boxes and
the rendered SVG, as documented. A round mark's radius takes the transform's
scale as well as its translation.
This is the same bug lint_table() had before 0.6.1, and it survived for the
same reason: every test in test-pick.R drew into the default full-page
viewport, where the transform is the identity and local is device. The
regression tests added here use an off-origin viewport.
Nothing rendered changes — the pick table feeds no drawing path.
Docs fix: the leader-line example placed labels and drew leaders from two
different solves. vignette("placement") and inst/examples/labels.R both
computed vl_place(scene), drew leaders from that answer, and then called
vl_repel() on the scene with the leaders already in it. The leaders are
obstacles to that second solve — and since each one lies exactly along the path
its label wanted to take, and vl_place() works on bounding boxes (so a
diagonal segment blocks the whole rectangle spanning it), the labels were
pushed back the way they came. The median leader ended up 146° from the
label's real displacement, with half of them pointing essentially backwards.
Both now place first and annotate afterwards, which makes the leaders exact,
and both say why. No engine change — vl_repel() was applying the solve it
reported all along. The general rule, now stated in the article: anything you
add to a scene becomes an obstacle to a later solve.
vl_lint()'s contrast rule no longer cries wolf on ordinary axis text.
It sampled the backdrop at four points just outside a label's box and took the
worst, so any label a single 2 px probe happened to graze against a nearby
tick, gridline or axis rule read as ~1:1 — flagging dark axis text on white on
essentially every plot. It now takes the second-worst of the four sides:
incidental adjacent ink on one side is ignored, while a label genuinely on a
low-contrast field (all sides) or straddling a dark region (two sides) is still
flagged.
Gridlines (role = "grid") are treated as decorative in tagged PDF. A node
with role = "grid" — the role a downstream sets so an interactive SVG host can
find and hide the gridlines — is now a PDF artifact (skipped by a screen
reader) alongside "presentation"/"none"/"decorative", rather than a bogus
Figure in the structure tree. The SVG role is unchanged, so the host selector
still works. Found while wiring vellumplot's accessibility pass.
grobwidth()/grobheight() of a width-constrained text_grob()
ignored the wrapping. The renderer wraps a text_grob(width=) to its box and
auto-fits when fit= is set, but the measurement path (.grob_extent(), what a
grobheight/grobwidth unit resolves to) measured the unwrapped line. So a
layout track sized by grobheight() reserved a single line for a label that
drew as several, and the wrapped text overran whatever sat above it. Extent now
wraps the label to width (in mm), stacks the resulting lines, and applies the
same auto-fit shrink as drawing — so a reserved region matches the glyphs that
land in it. Plain labels only; rich md() labels are unchanged (the renderer
does not width-wrap them either). Found while wiring wrapped plot
titles/subtitles/captions in vellumplot.roundrect_grob() aborted scene_model(), and a
keyed one silently dropped every key past the first. roundrect_grob() is a
batch — one rounded box per (x, y, width, height, r) element — but it
recycled key/meta to length 1, so roundrect_grob(key = c("a", "b")) kept
only "a". And scene_model() classed roundrect as a single shape (one row
per grob), so a grob drawing N keyed boxes reported one element against the
backend's N and aborted on the count check. Roundrect is now a keyed batch,
like text_grob(): each keyed box is one scene_model() row carrying its own
key/meta, and an unkeyed roundrect stays out of the model. Rendering is
unaffected (scene_model() is interactivity metadata only). Found while wiring
keyed data-label backgrounds in vellumplot.Fixed (breaking): vl_contour() transposed its input matrix. It assumed
rows indexed y and columns x. Base R is the opposite — image(), contour(),
persp() and contourLines() all take dim(z) == c(length(x), length(y)),
i.e. rows index x — and that is also the shape outer(xs, ys, f) produces,
which is how a grid is normally built.
So every contour came back reflected across the diagonal. On a symmetric
surface that is invisible; over a density with a reference layer under it,
it is obvious. The documentation compounded it by citing image() as the
authority for the opposite of what image() does.
vl_contour() now matches base R. If you were transposing your matrix to
work around this, stop. Code that passed outer(xs, ys, f) directly was
getting transposed output and is now correct with no change.
The test suite missed it because every contour test used a grid symmetric in
its two arguments. The new tests use an asymmetric surface and check against
grDevices::contourLines() rather than against our own expectation.
Fixed: node bounding boxes were viewport-local, not device coordinates.
lint_table() resolved a node's box through its viewport's scales but never
applied the viewport's own placement, so for any viewport not at the page
origin it disagreed with element_table() by the viewport's offset — which is
every real plot with a panel.
Everything built on it inherited the error. vl_repel() solved labels against
obstacles it believed were elsewhere, so on a panelled scatter it left labels
sitting on top of the very markers it was asked to avoid (22 of 22 in the
regression case, now 0). vl_lint()'s offscreen and low_contrast rules
looked at the wrong part of the page, and vl_nearest() misplaced text and
rounded rects.
It was invisible in the test suite because tests draw into the default
full-page viewport, where the transform is the identity and local is
device. scene_model() was never affected — it takes geometry from
element_table(), which was correct — so vellumwidget is unaffected.
Six phases of engine work: a typography layer, label placement, geometry operations, accessible output, new output destinations, and addressable marks. Every mark family can now carry a data key, text can be wrapped or set along a curve, labels can be repelled, paths can be combined, PDFs are tagged, and scenes render to animated SVG, multi-page PDF or a parallel batch.
Verified byte-identical across 11 scenes and 4 output paths throughout, except where a fix deliberately changed output (noted below).
Fixed: vl_repel() was defeated by a panel background. The default
obstacle set is "everything that is not a label", which on a real plot
includes the panel background rectangle — and a label inside it collides with
it wherever it goes, so it could never be placed. On a two-panel scene, 29 of
32 labels were unresolvable; now 2.
An obstacle that wholly contains a label is no longer treated as an obstacle for that label. That is also right for a bar or region a label deliberately annotates from the inside, where pushing the label out would be exactly wrong.
Fixed: vl_repel() could push labels off the page. Resolving a collision
by shoving a label off the canvas is strictly worse than the collision — an
overlapping label is hard to read, an off-canvas one is gone. Solved positions
are now bounded by the label's clip region intersected with the page, widened
to include its own anchor so a label deliberately placed off-page stays put.
Fixed: a halo ate the neighbouring glyphs on text_path_grob(). Placing
each glyph as its own draw broke the rule that every glyph is stroked before
any is filled, so each halo painted over the previous glyph's fill. Haloed
text on a curve is now stroked in one pass and filled in another, as straight
text already was.
Fixed: render_all() ignored _R_CHECK_LIMIT_CORES_. R CMD check sets
it and parallel::mclapply() errors above two processes under it, so any
package depending on vellum would have failed its own check.
Every mark family can now carry an element key. lines_grob(),
polygon_grob(), path_grob(), roundrect_grob() and text_grob() gain
key/meta, joining the batched marks that already had them;
bezier_grob(), spline_grob(), svg_grob() and contour_grob() inherit it.
A key is what makes a mark addressable — it becomes data-key in the SVG and a
row in scene_model(). Until now a line, an area, a choropleth region or a
data label could not carry one, so they could never be hovered, tooltipped,
brushed or cross-filtered by an interactive host. Now a whole series can be one
addressable thing.
Keyed text reports one element per label. Note the deliberate asymmetry with
the batched marks: lines, polygons, paths and text appear in scene_model()
only when keyed, because a plot is full of unkeyed gridlines and axis
labels that would otherwise bury the marks that mean something.
vl_nearest() and element_geometry() — hit-testing that respects the
shape. A bounding box is the right answer for a rectangular brush and the
wrong one for anything diagonal or thin: a line across a panel has a bounding
box covering the whole panel, so a box-based "what is nearest" matches it from
anywhere in the plot.
vl_nearest() measures to the real geometry — perpendicular to a segment, to
the disc for round marks, and zero anywhere inside a closed polygon, so
clicking the middle of a choropleth region hits the region.
element_geometry() returns that geometry instead of the answer, for hosts
that cannot call R on every mouse move: a browser takes the vertices once and
computes distances locally.
Animated SVG. vl_render_animation(format = "svg") emits the same
keyframe schedule as vector markup, shown in turn by a CSS step animation —
resolution-independent, and honouring prefers-reduced-motion.
Choose it by scene complexity, not by preference. Every frame is emitted in full, so size grows with complexity times frame count where a raster format's does not. Measured on a 30-frame scatter, gzipped: 20 marks → 20 KB vs GIF's 61 KB; 2000 marks → 720 KB vs GIF's 124 KB. Line art wins clearly; dense marks do not. Above 5 MB vellum warns rather than letting you find out later.
pdf_pages() — several scenes as the pages of one PDF. A report's
figures, one facet per page, an animation as a contact sheet. Pages may differ
in size, and each page's tagging survives into the document's structure tree.
render_all() — render many scenes across cores. Embarrassingly parallel,
one whole scene per worker. About 3× on four report figures, and asserted
byte-identical to rendering them one at a time. Named scenes plus a directory
names the files.
Fixed: a tagged multi-page PDF orphaned its earlier pages' structure. A
document has exactly one tag tree; each page was setting its own, discarding
the previous. Found by building pdf_pages(), and only reachable there.
Tagged PDF output. The per-mark id/role/name channel now builds a PDF
structure tree: a StructTreeRoot, a Figure for the plot as a whole carrying
the describe() text, and one structure element per marked-up mark, in draw
order — which for a graphic is reading order.
This needs no new annotation API: a scene already marked up for the web is
already marked up for PDF. role = "presentation" marks a node a PDF
artifact, so gridlines and panel backgrounds are skipped by assistive
technology rather than read aloud.
Structure is metadata — the rendered pixels are unchanged — and a scene with no marked-up nodes produces exactly the PDF it always did.
scene_fonts(), font_pin() and font_check(). vellum claims identical
pixels on every OS and in CI. Layout, shaping and rasterisation deliver that;
font resolution does not and cannot, since "sans" is a different file on
every platform.
These make that gap visible rather than pretending it away: scene_fonts()
reports the faces the text actually resolved to (read off the shaped glyphs),
and a pin next to a reference image lets a failing comparison be attributed —
your change, or the machine's fonts.
They deliberately do not bundle fonts. vellum resolves through
\pkg{systemfonts} so it agrees with the rest of R's graphics stack; for a
guarantee rather than a check, register the exact file with
systemfonts::register_font().
vl_path_op() — boolean operations on paths. Union, intersection,
difference and exclusive-or over closed rings.
The result is geometry, not a render-time mask, and that is the point: it
is an ordinary path, so it can be measured, hit-tested, simplified, filled
with its own gradient, stroked along the boundary the operation created,
exported as <path> data, and used as the operand of another boolean. A clip
can do none of those, rasterises, and degrades on some PDF paths.
rule describes how to read the inputs — whether a ring inside another is
a hole or an island — and operands must be in a single coordinate space, since
a boolean has to be computed somewhere.
vl_contour() and contour_grob() — contours from a grid. Marching
squares over any matrix, with saddle cells resolved from the centre value
rather than an arbitrary fixed choice, and cells with missing corners skipped
so a contour breaks around a hole instead of being drawn through it.
Segments are chained into continuous polylines. Under a solid stroke that is
invisible; for everything else it is not — an unchained contour restarts its
dash pattern in every grid cell and cannot be simplified, measured or closed.
Closed contours come back marked closed, so a filled density contour is a
path_grob() away.
svg_grob() and vl_svg_path() — SVG path data as scene geometry. The
full d grammar, including relative forms, implicit repeated commands, the
smooth-curve reflection rules, and elliptical arcs with the packed flag form
minified icon files use. Icon sets ship one <path d="..."> per glyph, so
this is what makes crisp vector markers possible.
It reads path data, not SVG documents — no stylesheets, gradients, <use>,
clip paths or element transforms. Use an XML parser to pull d strings out of
a file.
Fixed: draw() accepted a list of grobs and then failed to compile it.
The list was appended as one child and the failure surfaced much later as
Can't find method for compile(<list>). A list now draws each element.
lines_grob() now rejects a multi-value id. Its id is the
accessibility identifier, but path_grob()'s is a grouping variable — the
same argument name with the opposite meaning. Passing a grouping vector by
analogy silently drew one polyline through every group, joining them with
straight lines across the plot.
vl_repel() / vl_place() — label placement as an engine service.
vl_place() solves label collisions over a scene's resolved geometry and
reports each label's displacement; vl_repel() applies it and returns a new
scene.
The solve runs in device pixels and the answer is applied as an absolute
millimetre offset on top of each label's existing coordinate. That is what
makes it coordinate-system-agnostic: a label anchored in native units inside
a polar, faceted or warped panel moves by the same mechanism as one in npc
on the page, and every panel is solved together rather than one at a time.
It moves labels; it does not decide which to drop, shrink or abbreviate.
Labels that cannot be placed are reported with resolved = FALSE rather than
silently piled up or quietly deleted — that call needs to know what the labels
mean, and belongs above the engine.
vl_empty_region() — the largest empty rectangle in a scene. Where a
legend, annotation or watermark can go. Occupancy is rasterised onto a grid,
so the answer is exact on that grid and conservative off it: boxes round
outward, and it will never claim space that is in fact occupied. It reports
millimetres on request, which is the absolute measure text_grob(width = )
wants — so an annotation can be fitted to the gap that was just found.
vl_hull() and vl_buffer(). Convex and concave hulls of a point set,
and outward offsetting of a ring — for outlining a cluster and building the
exclusion zone around it. Note that concavity runs the opposite way to what
the name suggests: larger is more convex, Inf is the convex hull, and
below about 3 the boundary starts to self-intersect.
element_table() gains name, kind and a node index, and
lint_table() gains the matching node index, so the per-element and per-node
views of a scene can be joined. Without this a batched mark is a single node
whose box is the union of every element, which makes a scatter read as one
panel-sized obstacle.
Text that fits a box. text_grob(width = ) wraps a label to an absolute
measure; align sets "left" / "centre" / "right" / "justify" within
the box; fit = TRUE shrinks the font until the wrapped block fits
width × height.
Break decisions are made on the shaped width of each candidate line rather than a sum of word advances, so kerning and any active OpenType feature are accounted for and a line can never render wider than it measured. That is what makes auto-fit trustworthy rather than approximate.
width must be an absolute unit (mm/cm/in/pt), and relative units are
rejected with an explanation. Wrapping happens when the grob is built, and a
viewport's size in npc/native does not exist until render time — which is
also the reason this cannot be built on grid at all, where a string has no
width until a device is open.
text_path_grob() — text set along a curve. Each glyph keeps the pen
position shaping gave it and is placed that far along a polyline baseline,
rotated to the local tangent. offset sets a perpendicular standoff.
Halos, OpenType features, per-glyph colour and clipping all still apply, and
all three backends handle it — SVG stays real <text>, PDF stays copyable.
Glyphs follow the tangent as in SVG textPath, so a label on the underside of
a closed curve reads upside-down; reverse the path to fix that, not the
glyphs.
Fixed: multi-line text hung below its anchor. A block of n lines was
centred (n-1)·lineheight/2 too low — at 12 pt a six-line block sat half an
inch below where it should. Vertical justification is now correct at top,
centre and bottom for any line count. This changes the rendering of existing
multi-line labels.
Fixed: multi-line text collapsed to one line in SVG. SVG <text> ignores
newlines, and vellum emitted one element per label, so a multi-line label
rendered as a single run of text with the breaks turned into spaces. Each line
is now its own element. Single-line labels are untouched, and a label whose
lines cannot be matched to its glyphs (a blank line, which produces none)
falls back to glyph outlines rather than guessing.
Per-segment stroke style. segments_grob(col = , lwd = ) take one value
per segment, mirroring the per-element fill that hexagon_grob() and
sector_grob() already carry. Previously varying either meant building one
grob per segment, and grob construction is the dominant expense on scenes
made of many small marks.
A single grob with per-element style renders byte-identically to N grobs each
with its own gp (asserted in tests). No backend can stroke segments
differently in one call, so each is still stroked on its own in the output —
the saving is in R, not in the file size. Absent, the batch draws in one
combined stroke exactly as before.
vl_hatch() — hatch fills as geometry. Fills a shape with ruled lines at
a given angle, spacing and width. Unlike vl_pattern(), which rasterises a
tile, a hatch is real geometry: crisp at any zoom, correct in print, and
emitted as <path> data in SVG rather than an embedded image.
The reason it matters is accessibility. A categorical encoding that fails for
a red/green-blind reader — which render(cvd = ) shows you and vl_lint()
flags — is fixed by encoding with texture as well as hue. vellum could
already diagnose that problem; now it can solve it.
It is expanded in the scene walk into stroked spans, computed by scanline crossing against the shape, so no backend needs a hatch primitive and only the spans actually inside the shape are emitted.
Fixed: gp$fill never reached hexagon_grob() or sector_grob(). Those
marks take a per-element fill colour, and the shared gpar fill was not passed
to them at all — so a gradient or pattern there silently drew nothing. Both now
fall back to the shared paint when no per-element fill is given, which means
gradients, patterns and hatches work on them for the first time. An explicit
per-element fill still takes precedence.
New example inst/examples/hatching.R; vignette("scene-and-paint") gains a
hatching section and vignette("render-quality") now points at it as the fix
its CVD section was missing.
A scene is now a value you can save, fingerprint, compare and compose.
scene_write() / scene_read() persist a built scene (.rds, or .json
with jsonlite) and rebuild it to byte-identical pixels; as_scene_spec() /
from_scene_spec() are the plain-list form underneath. The conversion is
generic over the S7 property model rather than written per grob type, so a
grob added later serializes with no change to the serializer.
This is one level below vellumplot's plot spec and composes with it: a plot
spec is portable and re-renderable at any size, a scene spec reproduces exactly
this scene.
scene_hash() fingerprints content — two independently-built identical
scenes agree, any change to what is drawn does not. Usable as a cache key or a
test assertion.
scene_diff() reports what changed, in scene terms
(root$children[1]$gp$fill: steelblue -> tomato) rather than as a pixel diff.
That makes it a better basis for visual-regression testing than comparing
images, because an image diff is sensitive to the font stack and a structural
one is not.
scene_inset() places one scene inside a region of another. Because a
scene has a known resolved size this is a graft, not a re-render, and the
result is an ordinary scene: the inset is an addressable node that can be
edited by name, inset again, or serialized. The policy questions
(should panel edges align? should axes be shared?) stay above vellum, in a
grammar; the engine supplies the ability to nest at all.
New article vignette("scenes-as-values") and example
inst/examples/serialize.R.
Gradient strokes. vl_gpar(col = linear_gradient(...)) strokes with a
gradient — the same paint model fill already had, applied to the region the
line covers instead of the region a shape encloses. A trajectory can carry its
colour along itself, and it is real paint on every backend (a tiny-skia
shader, SVG stroke="url(#…)", a krilla shading) rather than the usual
workaround of emitting hundreds of one-segment lines in slightly different
flat colours. It applies to any stroked path, outlines included, and inherits
from a viewport like every other stroke property.
Text and markers fall back to the gradient's first stop, because a glyph run
has no path to run a ramp along; a pattern in col falls back to the colour,
because a pattern needs cell geometry a stroke does not have.
vl_gpar(dash_phase = ) sets how far into the dash pattern a line starts,
in multiples of lwd so it scales with the line width exactly as the nibbles
do. Aligns dashes across adjacent strokes, and animating it gives marching
ants. Reaches SVG as stroke-dashoffset and PDF as krilla's dash offset.
New example inst/examples/strokes-paint.R; vignette("scene-and-paint")
gains sections on stroking with a gradient and on dash phase.
Dense paths are simplified at render resolution. A coastline or a long
time series carries far more vertices than the canvas has pixels to
distinguish; vellum now drops the ones that could not have changed a pixel.
Worth 1.7–2.5× on render time and 65–75% of the SVG size at 50,000
vertices. It is automatic, and the dial is options(vellum.simplify) — a
Douglas–Peucker tolerance in device pixels, default 0.1, 0 to disable.
Only the renderer knows the output resolution, which is why this belongs here
rather than in a data-preparation step.
Like the marker-sprite and glyph-bitmap fast paths this is a deliberate fidelity trade, so it engages only where the win is real: paths under 1000 points per sub-path are never touched and stay byte-identical.
stroke_to_path() converts the stroke of a line-like grob into a
path_grob() describing the region it covers — so a line can be filled with a
gradient or a pattern, or handed to a cutting plotter that needs a closed
shape rather than a centreline. It uses the same stroker the rasterizer uses,
so the outline is exactly what would have been inked. The result is absolute
(mm): an outline is a shape baked at one size, not a stroke that rescales.
New example inst/examples/geometry.R and benchmark
inst/benchmarks/simplify.R; vignette("scene-and-paint") gains sections on
filling a line and on dense paths.
vl_lint() — static analysis for graphics. Resolves the scene and
inspects the geometry the renderer would draw with, reporting the things
people otherwise find by squinting at a PNG: a mark that landed off-canvas or
outside its viewport's clip, an element nothing will paint (no fill, no
stroke, or zero alpha), text below a legibility floor, overlapping labels, and
text whose contrast against its actual rendered backdrop falls below the WCAG
threshold. This is possible because vellum resolves layout and text metrics
before drawing — a layer above grid cannot ask how many pixels tall a label
is, because the answer does not exist until a device is open.
The rule set is a registry: vl_lint_rule() lets a package layered on vellum
add its own, so a grammar's semantic rules and vellum's geometric ones come
back from one call.
scene_stats() reports ink coverage, distinct colours, and an
overplotting index — the honest signal for "should this be
datashade()-ed?". A mark count says nothing about overlap: 2000 scattered
points score 4.3, the same 2000 clustered score 91.
profile_render() attributes render cost to the marks that caused it, and
splits build / compile / raster. Read the phase split first:
compile time is R-side, and on scenes with many small grobs it dominates, in
which case tuning the backend cannot help. Timing is armed only for that call,
so ordinary renders pay nothing.
New example inst/examples/linting.R.
Colour-vision-deficiency simulation. render(scene, path, cvd =),
scene_png(cvd =) and scene_raster(cvd =) re-render the finished raster as
a viewer with "protanopia", "deuteranopia", "tritanopia" or
"achromatopsia" would see it, using the Machado et al. (2009) matrices
applied in linear light (doing it in sRGB is the common shortcut and it
shifts lightness). An accessibility check becomes an argument rather than an
export-and-upload round trip. Raster only -- a vector format has no pixels to
transform, and render() says so rather than silently writing an unsimulated
file. The simulation is never written into the render cache.
Blur, drop shadow and glow as group effects. vl_viewport(blur =) and
vl_viewport(shadow = vl_shadow(dx, dy, blur, col)) act on the composited
layer, so overlapping shapes inside a viewport blur -- or cast a single
shadow -- together rather than each shadowing the others. A glow is a shadow
with no offset. Raster convolves (three box passes, the standard Gaussian
approximation); SVG emits native feGaussianBlur / feDropShadow and stays
vector; PDF has no filter model and reports it through the usual degradation
warning instead of quietly rasterising your vector output.
vl_gpar(crisp = TRUE) snaps axis-parallel strokes onto the pixel grid. A
1-px rule at a fractional coordinate straddles two rows and renders as two
grey ones -- the reason gridlines look muddy on screen. Diagonals are left
alone. vl_gpar(antialias = FALSE) gives hard pixel edges, for pixel art,
QR codes, and heatmap cells that must tile without a seam. Both inherit down
the tree like any other graphical parameter.
New examples inst/examples/effects.R and inst/examples/cvd.R.
Text halos (shadowtext). vl_gpar(halo_col =, halo_width =) strokes the
glyph outlines under the fill, so a label stays legible over a dense scatter
or map imagery. Because vellum holds the outlines, this is one real stroke
rather than the eight offset copies packages layered on grid have to draw --
and it works on all three backends (raster and outline-SVG do two explicit
passes, native SVG uses paint-order and stays selectable, PDF strokes then
fills). halo_width is in points like fontsize, so a halo keeps its
proportion at any dpi. Haloed text bypasses the glyph-sprite fast path, which
bakes only the fill. Resolves
#13.
OpenType feature control. vl_gpar(features = c(tnum = 1)) passes
four-character OpenType tags to the shaper: tabular figures so axis labels
stop jittering between ticks, plus small caps (smcp), oldstyle figures
(onum), and ligature (liga) and kerning (kern) control. Features reach
measurement as well as drawing -- vl_strwidth(), vl_strheight(),
grobwidth() and grobheight() all take them, and the shape cache is keyed
on them -- so a grobwidth-sized track reserves space for the glyphs that
actually get drawn. Nothing in R's graphics stack has offered this.
New article, vignette("typography"), and inst/examples/typography.R.
Text renders ~3x faster on a cold plot with many distinct labels. PERF-1
shaped every unique label in one textshaping::shape_text() call; the
multi-line-text change replaced that with a per-label loop, so each distinct
label re-resolved its font and font resolution grew to 56% of a cold render.
Shaping is batched across labels again -- lines are pooled before shaping and
re-stacked afterwards, so multi-line labels keep working. A cold 5000-label
render goes from 1.78 s to 0.55 s. Output is byte-identical.
Images are ~4x cheaper to convert, and can be read from a file.
raster_grob() now takes a path to a PNG, decoded in the Rust backend, so
no R image package is needed to put a picture in a plot. A numeric RGB/RGBA
array -- what png::readPNG() returns -- takes a fast path that skips the
per-pixel colour-string round-trip as.raster() performs: 0.21 s to 0.055 s
at 1 megapixel, which turns an image draw from slower than grid (0.69x) into
faster (1.38x). The pixels are identical either way.
vl_gpar(cex =). A multiplier on fontsize, as in grid, so a theme can
ask for "20% larger" without knowing the base size. It folds into text
drawing, char/line units, and grob measurement, so cex = 2 is exactly
equivalent to doubling fontsize everywhere.
vl_convert() resolves a unit to a plain number in another unit --
grid's convertWidth()/convertX() and friends. Absolute units need no
context; npc/native resolve against the page or a named viewport.
axis picks the extent and what distinguishes a length from a position
(they differ for native when the scale does not start at zero).
scene_png() and scene_pdf() return the encoded document as a raw
vector instead of writing a file, alongside the existing scene_svg()
(a string) and scene_raster() (pixels). Every output format can now be
produced in memory -- for a data URI, a web response, or a connection --
with no temp-file round-trip. Backend degradation warnings are surfaced just
as render() surfaces them.
render(scale =) renders at a multiple of the device pixels while
keeping the same physical size -- the retina / ggsave(scaling =) idiom.
It scales dpi, so the layout is untouched and text does not change relative
size. Also available on scene_png().
print() works again on gradients, patterns, masks, and why_size()
results. All four have print methods declared in NAMESPACE, but none of
them dispatched: the objects printed as raw lists. S7::method(print, cls) <- fn is a replacement call, so it also bound the symbol print inside the
package namespace (holding base::print, unchanged) — and R's
registerS3methods() treats a generic whose name is a local object as a
local generic, filing every S3method(print, <class>) into vellum's own
.__S3MethodsTable__. rather than base's, where dispatch looks. Dropping the
accidental print/plot bindings restores normal registration; the S7 methods
for vellum_scene register via S7::methods_register() and were never
affected. test-print-methods.R now guards both halves.
Cleaner GIF output from vl_render_animation(). GIF frames are limited to
256 colours, so on a plot (smooth panels, antialiased marks) the old
nearest-colour quantisation left bubble edges jagged and gradients banded. The
encoder now defaults to the best-quality NeuQuant palette (sample factor 1) and
applies Floyd–Steinberg dithering, and a frame that already fits in 256 colours
is kept exact. New gif_speed (1–30) and gif_dither arguments expose the
trade-off. GIF is still inherently lossy on a plot; format = "apng" remains the
lossless option.
Converting between the build tree and the immutable tree is ~4-10x faster.
.bnode_to_gtree() (which runs the first time a built scene is rendered,
edited, or queried) read the child dict with one get() per child; it now uses
a single mget(). .gtree_to_bnode() (which runs when a materialised scene is
drawn on again, or when a branched scene forks) did an assign() per child and
an S7_inherits() type test per child; it now builds the dict with one
list2env() and tests the type by attribute. Neither changes the resulting
tree. On a scene of 20,000 sibling grobs: materialising 34 ms -> 9 ms, drawing
onto an edited scene 106 ms -> 11 ms, branching a scene 144 ms -> 21 ms.
edit_node() is ~40x faster on large scenes. An edit rebuilt the nodes on
the path with node@children[[i]] <- ... and then wrote the new tree back with
S7::set_props(scene, root =). Both write into an existing S7 object, which
makes R duplicate it first, and duplicating it deep-copies the whole children
list hanging off its attributes — so a single edit copied every sibling grob,
making it O(scene) instead of O(depth). Both now construct a fresh node/scene
around the new children list, which just stores the pointer. .find_path() also
reads name/children as attributes rather than through S7_inherits() +
prop_names() + @, cutting the search itself by ~10x. On a scene of 20,000
named sibling grobs: 597 ms -> 14 ms per edit (grid's editGrob() is 430 ms on
the equivalent gTree). Scenes built from batched marks (one grob carrying many
elements, which is what vellumplot emits) were already ~0.3 ms and are
unchanged. No behaviour change: the derived tree, its nid re-stamping, the
repaint-boundary cache hits, and the rendered bytes are all identical.
vl_render_animation() — non-reactive keyframe animation. Interpolate
between a set of compiled keyframe scenes and encode the in-between frames to a
looping GIF, an animated PNG (APNG), or a directory of PNG frames. The tween,
render, and encode run in one parallel, streaming pass in the Rust backend
(rayon over frames): matching primitives interpolate their geometry, their
colours (perceptually, in Oklab), and their bounded graphical parameters, while
discrete attributes snap. The caller supplies the keyframes and a per-frame
schedule (which keyframe pair and the eased fraction), so easing stays in R. See
inst/examples/animation.R.
sector_grob() and loop_grob() no longer render vertically mirrored.
Sector arcs were built directly in the y-down device frame (cy + r·sin θ)
while every other primitive maps coordinates through the y-up native frame, so a
sector drawn at angle θ landed where the others would place -θ — the
documented "0 at 3 o'clock, counter-clockwise" contract was mirrored across the
horizontal axis. A centroid label/point overlaid on a pie / donut / rose /
sunburst therefore sat on the mirror image of its wedge. Sector fills, strokes,
arrowheads, and hand-drawn (sketch=) wedges — and loop_grob()'s teardrop
angle, which shared the same convention — now all honour the y-up angle
contract (#14).vl_viewport(pannable = TRUE): clip-stable pannable panels. A named panel so
marked is emitted to SVG as an outer <g data-vellum-panel> carrying the panel's
clip (hoisted once, untransformed) around an inner <g data-vellum-pan> holding
the content. A host can set a transform on the inner group to pan/zoom the
marks while the clip and the surrounding axes stay fixed — the basis for
host-side axis-aware zoom. SVG-only and inert for static rendering; non-pannable
panels and the raster/PDF backends are byte-for-byte unchanged. See
vignette("scene-contract").
scene_model() no longer forces a second scene compile. Its viewport
id↔name capture must compile (a cache hit would skip the capture), but it now
writes that compiled backend to the render cache, so a scene_svg() / render()
that follows in the same build reuses it instead of recompiling. Restores the
single-compile behaviour the earlier scene_model() change had lost.
Scene contract: scene_model()$panels now carries per-panel geometry and
metadata. Each panel row gains its panel viewport's resolved device-px
rectangle (px0,py0,px1,py1 — the true data region from the layout solve, not
the element-extent bbox), its native coordinate ranges
(xscale_lo/hi, yscale_lo/hi), and a meta list-column. Together the pixel
rect and native range give the affine a host needs to map device pixels back to
native (and thence data) coordinates. The existing element-extent x0..y1
columns are unchanged, so this is additive.
vl_viewport(meta=): a panel-level free-form metadata channel. A named
viewport can carry arbitrary meta (any R object), surfaced as the meta column
of scene_model()$panels. Like grob meta, it never crosses to the rendering
backend and vellum names no conventions for it — it is the panel-scoped
counterpart of per-element meta, for host conventions such as axis/scale
descriptors. See vignette("scene-contract").
Faster, leaner keyed SVG emission. Emitting a scene to SVG with per-element
data-keys (the interactivity attributes) now writes each element straight into
the output buffer instead of building and copying an intermediate string per
element, memoises the fill attribute across a batch's shared paint, holds the
current element key as an Rc<str>, and skips escaping work for keys/ids/labels
that contain no XML metacharacters (the common case). Output is byte-identical;
a 150k-point keyed scatter's scene_svg() is ~12% faster with substantially
fewer per-element allocations.
vl_strwidth() / vl_strheight() measure md() labels. Both now accept a
rich label from md() (or a list of them) in addition to character strings,
measuring it through the same run composition the renderer draws — so
super/subscripts and bold runs reserve the space they actually occupy.
family/fontface/fontsize supply the base style the label's runs are
relative to. Previously a caller had to reduce a rich label to plain text (and
as.character() on an md() object errors), so downstream layout code that
measured a rich title got zero width and clipped it.
Line & segment datashading. datashade_lines() and datashade_segments()
extend the aggregate-then-shade engine from point clouds to dense lines. A new
anti-aliased Rust line rasteriser accumulates coverage per grid cell (overlapping
lines add), so a bundle of hundreds of timeseries or a graph of tens of
thousands of edges renders at cost decoupled from the vertex count, as one
raster_grob(). datashade_lines() takes a connected polyline with an optional
group id (packing many series into one call; NA also breaks the line);
datashade_segments() takes independent (x0,y0)->(x1,y1) segments (the
network-edge case). Both share datashade()'s colors/how/span/clip
shading and per-line weight. See the Datashading article and
inst/examples/lines.R.
Pixel spreading (spread() / dynspread()). Dilate the non-empty pixels of
any raster grob so thin marks stay visible — datashader's spread (fixed radius)
and dynspread (radius chosen from image density). Available standalone, or via
a spread = argument on the datashade* functions (spread = 2 for a fixed
radius, spread = "auto" for dynspread).
Focal / two-circle radial gradients. radial_gradient() gained fx, fy,
fr — the focal (start) circle at stop offset 0, distinct from the outer
(end) circle cx/cy/r at offset 1. Offsetting fx/fy moves the highlight
off-centre (a sphere lit from one side); a non-zero fr gives an annular ramp
between the two circles. This matches grid's two-circle radialGradient() (the
previous concentric-only form could only place the highlight dead-centre). The
defaults (fx = cx, fy = cy, fr = 0) are the old concentric behaviour and
are byte-for-byte unchanged on every backend. Rendered identically on raster
(tiny-skia two-point conical), SVG (<radialGradient fx fy fr>), and PDF
(krilla). See inst/examples/gradients.R.
Hue-preserving (OKLCH) gradient interpolation. linear_gradient() and
radial_gradient() now also accept interpolation = "oklch", the polar form
of Oklab (lightness, chroma, hue). Hue and chroma move independently, so a ramp
between two saturated colours keeps its chroma through the middle instead of
desaturating toward grey the way a straight line in Oklab can — the hue sweeps
along the shorter arc (blue→yellow passes through green). An achromatic endpoint
(grey/black/white) borrows the other end's hue, so ramps to/from white don't
flash an arbitrary colour. Like "oklab" it is pre-sampled into dense sRGB
stops in the Rust core, so it renders identically on the raster, SVG, and PDF
backends with no new dependency. See inst/examples/gradient-interpolation.R
for a side-by-side of all three spaces.
Perceptual (Oklab) gradient interpolation. linear_gradient() and
radial_gradient() gained interpolation = "oklab", which blends the stops in
the perceptually-uniform Oklab space instead of sRGB — removing the muddy,
over-dark midtones and hue drift of sRGB blending (a blue→yellow ramp no longer
passes through a grey dead-zone). It works identically on the raster, SVG, and
PDF backends: the stops are pre-sampled in Oklab into dense sRGB stops, so no
backend colour-space support is needed. The default is "srgb" and is
byte-for-byte unchanged. (Implemented directly, with no new crate dependency.)
Two new marker shapes: "triangle_down" and "star". points_grob(shape=)
now accepts a downward-pointing triangle and a five-pointed star in addition to
circle/square/triangle/diamond/plus/cross. Like the other filled
shapes they paint gp$fill and outline with gp$col (so an open marker is
fill = NA with a col), and they take the same solid-fill fast path. The
grid-device shim now maps pch 6/25 to triangle_down (previously collapsed
onto the up-triangle) and pch 8 to star.
Slimmer Rust dependency tree. Bumped the direct tiny-skia (0.11 → 0.12)
and skrifa (0.31 → 0.42) crates to the versions the krilla PDF backend
already pulls in. This collapses six duplicated transitive crates (skrifa,
read-fonts, font-types, tiny-skia-path, png, and bitflags were each
compiled twice), taking the vendored tree from 75 to 69 crates for a smaller
source tarball and a faster build. Rendered output is unchanged — the raster
snapshots are pixel-for-pixel identical — and the minimum Rust version is still
1.92.
Categorical datashading (datashade(category=)). datashade() gained a
count_cat mode: pass category (a factor or vector, one value per point) and
each category is aggregated into its own count grid in the same single pass, then
every cell is coloured by the count-weighted average of the category hues it
holds, with opacity from the cell's total density. This shows which category
dominates where — and where categories mix — without overplotting bias, in one
call instead of a hand-stacked layer per category. When category is set,
colors is a per-category hue vector (named by level, or one per level in level
order) rather than a low-to-high ramp. Backed by a new rs_aggregate_2d_cat()
Rust aggregator (one O(N) pass, category-major grid). A datashade() call with no
category renders byte-for-byte as before.
Percentile / span colour clamping for datashade(). New span (absolute
c(lo, hi) density limits) and clip (a percentile pair like c(0.01, 0.99),
derived from the non-empty cell quantiles) clamp the density range before the
how transform, so a few extreme cells no longer flatten the rest. Both default
NULL (unchanged output). The shade step is now a reusable internal colormap
utility shared by the density and categorical paths.
Breaking: renamed the grid-colliding exports to a vl_ prefix so
attaching vellum no longer masks grid: gpar() → vl_gpar(), unit() →
vl_unit(), viewport() → vl_viewport(), arrow() → vl_arrow(), and
pattern() → vl_pattern(). The old names are removed (no aliases).
Multi-line and per-datum rich text. text_grob() labels may now contain
embedded newlines (\n), stacked baseline-to-baseline; md() gained the same
and is now vectorised — md(x) returns a single label for a length-1 x or
a list of labels for a vector, so a label grob can carry one distinct rich label
per position. Single-line, single-label output is byte-for-byte unchanged. (Rust
is untouched — shaping stays R-side.)
Accessibility (a11y). vl_scene(title=, desc=) and the new describe() setter
attach an accessible name and long description (alt text) to a scene. When set:
<svg role="img" aria-labelledby=…> and emits
<title>/<desc> (WCAG 1.1.1);Figure in the
structure tree carrying the description as Alt text.
Purely additive: a scene with no title/desc renders byte-for-byte as before. (Strict
PDF/UA-1 validation is a planned follow-up; the tag tree + Alt ship now.)datashade(weight=) now recycles a scalar and errors on a wrong-length vector,
instead of silently discarding a mismatched weight and reverting to a plain count.
native + mm / npc + mm units. A position unit combined with an
absolute unit now forms a compound unit — a data/panel anchor plus an exact
absolute offset — instead of erroring. unit(1, "native") + unit(2, "mm")
resolves to the native position shifted by exactly 2 mm at render, at any scale
or aspect (the offset is applied device-side after the base resolves). This is
the deferred "B1" route; it unlocks device-exact label nudges, halos, and
drop-shadow offsets in the grammar layer. Mixing two different position bases
(e.g. npc and native) still errors. Unit arithmetic scales the base and the
offset together. Additive change: a scene using no compound units renders
byte-for-byte as before.First release. vellum is a low-level graphics framework for R in the spirit of
grid, with a Rust backend: you describe a scene through a small declarative R
API, and the scene graph, unit/layout engine, and rendering all run in Rust.
vl_scene() and a pipeline of push(),
draw(), and pop() over an immutable tree.render() draws the same scene to PNG, SVG, or PDF, picking the backend
from the file extension — raster via tiny-skia,
PDF via krilla, SVG hand-rolled. Output
is byte-stable and snapshot-testable.display() draws a scene into the active graphics device; scene_raster() /
scene_svg() return the rendered scene in memory.unit(), grobwidth, ...) and nested viewport()s with their
own scales, rotation, and arbitrary-path clipping, plus a row/column layout
solver with "null" (flexible) tracks.viewport(alpha =)), reusable style()s, and hand-drawn sketch()
rendering.md().datashade() aggregates millions of points into a density raster in a single
pass — cost scales with output pixels, not point count — with no overplotting
and small output files.node_names() / get_node() / edit_node(),
hit_test() to pick the topmost grob under a point, and scene_model() to
serialize a per-element model (data keys, bounding boxes) — the foundation the
vellumplot grammar and the vellumwidget widget layer build on.as_vellum() / render_grid() render an existing grid grob tree —
including ggplot2 and lattice — through the vellum backend.