Files
effectcraft/crates/path/src/lib.rs
T
Camille Moine 3bc1caaedb Shapes: per-corner roundness for Rectangle Path (#1896)
Add Per-Corner Roundness to Rectangle Path: a checkbox (off by default) and four Pixels properties (top-left, top-right, bottom-right, bottom-left). New rectangles initialise the four values from their Roundness, so ticking the box keeps the shape; the radii are animatable and expression-driven.\n\nrect_radii() builds the path clockwise from the top-left with kappa quarter-circle arcs. Overlapping radii scale down like CSS border-radius; negative and non-finite radii are square; rect() delegates to it, so uniform roundness clamps to half the shorter side as before.\n\nLottie rc has one r: four equal static radii export as that value; mixed or animated radii export roundness 0 with a warning. Import puts r into the four per-corner values so ticking the box on an imported rectangle keeps the shape.\n\nTests: path geometry and clamping, rendered per-corner pixels, Lottie equal/mixed/checkbox-off export.
2026-10-30 01:24:59 +01:00

552 lines
21 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.
//! Bezier shapes for shape layers, masks and text.
//!
//! Geometry is kept in [`kurbo::BezPath`]; anti-aliased coverage comes from tiny-skia's scanline
//! rasteriser (BSD-3) and is converted to our `f32` [`Mask`]. Path operations (trim, round corners,
//! pucker & bloat, zig zag, twist, wiggle, offset, merge) live in [`ops`]; curve-preserving booleans
//! (for Merge Paths) in [`boolean`] and curve offsetting in [`offset`].
#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]
pub mod boolean;
pub mod feather;
mod fit;
pub mod interp;
pub mod offset;
pub mod ops;
pub mod trace;
pub mod varstroke;
use effectcraft_geom::Mat3;
use effectcraft_keyframe::ShapePath;
use effectcraft_raster::Mask;
pub use kurbo::{BezPath, PathEl, Point};
use kurbo::{Shape, Vec2 as KVec2};
/// Convert the AE vertex/tangent model to a kurbo path.
pub fn to_kurbo(p: &ShapePath) -> BezPath {
let mut out = BezPath::new();
let n = p.vertices.len();
if n == 0 {
return out;
}
let v = |i: usize| Point::new(p.vertices[i][0], p.vertices[i][1]);
let ot = |i: usize| p.out_tangents.get(i).copied().unwrap_or([0.0; 2]);
let it = |i: usize| p.in_tangents.get(i).copied().unwrap_or([0.0; 2]);
out.move_to(v(0));
let seg = |out: &mut BezPath, a: usize, b: usize| {
let (o, i) = (ot(a), it(b));
if o == [0.0; 2] && i == [0.0; 2] {
out.line_to(v(b));
} else {
out.curve_to(v(a) + KVec2::new(o[0], o[1]), v(b) + KVec2::new(i[0], i[1]), v(b));
}
};
for i in 0..n - 1 {
seg(&mut out, i, i + 1);
}
if p.closed && n > 1 {
seg(&mut out, n - 1, 0);
out.close_path();
}
out
}
/// Convert a kurbo path (first subpath) back to the vertex model (quads are elevated).
pub fn from_kurbo(path: &BezPath) -> Vec<ShapePath> {
let mut out = Vec::new();
let mut cur: Option<ShapePath> = None;
let mut last = Point::ZERO;
let flush = |cur: &mut Option<ShapePath>, out: &mut Vec<ShapePath>| {
if let Some(mut c) = cur.take() {
// A closing segment ending on the first vertex duplicates it.
if c.closed && c.vertices.len() > 1 {
let f = c.vertices[0];
let l = *c.vertices.last().unwrap_or(&f);
if (f[0] - l[0]).abs() < 1e-9 && (f[1] - l[1]).abs() < 1e-9 {
let it = c.in_tangents.pop().unwrap_or([0.0; 2]);
c.vertices.pop();
c.out_tangents.pop();
c.in_tangents[0] = it;
}
}
if !c.vertices.is_empty() {
out.push(c);
}
}
};
for el in path.elements() {
match *el {
PathEl::MoveTo(p) => {
flush(&mut cur, &mut out);
cur = Some(ShapePath {
vertices: vec![[p.x, p.y]],
in_tangents: vec![[0.0; 2]],
out_tangents: vec![[0.0; 2]],
closed: false,
feather: Vec::new(),
});
last = p;
}
PathEl::LineTo(p) => {
if let Some(c) = cur.as_mut() {
c.vertices.push([p.x, p.y]);
c.in_tangents.push([0.0; 2]);
c.out_tangents.push([0.0; 2]);
}
last = p;
}
PathEl::QuadTo(q, p) => {
let c1 = last + (q - last) * (2.0 / 3.0);
let c2 = p + (q - p) * (2.0 / 3.0);
if let Some(c) = cur.as_mut() {
if let Some(o) = c.out_tangents.last_mut() {
*o = [c1.x - last.x, c1.y - last.y];
}
c.vertices.push([p.x, p.y]);
c.in_tangents.push([c2.x - p.x, c2.y - p.y]);
c.out_tangents.push([0.0; 2]);
}
last = p;
}
PathEl::CurveTo(c1, c2, p) => {
if let Some(c) = cur.as_mut() {
if let Some(o) = c.out_tangents.last_mut() {
*o = [c1.x - last.x, c1.y - last.y];
}
c.vertices.push([p.x, p.y]);
c.in_tangents.push([c2.x - p.x, c2.y - p.y]);
c.out_tangents.push([0.0; 2]);
}
last = p;
}
PathEl::ClosePath => {
if let Some(c) = cur.as_mut() {
c.closed = true;
}
}
}
}
flush(&mut cur, &mut out);
out
}
/// Rectangle path centred at `pos` with corner `roundness`, drawn as After Effects draws it
/// (Trim Paths measures from its first point): clockwise from the top-right corner, or with
/// round corners from the end of the top-right arc on the right edge (After Effects 2026,
/// 400 × 300 rectangles trimmed 0–25 %, case 930).
pub fn rect(size: [f64; 2], pos: [f64; 2], roundness: f64) -> BezPath {
let (w, h) = (size[0].abs(), size[1].abs());
let (x0, y0, x1, y1) = (pos[0] - w / 2.0, pos[1] - h / 2.0, pos[0] + w / 2.0, pos[1] + h / 2.0);
let max_rad = w.min(h) / 2.0;
// `f64::clamp` panics on a NaN bound (both size components NaN): fall back to no roundness.
let rad = if roundness.is_finite() && !max_rad.is_nan() { roundness.clamp(0.0, max_rad) } else { 0.0 };
let mut p = BezPath::new();
if rad <= 0.0 {
p.move_to((x1, y0));
p.line_to((x1, y1));
p.line_to((x0, y1));
p.line_to((x0, y0));
p.close_path();
return p;
}
// Quarter circles as cubics (handle 0.5523 × radius).
let k = rad * 0.552_284_749_8;
p.move_to((x1, y0 + rad));
p.line_to((x1, y1 - rad));
p.curve_to((x1, y1 - rad + k), (x1 - rad + k, y1), (x1 - rad, y1));
p.line_to((x0 + rad, y1));
p.curve_to((x0 + rad - k, y1), (x0, y1 - rad + k), (x0, y1 - rad));
p.line_to((x0, y0 + rad));
p.curve_to((x0, y0 + rad - k), (x0 + rad - k, y0), (x0 + rad, y0));
p.line_to((x1 - rad, y0));
p.curve_to((x1 - rad + k, y0), (x1, y0 + rad - k), (x1, y0 + rad));
p.close_path();
p
}
/// Rectangle path centred at `pos` with independent corner radii `[top-left, top-right,
/// bottom-right, bottom-left]`, clockwise in screen coordinates. Negative and non-finite radii
/// are 0; radii that would overlap are scaled down as CSS `border-radius` does.
pub fn rect_radii(size: [f64; 2], pos: [f64; 2], radii: [f64; 4]) -> BezPath {
// Start at the end of the top-right arc on the right edge, like `rect`, and walk the corners
// clockwise: Trim Paths measures from the first point, so ticking per-corner roundness must
// not make a trim jump (#1896).
let w = if size[0].is_finite() { size[0].abs() } else { 0.0 };
let h = if size[1].is_finite() { size[1].abs() } else { 0.0 };
let (x0, y0) = (pos[0] - w / 2.0, pos[1] - h / 2.0);
let (x1, y1) = (pos[0] + w / 2.0, pos[1] + h / 2.0);
let mut r = radii.map(|v| if v.is_finite() { v.max(0.0) } else { 0.0 });
// CSS border-radius: when two radii on an edge overlap, scale every radius by the smallest
// edge ratio (a uniform radius then clamps to half the shorter side, as before).
let ratio = |a: f64, b: f64, edge: f64| if a + b > edge && a + b > 0.0 { (edge / (a + b)).min(1.0) } else { 1.0 };
let f = ratio(r[0], r[1], w).min(ratio(r[3], r[2], w)).min(ratio(r[0], r[3], h)).min(ratio(r[1], r[2], h));
if f < 1.0 {
for v in &mut r {
*v *= f;
}
}
let [tl, tr, br, bl] = r;
// Quarter-circle Bezier control distance.
const K: f64 = 0.552_284_749_830_793_6;
let mut p = BezPath::new();
p.move_to((x1, y0 + tr));
p.line_to((x1, y1 - br));
if br > 0.0 {
p.curve_to((x1, y1 - br + K * br), (x1 - br + K * br, y1), (x1 - br, y1));
}
p.line_to((x0 + bl, y1));
if bl > 0.0 {
p.curve_to((x0 + bl - K * bl, y1), (x0, y1 - bl + K * bl), (x0, y1 - bl));
}
p.line_to((x0, y0 + tl));
if tl > 0.0 {
p.curve_to((x0, y0 + tl - K * tl), (x0 + tl - K * tl, y0), (x0 + tl, y0));
}
p.line_to((x1 - tr, y0));
if tr > 0.0 {
p.curve_to((x1 - tr + K * tr, y0), (x1, y0 + tr - K * tr), (x1, y0 + tr));
}
p.close_path();
p
}
pub fn ellipse(size: [f64; 2], pos: [f64; 2]) -> BezPath {
to_kurbo(&ShapePath::ellipse(pos, size[0], size[1]))
}
/// Polystar: a star (`inner` radius used) or polygon, rotation in degrees, roundness in percent.
pub struct Polystar {
pub star: bool,
pub points: f64,
pub pos: [f64; 2],
pub rotation: f64,
pub inner: f64,
pub outer: f64,
pub inner_round: f64,
pub outer_round: f64,
}
/// Build the path described by the polystar's geometry.
pub fn polystar(spec: Polystar) -> BezPath {
let Polystar { star, points, pos, rotation, inner, outer, inner_round, outer_round } = spec;
let n = points.max(3.0).round() as usize;
let verts = if star { n * 2 } else { n };
let mut path = BezPath::new();
let step = std::f64::consts::TAU / verts as f64;
let start = (rotation - 90.0).to_radians();
let mut pts = Vec::with_capacity(verts);
for i in 0..verts {
let r = if star && i % 2 == 1 { inner } else { outer };
let a = start + step * i as f64;
pts.push((Point::new(pos[0] + r * a.cos(), pos[1] + r * a.sin()), a, r, if star && i % 2 == 1 { inner_round } else { outer_round }));
}
let tangent = |(p, a, r, round): (Point, f64, f64, f64), sign: f64| -> Point {
if round == 0.0 {
return p;
}
// Tangent perpendicular to the radius, length proportional to the arc between vertices.
let len = r * step * 0.25 * round / 100.0 * 4.0 / 3.0 * 0.75;
let d = KVec2::new(-a.sin(), a.cos()) * len * sign;
p + d
};
path.move_to(pts[0].0);
for i in 0..verts {
let a = pts[i];
let b = pts[(i + 1) % verts];
if a.3 == 0.0 && b.3 == 0.0 {
path.line_to(b.0);
} else {
path.curve_to(tangent(a, 1.0), tangent(b, -1.0), b.0);
}
}
path.close_path();
path
}
fn to_skia(path: &BezPath) -> Option<tiny_skia::Path> {
let mut pb = tiny_skia::PathBuilder::new();
for el in path.elements() {
match *el {
PathEl::MoveTo(p) => pb.move_to(p.x as f32, p.y as f32),
PathEl::LineTo(p) => pb.line_to(p.x as f32, p.y as f32),
PathEl::QuadTo(a, p) => pb.quad_to(a.x as f32, a.y as f32, p.x as f32, p.y as f32),
PathEl::CurveTo(a, b, p) => pb.cubic_to(a.x as f32, a.y as f32, b.x as f32, b.y as f32, p.x as f32, p.y as f32),
PathEl::ClosePath => pb.close(),
}
}
pb.finish()
}
fn from_skia(p: &tiny_skia::Path) -> BezPath {
let mut out = BezPath::new();
let pt = |p: tiny_skia::Point| Point::new(p.x as f64, p.y as f64);
for seg in p.segments() {
match seg {
tiny_skia::PathSegment::MoveTo(a) => out.move_to(pt(a)),
tiny_skia::PathSegment::LineTo(a) => out.line_to(pt(a)),
tiny_skia::PathSegment::QuadTo(a, b) => out.quad_to(pt(a), pt(b)),
tiny_skia::PathSegment::CubicTo(a, b, c) => out.curve_to(pt(a), pt(b), pt(c)),
tiny_skia::PathSegment::Close => out.close_path(),
}
}
out
}
fn skia_transform(m: &Mat3) -> tiny_skia::Transform {
let a = &m.0;
tiny_skia::Transform::from_row(a[0][0] as f32, a[1][0] as f32, a[0][1] as f32, a[1][1] as f32, a[0][2] as f32, a[1][2] as f32)
}
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
pub enum FillRule {
#[default]
NonZero,
EvenOdd,
}
/// Anti-aliased coverage of `paths` (in local space) transformed by `m` into a `w × h` mask.
pub fn fill_coverage(paths: &[BezPath], m: &Mat3, w: u32, h: u32, rule: FillRule) -> Mask {
let mut out = Mask::new(w, h, 0.0);
let Some(mut mask) = tiny_skia::Mask::new(w.max(1), h.max(1)) else { return out };
let mut all = BezPath::new();
for p in paths {
all.extend(p.elements().iter().copied());
}
let Some(sp) = to_skia(&all) else { return out };
let fr = match rule {
FillRule::NonZero => tiny_skia::FillRule::Winding,
FillRule::EvenOdd => tiny_skia::FillRule::EvenOdd,
};
mask.fill_path(&sp, fr, true, skia_transform(m));
for (o, &v) in out.data.iter_mut().zip(mask.data()) {
*o = v as f32 / 255.0;
}
out
}
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
pub enum Cap {
#[default]
Butt,
Round,
Square,
}
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
pub enum Join {
#[default]
Miter,
Round,
Bevel,
}
#[derive(Clone, Debug, PartialEq)]
pub struct StrokeStyle {
pub width: f64,
pub cap: Cap,
pub join: Join,
pub miter: f64,
/// Dash pattern (dash, gap, …) and offset.
pub dash: Option<(Vec<f64>, f64)>,
/// Stroke Taper (variable width at the ends).
pub taper: Option<varstroke::Taper>,
/// Stroke Wave (periodic width).
pub wave: Option<varstroke::Wave>,
}
impl StrokeStyle {
/// Whether the width varies along the path (Taper or Wave active).
pub fn is_variable(&self) -> bool {
self.taper.as_ref().is_some_and(|t| t.is_active()) || self.wave.as_ref().is_some_and(|w| w.is_active())
}
}
impl Default for StrokeStyle {
fn default() -> Self {
StrokeStyle { width: 1.0, cap: Cap::Butt, join: Join::Miter, miter: 4.0, dash: None, taper: None, wave: None }
}
}
/// Outline of a stroke as a fillable path (local space). `res_scale` = device/local scale.
pub fn stroke_outline(paths: &[BezPath], style: &StrokeStyle, res_scale: f64) -> Option<BezPath> {
if style.width <= 0.0 {
return None;
}
if style.is_variable() {
return varstroke::outline(paths, style, res_scale);
}
let mut all = BezPath::new();
for p in paths {
all.extend(p.elements().iter().copied());
}
let mut sp = to_skia(&all)?;
if let Some((d, off)) = &style.dash {
let arr: Vec<f32> = d.iter().map(|v| v.max(0.0) as f32).collect();
if arr.len() >= 2 && arr.iter().any(|v| *v > 0.0) {
let arr = if arr.len() % 2 == 1 { [arr.clone(), arr].concat() } else { arr };
if let Some(dash) = tiny_skia::StrokeDash::new(arr, *off as f32) {
sp = sp.dash(&dash, res_scale as f32)?;
}
}
}
let st = tiny_skia::Stroke {
width: style.width as f32,
miter_limit: style.miter.max(1.0) as f32,
line_cap: match style.cap {
Cap::Butt => tiny_skia::LineCap::Butt,
Cap::Round => tiny_skia::LineCap::Round,
Cap::Square => tiny_skia::LineCap::Square,
},
line_join: match style.join {
Join::Miter => tiny_skia::LineJoin::Miter,
Join::Round => tiny_skia::LineJoin::Round,
Join::Bevel => tiny_skia::LineJoin::Bevel,
},
dash: None,
};
let out = sp.stroke(&st, res_scale.max(0.01) as f32)?;
Some(from_skia(&out))
}
/// Coverage of a stroke.
pub fn stroke_coverage(paths: &[BezPath], style: &StrokeStyle, m: &Mat3, w: u32, h: u32) -> Mask {
match stroke_outline(paths, style, m.mean_scale()) {
Some(o) => fill_coverage(&[o], m, w, h, FillRule::NonZero),
None => Mask::new(w, h, 0.0),
}
}
/// Bounding box of paths in local space.
pub fn bounds(paths: &[BezPath]) -> Option<kurbo::Rect> {
let mut r: Option<kurbo::Rect> = None;
for p in paths {
if p.elements().is_empty() {
continue;
}
let b = p.bounding_box();
r = Some(r.map_or(b, |a| a.union(b)));
}
r
}
/// Transform paths by an affine Mat3.
pub fn transform(paths: &[BezPath], m: &Mat3) -> Vec<BezPath> {
let a = &m.0;
let k = kurbo::Affine::new([a[0][0], a[1][0], a[0][1], a[1][1], a[0][2], a[1][2]]);
paths.iter().map(|p| k * p.clone()).collect()
}
#[cfg(test)]
mod tests {
use super::*;
/// After Effects 2026 (case 930): 400 × 300 rectangles at the origin with Trim Paths. Plain:
/// 0–25 % runs from the top-right corner down the right edge and 50 px along the bottom.
/// Round corners 40: from the end of the top-right arc (200, −110) down, round the
/// bottom-right corner, to x 110 on the bottom. Start 70 / End 30 draws 30–70 %.
#[test]
fn rectangles_start_where_after_effects_starts_them() {
let ends = |p: &BezPath| {
let mut first = None;
let mut last = None;
for el in p.elements() {
match *el {
PathEl::MoveTo(q) if first.is_none() => first = Some(q),
PathEl::MoveTo(q) | PathEl::LineTo(q) | PathEl::CurveTo(_, _, q) | PathEl::QuadTo(_, q) => last = Some(q),
PathEl::ClosePath => {}
}
}
(first.unwrap_or_default(), last.unwrap_or_default())
};
let near = |a: Point, b: (f64, f64)| (a.x - b.0).abs() < 1.0 && (a.y - b.1).abs() < 1.0;
let plain = ops::trim(&[rect([400.0, 300.0], [0.0, 0.0], 0.0)], 0.0, 25.0, 0.0);
let (a, b) = ends(&plain[0]);
assert!(near(a, (200.0, -150.0)) && near(b, (150.0, 150.0)), "{a:?} {b:?}");
let round = ops::trim(&[rect([400.0, 300.0], [0.0, 0.0], 40.0)], 0.0, 25.0, 0.0);
let (a, b) = ends(&round[0]);
assert!(near(a, (200.0, -110.0)) && near(b, (110.0, 150.0)), "{a:?} {b:?}");
// Per-corner roundness starts and runs in the same order: ticking the checkbox must not
// move a Trim Paths contour.
let corners = ops::trim(&[rect_radii([400.0, 300.0], [0.0, 0.0], [40.0; 4])], 0.0, 25.0, 0.0);
let (a, b) = ends(&corners[0]);
assert!(near(a, (200.0, -110.0)) && near(b, (110.0, 150.0)), "per-corner: {a:?} {b:?}");
// Start > End: AE draws 30..70 % (plain: from x 80 on the bottom up to y −130 on the left).
let swapped = ops::trim(&[rect([400.0, 300.0], [0.0, 0.0], 0.0)], 70.0, 30.0, 0.0);
let (a, b) = ends(&swapped[0]);
assert!(near(a, (80.0, 150.0)) && near(b, (-200.0, -130.0)), "{a:?} {b:?}");
}
#[test]
fn rect_with_nan_size_does_not_panic() {
let _ = rect([f64::NAN, f64::NAN], [0.0, 0.0], 5.0);
}
#[test]
fn roundtrip_vertex_model() {
let sp = ShapePath::ellipse([10.0, 20.0], 40.0, 30.0);
let k = to_kurbo(&sp);
let back = from_kurbo(&k);
assert_eq!(back.len(), 1);
let b = &back[0];
assert!(b.closed);
assert_eq!(b.vertices.len(), 4);
for i in 0..4 {
for c in 0..2 {
assert!((b.vertices[i][c] - sp.vertices[i][c]).abs() < 1e-9);
assert!((b.in_tangents[i][c] - sp.in_tangents[i][c]).abs() < 1e-9, "{i} {b:?}");
assert!((b.out_tangents[i][c] - sp.out_tangents[i][c]).abs() < 1e-9);
}
}
}
#[test]
fn rect_coverage_area() {
let r = rect([20.0, 10.0], [50.0, 50.0], 0.0);
let m = fill_coverage(&[r], &Mat3::IDENTITY, 100, 100, FillRule::NonZero);
let area: f32 = m.data.iter().sum();
assert!((area - 200.0).abs() < 1.0, "{area}");
}
#[test]
fn rect_radii_uniform_matches_a_rounded_rect() {
for r in [0.0, 5.0, 25.0, 100.0] {
let p = rect_radii([100.0, 50.0], [10.0, 20.0], [r; 4]);
let clamped = r.min(25.0);
let expect = 100.0 * 50.0 - (4.0 - std::f64::consts::PI) * clamped * clamped;
assert!((p.area().abs() - expect).abs() < 1.0, "{r}: {} != {expect}", p.area());
assert!((p.area() - rect([100.0, 50.0], [10.0, 20.0], r).area()).abs() < 1e-6);
}
}
#[test]
fn rect_radii_mixes_corners_and_scales_overlaps() {
// Only the top-left and bottom-right corners round.
let p = rect_radii([100.0, 50.0], [0.0, 0.0], [10.0, 0.0, 20.0, 0.0]);
let expect = 100.0 * 50.0 - (1.0 - std::f64::consts::PI / 4.0) * (10.0 * 10.0 + 20.0 * 20.0);
assert!((p.area().abs() - expect).abs() < 1.0, "{}", p.area());
// Radii wider than the top edge scale down together, like CSS border-radius.
let p = rect_radii([50.0, 100.0], [0.0, 0.0], [40.0, 40.0, 0.0, 0.0]);
let scaled = rect_radii([50.0, 100.0], [0.0, 0.0], [25.0, 25.0, 0.0, 0.0]);
assert!((p.area() - scaled.area()).abs() < 1e-9);
// Negative and non-finite radii are square corners.
let p = rect_radii([50.0, 50.0], [0.0, 0.0], [-5.0, f64::NAN, f64::INFINITY, 0.0]);
assert!((p.area().abs() - 2500.0).abs() < 1e-9);
}
#[test]
fn stroke_has_area() {
let r = rect([40.0, 40.0], [50.0, 50.0], 0.0);
let st = StrokeStyle { width: 4.0, ..Default::default() };
let m = stroke_coverage(&[r], &st, &Mat3::IDENTITY, 100, 100);
let area: f32 = m.data.iter().sum();
assert!((area - 4.0 * 160.0).abs() < 40.0, "{area}");
}
#[test]
fn star_has_right_vertex_count() {
let p = polystar(Polystar { star: true, points: 5.0, pos: [0.0, 0.0], rotation: 0.0, inner: 20.0, outer: 50.0, inner_round: 0.0, outer_round: 0.0 });
let v = from_kurbo(&p);
assert_eq!(v[0].vertices.len(), 10);
}
}