Fix Vello renderer cropping wide miter joins and square caps (#4108)

* Fix renderer compositing layers cropping wide miter joins and square caps

* Fix double-scaled inflation on the Vello blend layer's clip rect
This commit is contained in:
Keavon Chambers 2026-05-05 22:02:02 -07:00 committed by GitHub
parent 644e9cf91e
commit 5becf132e7
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
2 changed files with 27 additions and 6 deletions

View File

@ -1025,8 +1025,8 @@ impl Render for Table<Vector> {
let mut svg = SvgRender::new();
vector_item.render_svg(&mut svg, &render_params.for_alignment(applied_stroke_transform));
let stroke = vector.style.stroke().unwrap();
let weight = stroke.effective_width() * max_scale(applied_stroke_transform);
let quad = Quad::from_box(transformed_bounds).inflate(weight);
let inflation = stroke.max_aabb_inflation() * max_scale(applied_stroke_transform);
let quad = Quad::from_box(transformed_bounds).inflate(inflation);
let (x, y) = quad.top_left().into();
let (width, height) = (quad.bottom_right() - quad.top_left()).into();
@ -1147,8 +1147,14 @@ impl Render for Table<Vector> {
let opacity = (opacity_attr * if render_params.for_mask { 1. } else { opacity_fill_attr }) as f32;
if opacity < 1. || blend_mode_attr != BlendMode::default() {
layer = true;
let weight = element.style.stroke().as_ref().map_or(0., Stroke::effective_width);
let quad = Quad::from_box(layer_bounds).inflate(weight * max_scale(applied_stroke_transform));
// `max_aabb_inflation` is in `applied_stroke_transform`-space (where the stroke is drawn).
// `layer_bounds` is in path-local coords and `push_layer` re-applies `multiplied_transform`.
// Divide by `max_scale(applied_stroke_transform)` so the rect, after Vello's transform, ends at the right scene extent.
// Skip on a degenerate transform since nothing renders in that case.
let scale = max_scale(applied_stroke_transform);
let stroke_inflation = element.style.stroke().as_ref().map_or(0., Stroke::max_aabb_inflation);
let inflate_amount = if scale > 0. { stroke_inflation / scale } else { 0. };
let quad = Quad::from_box(layer_bounds).inflate(inflate_amount);
let layer_bounds = quad.bounding_box();
scene.push_layer(
peniko::Fill::NonZero,
@ -1306,8 +1312,8 @@ impl Render for Table<Vector> {
);
let bounds = element.bounding_box_with_transform(multiplied_transform).unwrap_or(layer_bounds);
let weight = element.style.stroke().as_ref().map_or(0., Stroke::effective_width);
let quad = Quad::from_box(bounds).inflate(weight * max_scale(applied_stroke_transform));
let inflation = element.style.stroke().as_ref().map_or(0., Stroke::max_aabb_inflation);
let quad = Quad::from_box(bounds).inflate(inflation * max_scale(applied_stroke_transform));
let bounds = quad.bounding_box();
let rect = kurbo::Rect::new(bounds[0].x, bounds[0].y, bounds[1].x, bounds[1].y);

View File

@ -417,6 +417,21 @@ impl Stroke {
}
}
/// Worst-case upper bound on the perpendicular extent (per side) of the visible stroke from the path
/// centerline, accounting for stroke alignment, miter join overshoot, and square cap diagonal extent.
/// Used as a cheap, safe inflation amount for renderer clip rects so alignment compositing layers
/// don't crop the actual stroke geometry. Constant-time — no path traversal.
///
/// Tight for round/bevel joins with butt/round caps. Otherwise overestimates: miter joins are assumed
/// to reach the miter limit at every join (most don't), and square caps are assumed to sit at 45° to
/// the axes (rarely the case). For an exact bound, use `Vector::stroke_inclusive_bounding_box_with_transform`
/// at the cost of running kurbo to compute the stroke's outline path.
pub fn max_aabb_inflation(&self) -> f64 {
let join_factor = if self.join == StrokeJoin::Miter { self.join_miter_limit.max(1.) } else { 1. };
let cap_factor = if self.cap == StrokeCap::Square { core::f64::consts::SQRT_2 } else { 1. };
self.effective_width() * 0.5 * join_factor.max(cap_factor)
}
pub fn dash_lengths(&self) -> String {
if self.dash_lengths.is_empty() {
"none".to_string()