This commit is contained in:
James Lindsay 2025-12-21 12:21:16 +00:00 committed by GitHub
commit 8f5d4a47c4
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
2 changed files with 253 additions and 0 deletions

View file

@ -674,3 +674,168 @@ mod tests {
assert!(bezpath_is_inside_bezpath(&line_inside, &boundary_polygon, None, None));
}
}
pub mod inscribe_circles_algorithms {
use kurbo::{ParamCurve, ParamCurveDeriv, common::solve_itp};
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct CircleInscription {
pub t_first: f64,
pub t_second: f64,
pub theta: f64,
pub radius_to_centre: f64,
pub circle_centre: glam::DVec2,
}
/// Find the normalised tangent at a particular time. Avoid using for t=0 or t=1 due to errors.
fn tangent(segment: kurbo::PathSeg, t: f64) -> glam::DVec2 {
let tangent = match segment {
kurbo::PathSeg::Line(line) => line.deriv().eval(t),
kurbo::PathSeg::Quad(quad_bez) => quad_bez.deriv().eval(t),
kurbo::PathSeg::Cubic(cubic_bez) => cubic_bez.deriv().eval(t),
}
.to_vec2()
.normalize();
let tangent = glam::DVec2::new(tangent.x, tangent.y);
debug_assert!(tangent.is_finite(), "cannot round corner with NaN tangent");
tangent
}
/// Compute the tangent at t=0 for the path segment
pub fn tangent_at_start(segment: kurbo::PathSeg) -> kurbo::Vec2 {
let tangent = match segment {
kurbo::PathSeg::Line(line) => (line.p1 - line.p0).normalize(),
kurbo::PathSeg::Quad(quad_bez) => {
let first = (quad_bez.p1 - quad_bez.p0).normalize();
if first.is_finite() { first } else { (quad_bez.p2 - quad_bez.p0).normalize() }
}
kurbo::PathSeg::Cubic(cubic_bez) => {
let first = (cubic_bez.p1 - cubic_bez.p0).normalize();
if first.is_finite() {
first
} else {
let second = (cubic_bez.p2 - cubic_bez.p0).normalize();
if second.is_finite() { second } else { (cubic_bez.p3 - cubic_bez.p0).normalize() }
}
}
};
debug_assert!(tangent.is_finite(), "cannot round corner with NaN tangent {segment:?}");
tangent
}
/// Convert [`crate::subpath::Bezier`] to [`kurbo::PathSeg`]
pub fn bezier_to_path_seg(bezier: crate::subpath::Bezier) -> kurbo::PathSeg {
let [start, end] = [(bezier.start().x, bezier.start().y), (bezier.end().x, bezier.end().y)];
match bezier.handles {
crate::subpath::BezierHandles::Linear => kurbo::Line::new(start, end).into(),
crate::subpath::BezierHandles::Quadratic { handle } => kurbo::QuadBez::new(start, (handle.x, handle.y), end).into(),
crate::subpath::BezierHandles::Cubic { handle_start, handle_end } => kurbo::CubicBez::new(start, (handle_start.x, handle_start.y), (handle_end.x, handle_end.y), end).into(),
}
}
/// Convert [`kurbo::PathSeg`] to [`crate::subpath::BezierHandles`]
pub fn path_seg_to_handles(segment: kurbo::PathSeg) -> crate::subpath::BezierHandles {
match segment {
kurbo::PathSeg::Line(_line) => crate::subpath::BezierHandles::Linear,
kurbo::PathSeg::Quad(quad_bez) => crate::subpath::BezierHandles::Quadratic {
handle: glam::DVec2::new(quad_bez.p1.x, quad_bez.p1.y),
},
kurbo::PathSeg::Cubic(cubic_bez) => crate::subpath::BezierHandles::Cubic {
handle_start: glam::DVec2::new(cubic_bez.p1.x, cubic_bez.p1.y),
handle_end: glam::DVec2::new(cubic_bez.p2.x, cubic_bez.p2.y),
},
}
}
/// Find the t value that is distance `radius` from the start
fn distance_from_start(seg: kurbo::PathSeg, radius: f64) -> Option<f64> {
let r_squared = radius * radius;
let final_distance = (seg.end() - seg.start()).length_squared();
if final_distance < radius {
return None;
}
let evaluate = |t| (seg.eval(t) - seg.start()).length_squared() - r_squared;
Some(solve_itp(evaluate, 0., 1., 1e-9, 1, 0.2, evaluate(0.), evaluate(1.)))
}
/// Attemt to inscribe circle into the start of the [`kurbo::PathSeg`]s
pub fn inscribe(first: kurbo::PathSeg, second: kurbo::PathSeg, radius: f64) -> Option<CircleInscription> {
let [t_first, t_second] = [distance_from_start(first, radius)?, distance_from_start(second, radius)?];
let tangents = [(first, t_first), (second, t_second)].map(|(segment, t)| tangent(segment, t));
let points = [(first, t_first), (second, t_second)].map(|(segment, t)| segment.eval(t)).map(|x| glam::DVec2::new(x.x, x.y));
let mut normals = tangents.map(glam::DVec2::perp);
// Make sure the normals are pointing in the right direction
normals[0] *= normals[0].dot(tangents[1]).signum();
normals[1] *= normals[1].dot(tangents[0]).signum();
let mid = (points[0] + points[1]) / 2.;
if normals[0].abs_diff_eq(glam::DVec2::ZERO, 1e-6) || normals[1].abs_diff_eq(glam::DVec2::ZERO, 1e-6) || mid.abs_diff_eq(points[0], 1e-6) {
return None;
}
let radius_to_centre = (mid - points[0]).length_squared() / (normals[0].dot(mid - points[0]));
let circle_centre = points[0] + normals[0] * radius_to_centre;
if radius_to_centre > radius * 10. {
return None; // Don't inscribe if it is a long way from the centre
}
info!("Points {points:?}\ntangents {tangents:?}\nnormals {normals:?}\ncentres {circle_centre}");
return Some(CircleInscription {
t_first,
t_second,
theta: normals[0].dot(normals[1]).clamp(-1., 1.).acos(),
radius_to_centre,
circle_centre,
});
}
#[cfg(test)]
mod inscribe_tests {
const ROUND_ACCURACY: f64 = 1e-6;
#[test]
fn test_perpendicular_lines() {
let l1 = kurbo::PathSeg::Line(kurbo::Line::new((0., 0.), (100., 0.)));
let l2 = kurbo::PathSeg::Line(kurbo::Line::new((0., 0.), (0., 100.)));
let result = super::inscribe(l1, l2, 5.);
assert!(result.unwrap().circle_centre.abs_diff_eq(glam::DVec2::new(5., 5.), ROUND_ACCURACY), "{result:?}");
assert_eq!(result.unwrap().theta, std::f64::consts::FRAC_PI_2, "unexpected {result:?}");
}
#[test]
fn test_skew_lines() {
let l1 = kurbo::PathSeg::Line(kurbo::Line::new((0., 0.), (100., 100.)));
let l2 = kurbo::PathSeg::Line(kurbo::Line::new((0., 0.), (0., 100.)));
let result = super::inscribe(l1, l2, 5.);
let expected_centre = glam::DVec2::new(10. / core::f64::consts::SQRT_2 - 5., 5.);
assert!(result.unwrap().circle_centre.abs_diff_eq(expected_centre, ROUND_ACCURACY), "unexpected {result:?}");
assert_eq!(result.unwrap().theta, std::f64::consts::FRAC_PI_4 * 3., "unexpected {result:?}");
}
#[test]
fn test_skew_lines2() {
let l1 = kurbo::PathSeg::Line(kurbo::Line::new((0., 0.), (30., 40.)));
let l2 = kurbo::PathSeg::Line(kurbo::Line::new((0., 0.), (40., 30.)));
let result = super::inscribe(l1, l2, 5.);
let expected_centre = glam::DVec2::new(25. / 7., 25. / 7.);
assert!(result.unwrap().circle_centre.abs_diff_eq(expected_centre, ROUND_ACCURACY), "{result:?}");
assert_eq!(result.unwrap().theta, (-24f64 / 25.).acos(), "{result:?}");
}
#[test]
fn test_perpendicular_cubic() {
let l1 = kurbo::PathSeg::Cubic(kurbo::CubicBez::new((0., 0.), (0., 0.), (100., 0.), (100., 0.)));
let l2 = kurbo::PathSeg::Cubic(kurbo::CubicBez::new((0., 0.), (0., 33.), (0., 67.), (0., 100.)));
let result = super::inscribe(l1, l2, 5.);
assert!(result.unwrap().circle_centre.abs_diff_eq(glam::DVec2::new(5., 5.), ROUND_ACCURACY), "{result:?}");
assert_eq!(result.unwrap().theta, std::f64::consts::FRAC_PI_2, "unexpected {result:?}");
}
}
}

