Files
figcraft/crates/pathops/src/rounded_side_strokes_tests.rs
T
MortenHusted 6db58d32cb 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.
2026-10-22 11:20:24 +02:00

115 lines
5.3 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! 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());
}