mirror of
https://github.com/storytold/figcraft.git
synced 2026-10-24 10:27:00 -06:00
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.
This commit is contained in:
@@ -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::*;
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
@@ -162,9 +162,13 @@ pub fn stroke_area(n: &Node, path: &BezPath, closed: bool) -> Arc<BezPath> {
|
||||
// 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 {
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -135,6 +135,26 @@ fn title_case_does_not_restart_words_across_fill_runs_in_live_text() {
|
||||
assert!(svg.contains(">He</tspan>") && svg.contains(">l</tspan>") && svg.contains(">lo</tspan>"), "{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("<path").find(|e| e.contains("#FF0000")).unwrap();
|
||||
assert!(stroke.contains("fill-rule=\"evenodd\""), "{stroke}");
|
||||
let data = stroke.split("d=\"").nth(1).and_then(|s| s.split('"').next()).unwrap();
|
||||
let area = figcraft_geom::BezPath::from_svg(data).unwrap().area().abs();
|
||||
assert!((area - 240.0).abs() < 0.01, "{area}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn point_joins_equal_to_the_layer_join_keep_a_plain_stroke() {
|
||||
use figcraft_doc::{StrokeJoin, VectorNetwork};
|
||||
|
||||
Reference in New Issue
Block a user