View file

@ -553,6 +553,94 @@ async fn round_corners(
.collect()
}
/// Attempt to inscribe circles that start `radius`` away from the anchor points.
#[node_macro::node(category("Vector: Modifier"), path(core_types::vector))]
async fn inscribe_circles(
_: impl Ctx,
mut source: Table<Vector>,
#[hard_min(0.)]
#[default(10.)]
radius: PixelLength,
) -> Table<Vector> {
for TableRowMut { transform, element: vector, .. } in source.iter_mut() {
let mut new_point_id = vector.point_domain.next_id();
let mut new_segment_id = vector.segment_domain.next_id();
let point_ids_count = vector.point_domain.ids().len();
for point_index in 0..point_ids_count {
let point_id = vector.point_domain.ids()[point_index];
// Get points with two connected segments
let [Some((first_index, first)), Some((second_index, second)), None] = ({
let mut connected_segments = vector.segment_bezier_iter().enumerate().filter(|&(_, (_, _, start, end))| (start == point_id) != (end == point_id));
[connected_segments.next(), connected_segments.next(), connected_segments.next()]
}) else {
continue;
};
// Convert data types
let flipped = [first.3, second.3].map(|end| end == point_id);
let [first, second] = [first.1, second.1]
.map(|t| t.apply_transformation(|x| transform.transform_point2(x)))
.map(bezpath_algorithms::inscribe_circles_algorithms::bezier_to_path_seg);
let first = if flipped[0] { first.reverse() } else { first };
let second = if flipped[1] { second.reverse() } else { second };
// Find positions to inscribe
let Some(pos) = bezpath_algorithms::inscribe_circles_algorithms::inscribe(first, second, radius) else {
continue;
};
// Split path based on inscription
let [first, second] = [first.subsegment(pos.t_first..1.0), second.subsegment(pos.t_second..1.0)];
let start_positions = [first, second].map(|segment| DVec2::new(segment.start().x, segment.start().y));
// Make round handles into circle shape
let start_tangents = [first, second].map(bezpath_algorithms::inscribe_circles_algorithms::tangent_at_start).map(|v| DVec2::new(v.x, v.y));
let k = (4. / 3.) * (pos.theta / 4.).tan();
if !k.is_finite() {
warn!("k is not finite corner {pos:?}, skipping");
continue;
}
let handle_positions = [
start_positions[0] - start_tangents[0] * k * pos.radius_to_centre,
start_positions[1] - start_tangents[1] * k * pos.radius_to_centre,
];
let rounded_handles = BezierHandles::Cubic {
handle_start: handle_positions[0],
handle_end: handle_positions[1],
};
// Convert data types back
let first = if flipped[0] { first.reverse() } else { first };
let second = if flipped[1] { second.reverse() } else { second };
let handles = [first, second].map(bezpath_algorithms::inscribe_circles_algorithms::path_seg_to_handles);
// Apply inverse transforms
let inverse = transform.inverse();
let handles = handles.map(|handle| handle.apply_transformation(|p| inverse.transform_point2(p)));
let start_positions = start_positions.map(|p| inverse.transform_point2(p));
let rounded_handles = rounded_handles.apply_transformation(|p| inverse.transform_point2(p));
vector.segment_domain.set_handles(first_index, handles[0]);
vector.segment_domain.set_handles(second_index, handles[1]);
let end_point_index = vector.point_domain.len();
if flipped[1] {
vector.segment_domain.set_end_point(second_index, end_point_index);
} else {
vector.segment_domain.set_start_point(second_index, end_point_index);
}
vector.point_domain.set_position(point_index, start_positions[0]);
vector.point_domain.push(new_point_id.next_id(), start_positions[1]);
vector
.segment_domain
.push(new_segment_id.next_id(), point_index, end_point_index, rounded_handles, StrokeId::generate());
}
}
source
}
#[node_macro::node(name("Merge by Distance"), category("Vector: Modifier"), path(core_types::vector))]
pub fn merge_by_distance(
_: impl Ctx,