Skip to content

Commit 269a217

Browse files
authored
Fix Vello renderer cropping open paths with Inside-aligned strokes and an opacity or blend mode set (#4109)
* Fix blend layers wrongly cropping Inside-aligned strokes on open paths * Skip the closed-subpath check when stroke alignment can't apply * Compute `can_draw_aligned_stroke` once and share it with the blend layer * Use the actual axis scale, not the column-vector diagonal, when sizing stroke clip rects * Fix under skew
1 parent 5becf13 commit 269a217

2 files changed

Lines changed: 48 additions & 20 deletions

File tree

node-graph/libraries/rendering/src/renderer.rs

Lines changed: 35 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -250,10 +250,22 @@ pub fn format_transform_matrix(transform: DAffine2) -> String {
250250
}) + ")"
251251
}
252252

253-
fn max_scale(transform: DAffine2) -> f64 {
254-
let sx = transform.x_axis.length_squared();
255-
let sy = transform.y_axis.length_squared();
256-
(sx + sy).sqrt()
253+
/// `(max, min)` factors by which a unit vector is stretched under `transform`'s linear part — the
254+
/// principal and minor singular values, equal to the semi-axes of the ellipse a unit circle maps to.
255+
/// Equivalent to `(max(sx, sy), min(sx, sy))` for axis-aligned scales, but accounts for shear.
256+
fn singular_values(transform: DAffine2) -> (f64, f64) {
257+
let m = transform.matrix2;
258+
let a = m.x_axis.x;
259+
let b = m.x_axis.y;
260+
let c = m.y_axis.x;
261+
let d = m.y_axis.y;
262+
// Eigenvalues of MᵀM via the closed form for a 2×2, both are non-negative
263+
let trace = a * a + b * b + c * c + d * d;
264+
let det = a * d - b * c;
265+
let discriminant = (trace * trace - 4. * det * det).max(0.).sqrt();
266+
let largest_eigenvalue = (trace + discriminant) * 0.5;
267+
let smallest_eigenvalue = ((trace - discriminant) * 0.5).max(0.);
268+
(largest_eigenvalue.sqrt(), smallest_eigenvalue.sqrt())
257269
}
258270

259271
pub fn black_or_white_for_best_contrast(background: Option<Color>) -> Color {
@@ -1025,7 +1037,9 @@ impl Render for Table<Vector> {
10251037
let mut svg = SvgRender::new();
10261038
vector_item.render_svg(&mut svg, &render_params.for_alignment(applied_stroke_transform));
10271039
let stroke = vector.style.stroke().unwrap();
1028-
let inflation = stroke.max_aabb_inflation() * max_scale(applied_stroke_transform);
1040+
// `push_id` is only `Some` when `can_draw_aligned_stroke`, which is gated on `path_is_closed`
1041+
let (largest_scale, _) = singular_values(applied_stroke_transform);
1042+
let inflation = stroke.max_aabb_inflation(true) * largest_scale;
10291043
let quad = Quad::from_box(transformed_bounds).inflate(inflation);
10301044
let (x, y) = quad.top_left().into();
10311045
let (width, height) = (quad.bottom_right() - quad.top_left()).into();
@@ -1144,16 +1158,20 @@ impl Render for Table<Vector> {
11441158
};
11451159
let mut layer = false;
11461160

1161+
// Whether the renderer will engage the stroke-alignment compositing trick (non-Center align on a fully closed path).
1162+
// Used by both the blend-layer clip rect inflation below (as `max_aabb_inflation`'s `path_is_closed` arg, equivalent here since
1163+
// the function ignores the arg for Center align) and the `SrcIn`/`SrcOut` aligned-stroke branch further down.
1164+
let stroke = element.style.stroke();
1165+
let can_draw_aligned_stroke = stroke.as_ref().is_some_and(|s| s.has_renderable_stroke() && s.align.is_not_centered()) && element.stroke_bezier_paths().all(|p| p.closed());
1166+
11471167
let opacity = (opacity_attr * if render_params.for_mask { 1. } else { opacity_fill_attr }) as f32;
11481168
if opacity < 1. || blend_mode_attr != BlendMode::default() {
11491169
layer = true;
1150-
// `max_aabb_inflation` is in `applied_stroke_transform`-space (where the stroke is drawn).
1151-
// `layer_bounds` is in path-local coords and `push_layer` re-applies `multiplied_transform`.
1152-
// Divide by `max_scale(applied_stroke_transform)` so the rect, after Vello's transform, ends at the right scene extent.
1153-
// Skip on a degenerate transform since nothing renders in that case.
1154-
let scale = max_scale(applied_stroke_transform);
1155-
let stroke_inflation = element.style.stroke().as_ref().map_or(0., Stroke::max_aabb_inflation);
1156-
let inflate_amount = if scale > 0. { stroke_inflation / scale } else { 0. };
1170+
// `max_aabb_inflation` is in `applied_stroke_transform`-space; `layer_bounds` is path-local and `push_layer` re-applies `multiplied_transform`.
1171+
// Divide by the smaller axial scale to cover the stroke in both axes after Vello's transform. Skip on a degenerate transform.
1172+
let (_, smallest_scale) = singular_values(applied_stroke_transform);
1173+
let stroke_inflation = stroke.as_ref().map_or(0., |s| s.max_aabb_inflation(can_draw_aligned_stroke));
1174+
let inflate_amount = if smallest_scale > 0. { stroke_inflation / smallest_scale } else { 0. };
11571175
let quad = Quad::from_box(layer_bounds).inflate(inflate_amount);
11581176
let layer_bounds = quad.bounding_box();
11591177
scene.push_layer(
@@ -1165,11 +1183,8 @@ impl Render for Table<Vector> {
11651183
);
11661184
}
11671185

1168-
let can_draw_aligned_stroke =
1169-
element.style.stroke().is_some_and(|stroke| stroke.has_renderable_stroke() && stroke.align.is_not_centered()) && element.stroke_bezier_paths().all(|path| path.closed());
1170-
11711186
let use_layer = can_draw_aligned_stroke;
1172-
let wants_stroke_below = element.style.stroke().is_some_and(|s| s.paint_order == vector::style::PaintOrder::StrokeBelow);
1187+
let wants_stroke_below = stroke.as_ref().is_some_and(|s| s.paint_order == vector::style::PaintOrder::StrokeBelow);
11731188

11741189
// Closures to avoid duplicated fill/stroke drawing logic
11751190
let do_fill_path = |scene: &mut Scene, path: &kurbo::BezPath, fill_rule: peniko::Fill| match element.style.fill() {
@@ -1312,8 +1327,10 @@ impl Render for Table<Vector> {
13121327
);
13131328

13141329
let bounds = element.bounding_box_with_transform(multiplied_transform).unwrap_or(layer_bounds);
1315-
let inflation = element.style.stroke().as_ref().map_or(0., Stroke::max_aabb_inflation);
1316-
let quad = Quad::from_box(bounds).inflate(inflation * max_scale(applied_stroke_transform));
1330+
// This branch is gated on `can_draw_aligned_stroke`, which already requires every subpath is closed
1331+
let inflation = element.style.stroke().as_ref().map_or(0., |stroke| stroke.max_aabb_inflation(true));
1332+
let (largest_scale, _) = singular_values(applied_stroke_transform);
1333+
let quad = Quad::from_box(bounds).inflate(inflation * largest_scale);
13171334
let bounds = quad.bounding_box();
13181335
let rect = kurbo::Rect::new(bounds[0].x, bounds[0].y, bounds[1].x, bounds[1].y);
13191336

node-graph/libraries/vector-types/src/vector/style.rs

Lines changed: 13 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -422,14 +422,25 @@ impl Stroke {
422422
/// Used as a cheap, safe inflation amount for renderer clip rects so alignment compositing layers
423423
/// don't crop the actual stroke geometry. Constant-time — no path traversal.
424424
///
425+
/// `path_is_closed` indicates whether every subpath of the vector being measured is closed. The renderer
426+
/// only honors stroke alignment for fully-closed paths and falls back to drawing a Center-aligned
427+
/// `weight`-wide stroke otherwise, so callers must pass `false` when any subpath is open or an
428+
/// `Inside`-aligned stroke would silently get an inflation of `0` and crop at the blend layer.
429+
///
425430
/// Tight for round/bevel joins with butt/round caps. Otherwise overestimates: miter joins are assumed
426431
/// to reach the miter limit at every join (most don't), and square caps are assumed to sit at 45° to
427432
/// the axes (rarely the case). For an exact bound, use `Vector::stroke_inclusive_bounding_box_with_transform`
428433
/// at the cost of running kurbo to compute the stroke's outline path.
429-
pub fn max_aabb_inflation(&self) -> f64 {
434+
pub fn max_aabb_inflation(&self, path_is_closed: bool) -> f64 {
435+
// Match the renderer: stroke alignment only applies to closed paths; open paths render as Center
436+
let half_width = if self.align != StrokeAlign::Center && path_is_closed {
437+
self.effective_width()
438+
} else {
439+
self.weight
440+
} * 0.5;
430441
let join_factor = if self.join == StrokeJoin::Miter { self.join_miter_limit.max(1.) } else { 1. };
431442
let cap_factor = if self.cap == StrokeCap::Square { core::f64::consts::SQRT_2 } else { 1. };
432-
self.effective_width() * 0.5 * join_factor.max(cap_factor)
443+
half_width * join_factor.max(cap_factor)
433444
}
434445

435446
pub fn dash_lengths(&self) -> String {

0 commit comments

Comments
 (0)