Files
figcraft/crates/geom/src/tests.rs
T
dotCooCoo 000d39b042 M4: closed vectors take corner smoothing
A vector's corner smoothing was stored, imported and exported, but its
rounded points were drawn as plain circular arcs, and the Design panel
hid the Corner smoothing control for vectors.

A vector with a closed region now draws its rounded points with the
layer's corner smoothing, on the canvas, in hit testing, in PNG, SVG and
PDF exports, and in the fill and stroke geometry of .fig exports. Each
point keeps its own radius; a point without one stays sharp. The corner
uses the profile of smoothed polygon corners, and smoothing is reduced
before the radius when a corner does not fit in half of each segment.

FigCraft's own rules for cases the help page does not describe: only a
point between two straight segments that both bound a closed region is
smoothed; points on open branches and points next to a curved segment
keep the plain arc; concave corners are smoothed like convex ones.

The Design panel shows Corner smoothing and its iOS (60%) button for
vectors with a closed region, and not for open paths.
2026-10-31 02:33:38 -07:00

227 lines
9.9 KiB
Rust

use super::*;
fn area(p: &BezPath) -> f64 {
p.area().abs()
}
#[test]
fn plain_rect_area() {
let p = rounded_rect(100.0, 50.0, [0.0; 4], 0.0);
assert!((area(&p) - 5000.0).abs() < 1e-6);
}
#[test]
fn rounded_rect_area_matches_circle_corners() {
let r = 10.0;
let p = rounded_rect(100.0, 50.0, [r; 4], 0.0);
let expected = 5000.0 - 4.0 * r * r + PI * r * r;
assert!((area(&p) - expected).abs() < 0.5, "{} vs {}", area(&p), expected);
}
#[test]
fn full_radius_is_capsule_and_bounds_hold() {
let p = rounded_rect(100.0, 40.0, [1000.0; 4], 0.0);
let b = p.bounding_box();
assert!((b.width() - 100.0).abs() < 1e-6 && (b.height() - 40.0).abs() < 1e-6);
}
#[test]
fn smoothing_stays_in_bounds_and_reduces_area_less_than_sharp() {
for s in [0.0, 0.3, 0.6, 1.0] {
let p = rounded_rect(200.0, 120.0, [24.0, 0.0, 40.0, 8.0], s);
let b = p.bounding_box();
assert!(b.x0 >= -1e-6 && b.y0 >= -1e-6 && b.x1 <= 200.0 + 1e-6 && b.y1 <= 120.0 + 1e-6, "s={s} {b:?}");
assert!(area(&p) < 200.0 * 120.0);
}
// More smoothing removes more area at the same radius.
let a0 = area(&rounded_rect(200.0, 200.0, [40.0; 4], 0.0));
let a1 = area(&rounded_rect(200.0, 200.0, [40.0; 4], 1.0));
assert!(a1 < a0);
}
#[test]
fn degenerate_inputs_dont_panic() {
let _ = rounded_rect(f64::NAN, -1.0, [f64::INFINITY; 4], f64::NAN);
let _ = rounded_rect(0.0, 0.0, [5.0; 4], 0.5);
let _ = polygon_points(0.0, 0.0, 0);
let _ = star_points(10.0, 10.0, 1, -3.0);
let _ = rounded_polygon(&[], 4.0);
let _ = rounded_polygon(&[Point::ZERO, Point::ZERO, Point::ZERO], 4.0);
let _ = smoothed_polygon(&[Point::ZERO, Point::ZERO, Point::ZERO], 4.0, 0.6);
let _ = smoothed_polygon(&[Point::ZERO, Point::new(10.0, 0.0), Point::new(20.0, 0.0)], 4.0, 1.0);
let _ = smoothed_polygon(&polygon_points(10.0, 10.0, 5), f64::INFINITY, f64::NAN);
let _ = smoothed_polygon(&polygon_points(10.0, 10.0, 5), 3.0, f64::INFINITY);
let _ = ellipse(f64::NAN, 10.0, f64::NAN, 100.0, 2.0);
// (corner, first edge, second edge, room, radius, smoothing): none of these is a corner to round.
let x = Vec2::new(1.0, 0.0);
let rows = [
(Point::ZERO, x, Vec2::new(0.0, 1.0), 10.0, 0.0, 0.6),
(Point::ZERO, x, Vec2::new(0.0, 1.0), 0.0, 5.0, 0.6),
(Point::ZERO, x, x, 10.0, 5.0, 0.6),
(Point::ZERO, x, -x, 10.0, 5.0, 0.6),
(Point::ZERO, Vec2::ZERO, x, 10.0, 5.0, 0.6),
(Point::ZERO, Vec2::new(f64::NAN, 0.0), x, 10.0, 5.0, 0.6),
(Point::new(f64::INFINITY, 0.0), x, Vec2::new(0.0, 1.0), 10.0, 5.0, 0.6),
];
for (corner, u1, u2, room, r, s) in rows {
assert!(smoothed_corner(corner, u1, u2, room, r, s).is_none(), "{corner:?} {u1:?} {u2:?} {room} {r} {s}");
}
}
#[test]
fn smoothed_corner_draws_the_polygon_corner() {
use kurbo::ParamCurve;
// The corner of a square drawn by itself follows the smoothed polygon's corner and joins its
// edges and its own pieces without a kink.
for s in [0.0, 0.6, 1.0] {
let curves = smoothed_corner(Point::ZERO, Vec2::new(0.0, 1.0), Vec2::new(1.0, 0.0), 50.0, 20.0, s).unwrap();
let mut path = BezPath::new();
path.move_to(curves[0].p0);
for q in &curves {
path.curve_to(q.p1, q.p2, q.p3);
}
path.line_to((100.0, 0.0));
path.line_to((100.0, 100.0));
path.line_to((0.0, 100.0));
path.close_path();
let want = area(&rounded_rect(100.0, 100.0, [20.0, 0.0, 0.0, 0.0], s));
assert!((area(&path) - want).abs() < 0.05, "s={s}: {} vs {want}", area(&path));
assert!((curves[0].p0 - Point::new(0.0, 20.0 * (1.0 + s))).hypot() < 1e-9, "s={s}: it starts 1 + smoothing radii along the edge");
let last = curves[curves.len() - 1];
assert!((last.p3 - Point::new(20.0 * (1.0 + s), 0.0)).hypot() < 1e-9, "s={s}: and ends as far along the other");
let dir = |c: &kurbo::CubicBez, t: f64| {
let d = if t < 0.5 { c.eval(1e-6) - c.eval(0.0) } else { c.eval(1.0) - c.eval(1.0 - 1e-6) };
d.normalize()
};
assert!(dir(&curves[0], 0.0).dot(Vec2::new(0.0, -1.0)) > 0.999, "s={s}: it leaves along the first edge");
assert!(dir(&last, 1.0).dot(Vec2::new(1.0, 0.0)) > 0.999, "s={s}: it arrives along the second edge");
for pair in curves.windows(2) {
assert!(dir(&pair[0], 1.0).dot(dir(&pair[1], 0.0)) > 0.999, "s={s}: its pieces join smoothly");
}
}
}
#[test]
fn polygon_first_vertex_top_center() {
let pts = polygon_points(100.0, 100.0, 3);
assert_eq!(pts.len(), 3);
assert!((pts[0].x - 50.0).abs() < 1e-9 && pts[0].y.abs() < 1e-9);
}
#[test]
fn star_has_twice_the_points() {
assert_eq!(star_points(100.0, 100.0, 5, 0.38).len(), 10);
}
#[test]
fn rounded_polygon_shrinks_area() {
let pts = polygon_points(100.0, 100.0, 6);
let sharp = area(&rounded_polygon(&pts, 0.0));
let round = area(&rounded_polygon(&pts, 10.0));
assert!(round < sharp && round > sharp * 0.8);
}
#[test]
fn smoothed_polygon_matches_the_rectangle_corner() {
// A four-sided polygon is a square turned 45°: smoothed, it has the rounded rectangle's area.
let side = 50.0 * std::f64::consts::SQRT_2;
let pts = polygon_points(100.0, 100.0, 4);
for (r, s) in [(10.0, 0.0), (10.0, 0.6), (12.0, 1.0), (30.0, 0.6), (1000.0, 0.6)] {
let poly = area(&smoothed_polygon(&pts, r, s));
let rect = area(&rounded_rect(side, side, [r; 4], s));
assert!((poly - rect).abs() < 0.05, "r={r} s={s}: {poly} vs {rect}");
}
}
#[test]
fn smoothed_polygon_smooths_every_corner() {
use kurbo::{ParamCurve, ParamCurveArclen, ParamCurveNearest};
let near = |p: &BezPath, at: Point| p.segments().map(|seg| seg.nearest(at, 1e-9).distance_sq).fold(f64::INFINITY, f64::min);
// (points, convex: smoothing only cuts area away)
let shapes = [(polygon_points(100.0, 100.0, 3), true), (polygon_points(120.0, 80.0, 6), true), (star_points(100.0, 100.0, 5, 0.38), false)];
for (pts, convex) in shapes {
let plain = smoothed_polygon(&pts, 8.0, 0.0);
assert_eq!(plain, rounded_polygon(&pts, 8.0), "no smoothing draws the plain rounded corners");
let sharp = area(&rounded_polygon(&pts, 0.0));
let mut last = area(&plain);
for s in [0.3, 0.6, 1.0] {
let p = smoothed_polygon(&pts, 8.0, s);
let a = area(&p);
assert!(if convex { a < last } else { (a - last).abs() > 1e-3 }, "s={s}: more smoothing changes the area ({a} after {last})");
assert!((a - sharp).abs() < sharp * 0.2, "s={s}: the shape stays close to its points");
last = a;
// The outline has no kinks: each piece leaves in the direction the one before arrives.
let segs: Vec<kurbo::PathSeg> = p.segments().filter(|seg| seg.arclen(1e-6) > 1e-6).collect();
for (i, seg) in segs.iter().enumerate() {
let next = segs[(i + 1) % segs.len()];
let out = (seg.eval(1.0) - seg.eval(1.0 - 1e-6)).normalize();
let into = (next.eval(1e-6) - next.eval(0.0)).normalize();
assert!(out.dot(into) > 0.999, "s={s}: piece {i} joins the next smoothly ({out:?} then {into:?})");
}
let b = p.bounding_box();
assert!(b.x0 >= -1e-6 && b.y0 >= -1e-6 && b.x1 <= 120.0 + 1e-6 && b.y1 <= 100.0 + 1e-6, "s={s} {b:?}");
// Every vertex is cut off, and the middle of every edge is still on the outline.
for (i, v) in pts.iter().enumerate() {
let next = pts[(i + 1) % pts.len()];
let mid = v.midpoint(next);
assert!(near(&p, mid) < 1e-6, "s={s}: edge {i} keeps its middle");
assert!(near(&p, *v) > 0.25, "s={s}: vertex {i} is rounded");
}
}
}
}
#[test]
fn ellipse_ring_area() {
let full = area(&ellipse(100.0, 100.0, 0.0, TAU, 0.0));
assert!((full - PI * 2500.0).abs() < 2.0);
let ring = ellipse(100.0, 100.0, 0.0, TAU, 0.5);
// Winding area of a ring with a reversed inner contour: outer - inner.
let a = ring.area().abs();
assert!((a - PI * (2500.0 - 625.0)).abs() < 5.0, "{a}");
let half = area(&ellipse(100.0, 100.0, 0.0, PI, 0.0));
assert!((half - PI * 1250.0).abs() < 2.0);
}
#[test]
fn rotation_roundtrip() {
for deg in [0.0, 30.0, -45.0, 90.0, 179.0] {
let xf = from_position_rotation(10.0, 20.0, deg);
assert!((rotation_degrees(xf) - deg).abs() < 1e-9, "{deg}");
assert_eq!(translation(xf), Point::new(10.0, 20.0));
}
}
#[test]
fn set_rotation_keeps_center() {
let xf = from_position_rotation(10.0, 20.0, 0.0);
let r = set_rotation_about_center(xf, 100.0, 50.0, 90.0);
let c0 = xf * Point::new(50.0, 25.0);
let c1 = r * Point::new(50.0, 25.0);
assert!((c0 - c1).hypot() < 1e-9);
assert!((rotation_degrees(r) - 90.0).abs() < 1e-9);
// A flipped box stays flipped and reads back the rotation set, whichever way it was flipped.
for flip in [Affine::FLIP_X, Affine::FLIP_Y] {
for deg in [0.0, 30.0, -120.0] {
let r = set_rotation_about_center(xf * flip, 100.0, 50.0, deg);
assert!(is_flipped(r) && (rotation_degrees(r) - deg).abs() < 1e-9, "{deg}");
assert!((xf * flip * Point::new(50.0, 25.0) - r * Point::new(50.0, 25.0)).hypot() < 1e-9);
}
}
}
#[test]
fn reverse_subpaths_preserves_open_and_closed() {
let mut open = BezPath::new();
open.move_to((0.0, 0.0));
open.line_to((10.0, 0.0));
open.line_to((10.0, 10.0));
let r = <BezPath as ReverseSubpaths>::reverse_subpaths(&open);
assert!(!r.elements().iter().any(|e| matches!(e, kurbo::PathEl::ClosePath)), "{:?}", r.elements());
let mut closed = open.clone();
closed.close_path();
let r = <BezPath as ReverseSubpaths>::reverse_subpaths(&closed);
assert!(matches!(r.elements().last(), Some(kurbo::PathEl::ClosePath)), "{:?}", r.elements());
}