From 6db58d32cb17ff18e4f1e32613ab5df4955e942c Mon Sep 17 00:00:00 2001 From: MortenHusted Date: Thu, 22 Oct 2026 09:57:31 +0200 Subject: [PATCH] M4: individual stroke weights follow the corner radius like CSS borders stroke_area used rect_side_strokes only for square corners; a rounded rect-like layer with individual weights fell through to a uniform ring at stroke.weight (canvas, SVG, PDF, shadows, shape builder, outline stroke). pathops::rounded_side_strokes now draws them as CSS does: the outer edge is the rounded rect (the fill outline for inside strokes), the inner edge is inset by each side's weight with elliptical corners max(0, R - weight), so border-l-4 rounded-r-lg is a straight bar and a weight that differs across a rounded corner tapers through it. Center and outside grow the outer box and each non-zero radius by half or all of the weight (CSS has only inside). The inner edge runs against the outer one, so the ring is a ring under non-zero too; where it could leave the outer edge (radii shrunk to fit a narrow inner box, smoothed outer corners) it is cut out instead. --- crates/pathops/src/lib.rs | 90 ++++++++++++++ .../pathops/src/rounded_side_strokes_tests.rs | 114 ++++++++++++++++++ crates/render/src/draw.rs | 8 +- crates/render/src/tests.rs | 21 ++++ crates/svg/src/tests.rs | 20 +++ 5 files changed, 251 insertions(+), 2 deletions(-) create mode 100644 crates/pathops/src/rounded_side_strokes_tests.rs diff --git a/crates/pathops/src/lib.rs b/crates/pathops/src/lib.rs index fb9cfcfe..ca2ee0b6 100644 --- a/crates/pathops/src/lib.rs +++ b/crates/pathops/src/lib.rs @@ -13,6 +13,8 @@ mod brush_tests; mod ops_tests; mod points; #[cfg(test)] +mod rounded_side_strokes_tests; +#[cfg(test)] mod sides_tests; pub mod width; #[cfg(test)] @@ -451,6 +453,94 @@ pub fn rect_side_strokes(w: f64, h: f64, sides: [f64; 4], align: StrokeAlign) -> p } +/// Per-side stroke of a rectangle with corner radii `[tl, tr, br, bl]`, like CSS borders: the outer +/// edge is the rounded rectangle (center and outside grow its box by half or all of each side's +/// weight, and each non-zero radius with it); the inner edge is inset by each side's weight, with +/// elliptical corners `max(0, R − weight)` across and down (square where either is 0). An even-odd +/// ring whose inner edge runs against the outer one, so it is a ring under non-zero too. +pub fn rounded_side_strokes(w: f64, h: f64, radii: [f64; 4], smoothing: f64, sides: [f64; 4], align: StrokeAlign) -> BezPath { + let [t, r, b, l] = sides.map(non_negative); + if t + r + b + l <= 0.0 { + return BezPath::new(); + } + let f = match align { + StrokeAlign::Inside => 0.0, + StrokeAlign::Center => 0.5, + StrokeAlign::Outside => 1.0, + }; + // Clamped like the fill: at most half the shorter side. + let max_r = non_negative(w.min(h)) / 2.0; + let radii = radii.map(|v| non_negative(v).min(max_r)); + // Each corner's radius with the weights across and down it, clockwise from the top-left. + let corners = [(radii[0], l, t), (radii[1], r, t), (radii[2], r, b), (radii[3], l, b)]; + let outer = if align == StrokeAlign::Inside { + figcraft_geom::rounded_rect(w, h, radii, smoothing) + } else { + let grown = corners.map(|(rr, x, y)| if rr > 0.0 { (rr + x * f, rr + y * f) } else { (0.0, 0.0) }); + elliptical_rect(figcraft_geom::Rect::new(-l * f, -t * f, w + r * f, h + b * f), grown) + }; + let k = 1.0 - f; + let inner = figcraft_geom::Rect::new(l * k, t * k, w - r * k, h - b * k); + if inner.width() <= 0.0 || inner.height() <= 0.0 { + return outer; + } + let (inset, fit) = fit_corners(inner, corners.map(|(rr, x, y)| ((rr - x * k).max(0.0), (rr - y * k).max(0.0)))); + // The hole's corners sit on the outer ones, inside them, unless they were shrunk to fit or the + // outer ones are smoothed: then it may leave the outer edge. + ring(outer, &elliptical_rect(inner, inset), fit < 1.0 || (align == StrokeAlign::Inside && smoothing > 0.0)) +} + +fn non_negative(v: f64) -> f64 { + if v.is_finite() { v.max(0.0) } else { 0.0 } +} + +/// `outer` with `hole` taken out: cut out of it when the hole may leave it, else as an even-odd +/// ring whose hole runs against it. +fn ring(outer: BezPath, hole: &BezPath, cut: bool) -> BezPath { + if cut && let Ok(p) = boolean_step(&outer, hole, BoolOp::Subtract) { + return p; + } + let mut p = outer; + p.extend(hole.reverse_subpaths().elements().iter().copied()); + p +} + +/// Corner radii `(rx, ry)` for the inner box `inner`, square where either is 0, all shrunk by one +/// factor (returned too) where two on an edge add up to more than it, as CSS shrinks radii. +fn fit_corners(inner: figcraft_geom::Rect, corners: [(f64, f64); 4]) -> ([(f64, f64); 4], f64) { + let c = corners.map(|(rx, ry)| if rx > 0.0 && ry > 0.0 { (rx, ry) } else { (0.0, 0.0) }); + let [tl, tr, br, bl] = c; + let edges = [(inner.width(), tl.0 + tr.0), (inner.width(), bl.0 + br.0), (inner.height(), tl.1 + bl.1), (inner.height(), tr.1 + br.1)]; + let fit = edges.iter().filter(|(_, sum)| *sum > 0.0).fold(1.0_f64, |m, (len, sum)| m.min(len / sum)); + (c.map(|(rx, ry)| (rx * fit, ry * fit)), fit) +} + +/// Rectangle `r` with an `(rx, ry)` quarter ellipse at each corner `[tl, tr, br, bl]`, drawn like +/// `figcraft_geom::rounded_rect` (circular corners come out the same). +fn elliptical_rect(r: figcraft_geom::Rect, [tl, tr, br, bl]: [(f64, f64); 4]) -> BezPath { + use std::f64::consts::{FRAC_PI_2, PI}; + // Each corner, the direction of its ellipse centre from it, and the angle its quarter starts at. + let quarters = [ + (Point::new(r.x1, r.y0), (-1.0, 1.0), tr, -FRAC_PI_2), + (Point::new(r.x1, r.y1), (-1.0, -1.0), br, 0.0), + (Point::new(r.x0, r.y1), (1.0, -1.0), bl, FRAC_PI_2), + (Point::new(r.x0, r.y0), (1.0, 1.0), tl, PI), + ]; + let mut p = BezPath::new(); + p.move_to((r.x0 + tl.0, r.y0)); + for (corner, (sx, sy), (rx, ry), start) in quarters { + if rx > 0.0 && ry > 0.0 { + let c = Point::new(corner.x + sx * rx, corner.y + sy * ry); + p.line_to((c.x + rx * start.cos(), c.y + ry * start.sin())); + kurbo::Arc::new(c, (rx, ry), start, FRAC_PI_2, 0.0).to_cubic_beziers(0.01, |a, b, e| p.curve_to(a, b, e)); + } else { + p.line_to(corner); + } + } + p.close_path(); + p +} + #[cfg(test)] mod tests { use super::*; diff --git a/crates/pathops/src/rounded_side_strokes_tests.rs b/crates/pathops/src/rounded_side_strokes_tests.rs new file mode 100644 index 00000000..ee0e8281 --- /dev/null +++ b/crates/pathops/src/rounded_side_strokes_tests.rs @@ -0,0 +1,114 @@ +//! Per-side stroke weights on rounded rectangles, drawn like CSS borders: square corners, a bar +//! along a rounded edge, tapers through corners, alignments, inner corners that no longer fit, +//! smoothing and degenerate weights. + +use figcraft_doc::StrokeAlign; +use figcraft_geom::kurbo::{BezPath, Point, Shape}; +use figcraft_geom::rounded_rect; + +use crate::{rect_side_strokes, rounded_side_strokes}; + +const W: f64 = 120.0; +const H: f64 = 60.0; + +/// The area of a ring whose hole runs against its outer edge. +fn area(p: &BezPath) -> f64 { + p.area().abs() +} + +fn painted(p: &BezPath, x: f64, y: f64) -> bool { + p.winding(Point::new(x, y)) % 2 != 0 +} + +#[test] +fn square_corners_paint_what_rect_side_strokes_paints() { + for align in [StrokeAlign::Inside, StrokeAlign::Center, StrokeAlign::Outside] { + let sides = [1.0, 2.0, 3.0, 4.0]; + let a = area(&rounded_side_strokes(W, H, [0.0; 4], 0.0, sides, align)); + assert!((a - area(&rect_side_strokes(W, H, sides, align))).abs() < 1e-6, "{align:?}: {a}"); + } +} + +/// `border-l-4 rounded-r-lg`. +#[test] +fn a_left_border_on_a_right_rounded_box_is_a_straight_bar() { + let bar = rounded_side_strokes(W, H, [0.0, 8.0, 8.0, 0.0], 0.0, [0.0, 0.0, 0.0, 4.0], StrokeAlign::Inside); + assert!((area(&bar) - 4.0 * H).abs() < 1e-6, "{}", area(&bar)); + assert!(painted(&bar, 2.0, 30.0)); + for (x, y) in [(119.9, 30.0), (117.0, 1.0), (60.0, 0.1), (60.0, 59.9), (4.1, 30.0)] { + assert!(!painted(&bar, x, y), "({x}, {y})"); + } +} + +#[test] +fn one_weight_on_every_side_is_the_uniform_ring() { + let ring = rounded_side_strokes(W, H, [8.0; 4], 0.0, [2.0; 4], StrokeAlign::Inside); + let want = area(&rounded_rect(W, H, [8.0; 4], 0.0)) - area(&rounded_rect(W - 4.0, H - 4.0, [6.0; 4], 0.0)); + assert!((area(&ring) - want).abs() < 1e-6, "{} vs {want}", area(&ring)); + assert_eq!(ring.winding(Point::new(60.0, 30.0)), 0, "a hole under non-zero too"); +} + +#[test] +fn center_and_outside_grow_the_box_and_its_corners() { + for (align, out, inset) in [(StrokeAlign::Center, 1.0, 1.0), (StrokeAlign::Outside, 2.0, 0.0)] { + let a = area(&rounded_side_strokes(W, H, [8.0; 4], 0.0, [2.0; 4], align)); + let outer = area(&rounded_rect(W + 2.0 * out, H + 2.0 * out, [8.0 + out; 4], 0.0)); + let hole = area(&rounded_rect(W - 2.0 * inset, H - 2.0 * inset, [8.0 - inset; 4], 0.0)); + assert!((a - (outer - hole)).abs() < 1e-6, "{align:?}: {a} vs {}", outer - hole); + } +} + +/// 8 × 7 inner ellipses at the bottom corners. +#[test] +fn a_bottom_border_tapers_into_the_bottom_corners() { + let bottom = rounded_side_strokes(W, H, [8.0; 4], 0.0, [0.0, 0.0, 1.0, 0.0], StrokeAlign::Inside); + assert!(painted(&bottom, 60.0, 59.5) && painted(&bottom, 8.0, 59.5) && painted(&bottom, 112.0, 59.5)); + for (x, y) in [(60.0, 0.5), (0.5, 30.0), (0.5, 52.0), (119.5, 52.0), (4.0, 4.0)] { + assert!(!painted(&bottom, x, y), "({x}, {y})"); + } +} + +#[test] +fn a_weight_past_the_radius_leaves_square_inner_corners() { + let top = rounded_side_strokes(W, H, [8.0; 4], 0.0, [10.0, 0.0, 0.0, 2.0], StrokeAlign::Inside); + assert!(!painted(&top, 2.2, 10.2) && !painted(&top, 119.8, 10.2)); + assert!(painted(&top, 60.0, 9.8) && painted(&top, 1.8, 30.0)); +} + +/// A weight past `w − R` leaves inner corners wider than the inner box: they shrink to fit, so the +/// hole stays inside the outer edge, across or down. +#[test] +fn inner_corners_shrink_to_fit_a_narrow_inner_box() { + let wide = rounded_side_strokes(20.0, 20.0, [10.0; 4], 0.0, [0.0, 0.0, 0.0, 12.0], StrokeAlign::Inside); + assert!(painted(&wide, 6.0, 10.0) && !painted(&wide, 16.0, 10.0)); + assert!(!painted(&wide, 17.5, 3.0) && !painted(&wide, 17.5, 17.0), "nothing past the outer edge"); + let tall = rounded_side_strokes(20.0, 20.0, [10.0; 4], 0.0, [12.0, 0.0, 0.0, 0.0], StrokeAlign::Inside); + assert!(painted(&tall, 10.0, 6.0) && !painted(&tall, 10.0, 16.0)); + assert!(!painted(&tall, 3.0, 17.5) && !painted(&tall, 17.0, 17.5), "nothing past the outer edge"); + let pill = rounded_side_strokes(30.0, 20.0, [10.0; 4], 0.0, [0.0, 0.0, 0.0, 25.0], StrokeAlign::Inside); + assert!(painted(&pill, 12.0, 10.0) && !painted(&pill, 27.0, 10.0)); + assert!(!painted(&pill, 24.0, 0.5) && !painted(&pill, 24.0, 19.5), "nothing past the outer edge"); +} + +#[test] +fn smoothed_corners_keep_the_stroke_inside_the_fill() { + let smooth = rounded_side_strokes(W, H, [16.0; 4], 0.6, [0.0, 0.0, 2.0, 0.0], StrokeAlign::Inside); + let fill = rounded_rect(W, H, [16.0; 4], 0.6); + let grid = (0..1200).flat_map(|i| (0..600).map(move |j| Point::new(f64::from(i) * 0.1 + 0.05, f64::from(j) * 0.1 + 0.05))); + let stray = grid.filter(|pt| painted(&smooth, pt.x, pt.y) && (pt.y < H / 2.0 || fill.winding(*pt) == 0)); + assert_eq!(stray.count(), 0, "painted outside the fill or along the top"); + assert!(painted(&smooth, 60.0, 59.5)); +} + +#[test] +fn weights_wider_than_the_box_leave_the_outer_edge() { + let full = rounded_side_strokes(20.0, 10.0, [4.0; 4], 0.0, [6.0; 4], StrokeAlign::Inside); + assert!((area(&full) - area(&rounded_rect(20.0, 10.0, [4.0; 4], 0.0))).abs() < 1e-6); +} + +#[test] +fn no_weight_paints_nothing() { + assert!(rounded_side_strokes(W, H, [8.0; 4], 0.0, [0.0; 4], StrokeAlign::Inside).elements().is_empty()); + let junk = [f64::NAN, -4.0, f64::INFINITY, 0.0]; + assert!(rounded_side_strokes(W, H, [8.0; 4], 0.0, junk, StrokeAlign::Inside).elements().is_empty()); +} diff --git a/crates/render/src/draw.rs b/crates/render/src/draw.rs index 63b38220..3718df26 100644 --- a/crates/render/src/draw.rs +++ b/crates/render/src/draw.rs @@ -162,9 +162,13 @@ pub fn stroke_area(n: &Node, path: &BezPath, closed: bool) -> Arc { // Per-side strokes on rectangles/frames. if let Some(sides) = props.sides && matches!(n.kind, NodeKind::Rectangle | NodeKind::Frame | NodeKind::Component { .. } | NodeKind::Instance { .. }) - && n.corners.uniform() == Some(0.0) { - return Arc::new(figcraft_pathops::rect_side_strokes(n.width, n.height, sides, props.align)); + let c = &n.corners; + return Arc::new(if c.uniform() == Some(0.0) { + figcraft_pathops::rect_side_strokes(n.width, n.height, sides, props.align) + } else { + figcraft_pathops::rounded_side_strokes(n.width, n.height, c.radii, c.smoothing, sides, props.align) + }); } // Rect/ellipse inside/outside strokes without booleans: an even-odd ring of two outlines. if props.dashes.is_empty() && props.profile().is_none() && props.brush.is_none() && closed && props.align != StrokeAlign::Center { diff --git a/crates/render/src/tests.rs b/crates/render/src/tests.rs index 55488153..a5a59831 100644 --- a/crates/render/src/tests.rs +++ b/crates/render/src/tests.rs @@ -129,6 +129,27 @@ fn strokes_align() { assert_eq!(px(&pix, 30, 8)[0], 0xF5); } +#[test] +fn side_strokes_follow_rounded_corners() { + // border-l-4 rounded-r-lg: a straight 4 px bar on the left, nothing on the other edges. + let mut d = figcraft_doc::Document::new("t"); + let p = d.first_page().unwrap(); + let f = d.create(p, NodeKind::Frame, "F", 10.0, 10.0, 120.0, 60.0).unwrap(); + let n = d.get_mut(f).unwrap(); + n.fills = vec![Paint::solid(Color::WHITE)]; + n.corners.radii = [0.0, 8.0, 8.0, 0.0]; + n.strokes = vec![Paint::solid(Color::BLACK)]; + n.stroke.align = StrokeAlign::Inside; + n.stroke.sides = Some([0.0, 0.0, 0.0, 4.0]); + let pix = render_view(&d, p, &view(150, 80), &Options::default()).unwrap(); + for x in 10..14 { + assert_eq!(px(&pix, x, 40), [0, 0, 0, 255], "x {x}"); + } + for (x, y) in [(14, 40), (129, 40), (126, 12), (126, 67), (70, 10), (70, 69)] { + assert_eq!(px(&pix, x, y), [255, 255, 255, 255], "({x}, {y})"); + } +} + #[test] fn mask_clips_siblings() { let mut d = figcraft_doc::Document::new("t"); diff --git a/crates/svg/src/tests.rs b/crates/svg/src/tests.rs index 8dece77f..fa8877c1 100644 --- a/crates/svg/src/tests.rs +++ b/crates/svg/src/tests.rs @@ -135,6 +135,26 @@ fn title_case_does_not_restart_words_across_fill_runs_in_live_text() { assert!(svg.contains(">He") && svg.contains(">l") && svg.contains(">lo"), "{svg}"); } +#[test] +fn side_strokes_on_rounded_corners_export_as_their_area() { + // border-l-4 rounded-r-lg: the stroke path is the 4 px bar (60 px tall), drawn even-odd. + use figcraft_geom::Shape; + let mut d = Document::new("t"); + let p = d.first_page().unwrap(); + let f = d.create(p, NodeKind::Frame, "F", 0.0, 0.0, 120.0, 60.0).unwrap(); + let n = d.get_mut(f).unwrap(); + n.corners.radii = [0.0, 8.0, 8.0, 0.0]; + n.strokes = vec![Paint::solid(Color::from_hex("FF0000").unwrap())]; + n.stroke.align = figcraft_doc::StrokeAlign::Inside; + n.stroke.sides = Some([0.0, 0.0, 0.0, 4.0]); + let svg = export_svg(&d, &[f], &SvgOptions::default()).unwrap(); + let stroke = svg.split("