From 20e97a87f513c1c36337a3e5c6da56076b28f2a6 Mon Sep 17 00:00:00 2001 From: Keavon Chambers Date: Tue, 5 May 2026 18:40:11 -0700 Subject: [PATCH 1/5] Fix blend layers wrongly cropping Inside-aligned strokes on open paths --- node-graph/libraries/rendering/src/renderer.rs | 10 +++++++--- .../libraries/vector-types/src/vector/style.rs | 15 +++++++++++++-- 2 files changed, 20 insertions(+), 5 deletions(-) diff --git a/node-graph/libraries/rendering/src/renderer.rs b/node-graph/libraries/rendering/src/renderer.rs index d878780118..7a23327d78 100644 --- a/node-graph/libraries/rendering/src/renderer.rs +++ b/node-graph/libraries/rendering/src/renderer.rs @@ -1025,7 +1025,8 @@ impl Render for Table { 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 inflation = stroke.max_aabb_inflation() * max_scale(applied_stroke_transform); + // `push_id` is only `Some` when `can_draw_aligned_stroke`, which is gated on `path_is_closed` + let inflation = stroke.max_aabb_inflation(true) * 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(); @@ -1150,9 +1151,11 @@ impl Render for Table { // `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. + // Pass `path_is_closed` so an Inside-aligned stroke on an open path doesn't inflate to 0 and crop the centered fallback. // Skip on a degenerate transform since nothing renders in that case. + let path_is_closed = element.stroke_bezier_paths().all(|p| p.closed()); let scale = max_scale(applied_stroke_transform); - let stroke_inflation = element.style.stroke().as_ref().map_or(0., Stroke::max_aabb_inflation); + let stroke_inflation = element.style.stroke().as_ref().map_or(0., |stroke| stroke.max_aabb_inflation(path_is_closed)); 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(); @@ -1312,7 +1315,8 @@ impl Render for Table { ); let bounds = element.bounding_box_with_transform(multiplied_transform).unwrap_or(layer_bounds); - let inflation = element.style.stroke().as_ref().map_or(0., Stroke::max_aabb_inflation); + // This branch is gated on `can_draw_aligned_stroke`, which already requires every subpath is closed + let inflation = element.style.stroke().as_ref().map_or(0., |stroke| stroke.max_aabb_inflation(true)); 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); diff --git a/node-graph/libraries/vector-types/src/vector/style.rs b/node-graph/libraries/vector-types/src/vector/style.rs index 3db7c214de..f9a5b1236c 100644 --- a/node-graph/libraries/vector-types/src/vector/style.rs +++ b/node-graph/libraries/vector-types/src/vector/style.rs @@ -422,14 +422,25 @@ impl Stroke { /// 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. /// + /// `path_is_closed` indicates whether every subpath of the vector being measured is closed. The renderer + /// only honors stroke alignment for fully-closed paths and falls back to drawing a Center-aligned + /// `weight`-wide stroke otherwise, so callers must pass `false` when any subpath is open or an + /// `Inside`-aligned stroke would silently get an inflation of `0` and crop at the blend layer. + /// /// 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 { + pub fn max_aabb_inflation(&self, path_is_closed: bool) -> f64 { + // Match the renderer: stroke alignment only applies to closed paths; open paths render as Center + let half_width = if self.align != StrokeAlign::Center && path_is_closed { + self.effective_width() + } else { + self.weight + } * 0.5; 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) + half_width * join_factor.max(cap_factor) } pub fn dash_lengths(&self) -> String { From 3b48aae8fd732766567186e6fbacd39527d31c44 Mon Sep 17 00:00:00 2001 From: Keavon Chambers Date: Tue, 5 May 2026 22:47:42 -0700 Subject: [PATCH 2/5] Skip the closed-subpath check when stroke alignment can't apply --- node-graph/libraries/rendering/src/renderer.rs | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/node-graph/libraries/rendering/src/renderer.rs b/node-graph/libraries/rendering/src/renderer.rs index 7a23327d78..e010eccb38 100644 --- a/node-graph/libraries/rendering/src/renderer.rs +++ b/node-graph/libraries/rendering/src/renderer.rs @@ -1153,9 +1153,13 @@ impl Render for Table { // Divide by `max_scale(applied_stroke_transform)` so the rect, after Vello's transform, ends at the right scene extent. // Pass `path_is_closed` so an Inside-aligned stroke on an open path doesn't inflate to 0 and crop the centered fallback. // Skip on a degenerate transform since nothing renders in that case. - let path_is_closed = element.stroke_bezier_paths().all(|p| p.closed()); + let stroke = element.style.stroke(); + // `path_is_closed` only changes `max_aabb_inflation`'s result when alignment renders, so skip + // the per-subpath traversal otherwise (Center align or no renderable stroke) + let needs_alignment_check = stroke.as_ref().is_some_and(|s| s.has_renderable_stroke() && s.align.is_not_centered()); + let path_is_closed = needs_alignment_check && element.stroke_bezier_paths().all(|p| p.closed()); let scale = max_scale(applied_stroke_transform); - let stroke_inflation = element.style.stroke().as_ref().map_or(0., |stroke| stroke.max_aabb_inflation(path_is_closed)); + let stroke_inflation = stroke.as_ref().map_or(0., |s| s.max_aabb_inflation(path_is_closed)); 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(); From 5518bbf601eaa40de5c1afff2cf4ee1c984fdce3 Mon Sep 17 00:00:00 2001 From: Keavon Chambers Date: Tue, 5 May 2026 23:03:02 -0700 Subject: [PATCH 3/5] Compute `can_draw_aligned_stroke` once and share it with the blend layer --- .../libraries/rendering/src/renderer.rs | 20 +++++++++---------- 1 file changed, 9 insertions(+), 11 deletions(-) diff --git a/node-graph/libraries/rendering/src/renderer.rs b/node-graph/libraries/rendering/src/renderer.rs index e010eccb38..199b543c81 100644 --- a/node-graph/libraries/rendering/src/renderer.rs +++ b/node-graph/libraries/rendering/src/renderer.rs @@ -1145,21 +1145,22 @@ impl Render for Table { }; let mut layer = false; + // Whether the renderer will engage the stroke-alignment compositing trick (non-Center align on a fully closed path). + // Used by both the blend-layer clip rect inflation below (as `max_aabb_inflation`'s `path_is_closed` arg, equivalent here since + // the function ignores the arg for Center align) and the `SrcIn`/`SrcOut` aligned-stroke branch further down. + let stroke = element.style.stroke(); + 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()); + 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; // `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. - // Pass `path_is_closed` so an Inside-aligned stroke on an open path doesn't inflate to 0 and crop the centered fallback. + // Pass `can_draw_aligned_stroke` so an Inside-aligned stroke on an open path doesn't inflate to 0 and crop the centered fallback. // Skip on a degenerate transform since nothing renders in that case. - let stroke = element.style.stroke(); - // `path_is_closed` only changes `max_aabb_inflation`'s result when alignment renders, so skip - // the per-subpath traversal otherwise (Center align or no renderable stroke) - let needs_alignment_check = stroke.as_ref().is_some_and(|s| s.has_renderable_stroke() && s.align.is_not_centered()); - let path_is_closed = needs_alignment_check && element.stroke_bezier_paths().all(|p| p.closed()); let scale = max_scale(applied_stroke_transform); - let stroke_inflation = stroke.as_ref().map_or(0., |s| s.max_aabb_inflation(path_is_closed)); + let stroke_inflation = stroke.as_ref().map_or(0., |s| s.max_aabb_inflation(can_draw_aligned_stroke)); 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(); @@ -1172,11 +1173,8 @@ impl Render for Table { ); } - let can_draw_aligned_stroke = - element.style.stroke().is_some_and(|stroke| stroke.has_renderable_stroke() && stroke.align.is_not_centered()) && element.stroke_bezier_paths().all(|path| path.closed()); - let use_layer = can_draw_aligned_stroke; - let wants_stroke_below = element.style.stroke().is_some_and(|s| s.paint_order == vector::style::PaintOrder::StrokeBelow); + let wants_stroke_below = stroke.as_ref().is_some_and(|s| s.paint_order == vector::style::PaintOrder::StrokeBelow); // Closures to avoid duplicated fill/stroke drawing logic let do_fill_path = |scene: &mut Scene, path: &kurbo::BezPath, fill_rule: peniko::Fill| match element.style.fill() { From 346f6f9a8ce7c69738d41181799ba8bd86c2dac7 Mon Sep 17 00:00:00 2001 From: Keavon Chambers Date: Tue, 5 May 2026 23:42:51 -0700 Subject: [PATCH 4/5] Use the actual axis scale, not the column-vector diagonal, when sizing stroke clip rects --- .../libraries/rendering/src/renderer.rs | 25 +++++++++---------- 1 file changed, 12 insertions(+), 13 deletions(-) diff --git a/node-graph/libraries/rendering/src/renderer.rs b/node-graph/libraries/rendering/src/renderer.rs index 199b543c81..b27d952e8f 100644 --- a/node-graph/libraries/rendering/src/renderer.rs +++ b/node-graph/libraries/rendering/src/renderer.rs @@ -250,10 +250,12 @@ pub fn format_transform_matrix(transform: DAffine2) -> String { }) + ")" } -fn max_scale(transform: DAffine2) -> f64 { - let sx = transform.x_axis.length_squared(); - let sy = transform.y_axis.length_squared(); - (sx + sy).sqrt() +fn axial_max_scale(transform: DAffine2) -> f64 { + transform.x_axis.length().max(transform.y_axis.length()) +} + +fn axial_min_scale(transform: DAffine2) -> f64 { + transform.x_axis.length().min(transform.y_axis.length()) } pub fn black_or_white_for_best_contrast(background: Option) -> Color { @@ -1026,7 +1028,7 @@ impl Render for Table { vector_item.render_svg(&mut svg, &render_params.for_alignment(applied_stroke_transform)); let stroke = vector.style.stroke().unwrap(); // `push_id` is only `Some` when `can_draw_aligned_stroke`, which is gated on `path_is_closed` - let inflation = stroke.max_aabb_inflation(true) * max_scale(applied_stroke_transform); + let inflation = stroke.max_aabb_inflation(true) * axial_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(); @@ -1154,14 +1156,11 @@ impl Render for Table { 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; - // `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. - // Pass `can_draw_aligned_stroke` so an Inside-aligned stroke on an open path doesn't inflate to 0 and crop the centered fallback. - // Skip on a degenerate transform since nothing renders in that case. - let scale = max_scale(applied_stroke_transform); + // `max_aabb_inflation` is in `applied_stroke_transform`-space; `layer_bounds` is path-local and `push_layer` re-applies `multiplied_transform`. + // Divide by the smaller axial scale to cover the stroke in both axes after Vello's transform. Skip on a degenerate transform. + let axial_scale = axial_min_scale(applied_stroke_transform); let stroke_inflation = stroke.as_ref().map_or(0., |s| s.max_aabb_inflation(can_draw_aligned_stroke)); - let inflate_amount = if scale > 0. { stroke_inflation / scale } else { 0. }; + let inflate_amount = if axial_scale > 0. { stroke_inflation / axial_scale } else { 0. }; let quad = Quad::from_box(layer_bounds).inflate(inflate_amount); let layer_bounds = quad.bounding_box(); scene.push_layer( @@ -1319,7 +1318,7 @@ impl Render for Table { let bounds = element.bounding_box_with_transform(multiplied_transform).unwrap_or(layer_bounds); // This branch is gated on `can_draw_aligned_stroke`, which already requires every subpath is closed let inflation = element.style.stroke().as_ref().map_or(0., |stroke| stroke.max_aabb_inflation(true)); - let quad = Quad::from_box(bounds).inflate(inflation * max_scale(applied_stroke_transform)); + let quad = Quad::from_box(bounds).inflate(inflation * axial_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); From e5e93ba53789c010e4511d79b2bda5cfdcd6e2b2 Mon Sep 17 00:00:00 2001 From: Keavon Chambers Date: Wed, 6 May 2026 00:09:52 -0700 Subject: [PATCH 5/5] Fix under skew --- .../libraries/rendering/src/renderer.rs | 32 +++++++++++++------ 1 file changed, 22 insertions(+), 10 deletions(-) diff --git a/node-graph/libraries/rendering/src/renderer.rs b/node-graph/libraries/rendering/src/renderer.rs index b27d952e8f..9c166d6c4f 100644 --- a/node-graph/libraries/rendering/src/renderer.rs +++ b/node-graph/libraries/rendering/src/renderer.rs @@ -250,12 +250,22 @@ pub fn format_transform_matrix(transform: DAffine2) -> String { }) + ")" } -fn axial_max_scale(transform: DAffine2) -> f64 { - transform.x_axis.length().max(transform.y_axis.length()) -} - -fn axial_min_scale(transform: DAffine2) -> f64 { - transform.x_axis.length().min(transform.y_axis.length()) +/// `(max, min)` factors by which a unit vector is stretched under `transform`'s linear part — the +/// principal and minor singular values, equal to the semi-axes of the ellipse a unit circle maps to. +/// Equivalent to `(max(sx, sy), min(sx, sy))` for axis-aligned scales, but accounts for shear. +fn singular_values(transform: DAffine2) -> (f64, f64) { + let m = transform.matrix2; + let a = m.x_axis.x; + let b = m.x_axis.y; + let c = m.y_axis.x; + let d = m.y_axis.y; + // Eigenvalues of MᵀM via the closed form for a 2×2, both are non-negative + let trace = a * a + b * b + c * c + d * d; + let det = a * d - b * c; + let discriminant = (trace * trace - 4. * det * det).max(0.).sqrt(); + let largest_eigenvalue = (trace + discriminant) * 0.5; + let smallest_eigenvalue = ((trace - discriminant) * 0.5).max(0.); + (largest_eigenvalue.sqrt(), smallest_eigenvalue.sqrt()) } pub fn black_or_white_for_best_contrast(background: Option) -> Color { @@ -1028,7 +1038,8 @@ impl Render for Table { vector_item.render_svg(&mut svg, &render_params.for_alignment(applied_stroke_transform)); let stroke = vector.style.stroke().unwrap(); // `push_id` is only `Some` when `can_draw_aligned_stroke`, which is gated on `path_is_closed` - let inflation = stroke.max_aabb_inflation(true) * axial_max_scale(applied_stroke_transform); + let (largest_scale, _) = singular_values(applied_stroke_transform); + let inflation = stroke.max_aabb_inflation(true) * largest_scale; 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(); @@ -1158,9 +1169,9 @@ impl Render for Table { layer = true; // `max_aabb_inflation` is in `applied_stroke_transform`-space; `layer_bounds` is path-local and `push_layer` re-applies `multiplied_transform`. // Divide by the smaller axial scale to cover the stroke in both axes after Vello's transform. Skip on a degenerate transform. - let axial_scale = axial_min_scale(applied_stroke_transform); + let (_, smallest_scale) = singular_values(applied_stroke_transform); let stroke_inflation = stroke.as_ref().map_or(0., |s| s.max_aabb_inflation(can_draw_aligned_stroke)); - let inflate_amount = if axial_scale > 0. { stroke_inflation / axial_scale } else { 0. }; + let inflate_amount = if smallest_scale > 0. { stroke_inflation / smallest_scale } else { 0. }; let quad = Quad::from_box(layer_bounds).inflate(inflate_amount); let layer_bounds = quad.bounding_box(); scene.push_layer( @@ -1318,7 +1329,8 @@ impl Render for Table { let bounds = element.bounding_box_with_transform(multiplied_transform).unwrap_or(layer_bounds); // This branch is gated on `can_draw_aligned_stroke`, which already requires every subpath is closed let inflation = element.style.stroke().as_ref().map_or(0., |stroke| stroke.max_aabb_inflation(true)); - let quad = Quad::from_box(bounds).inflate(inflation * axial_max_scale(applied_stroke_transform)); + let (largest_scale, _) = singular_values(applied_stroke_transform); + let quad = Quad::from_box(bounds).inflate(inflation * largest_scale); let bounds = quad.bounding_box(); let rect = kurbo::Rect::new(bounds[0].x, bounds[0].y, bounds[1].x, bounds[1].y);