diff --git a/node-graph/libraries/vector-types/src/vector/algorithms/bezpath_algorithms.rs b/node-graph/libraries/vector-types/src/vector/algorithms/bezpath_algorithms.rs index 9f0b897133..1b9c9fc9ff 100644 --- a/node-graph/libraries/vector-types/src/vector/algorithms/bezpath_algorithms.rs +++ b/node-graph/libraries/vector-types/src/vector/algorithms/bezpath_algorithms.rs @@ -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 { + 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 { + 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:?}"); + } + } +} diff --git a/node-graph/libraries/vector-types/src/vector/vector_attributes.rs b/node-graph/libraries/vector-types/src/vector/vector_attributes.rs index 7e267f62cf..60e25f7779 100644 --- a/node-graph/libraries/vector-types/src/vector/vector_attributes.rs +++ b/node-graph/libraries/vector-types/src/vector/vector_attributes.rs @@ -300,6 +300,10 @@ impl SegmentDomain { self.end_point[segment_index] = new; } + pub fn set_handles(&mut self, segment_index: usize, new: BezierHandles) { + self.handles[segment_index] = new; + } + pub fn handles(&self) -> &[BezierHandles] { &self.handles } diff --git a/node-graph/nodes/vector/src/vector_nodes.rs b/node-graph/nodes/vector/src/vector_nodes.rs index 7b889cd2b2..e8d106faf7 100644 --- a/node-graph/nodes/vector/src/vector_nodes.rs +++ b/node-graph/nodes/vector/src/vector_nodes.rs @@ -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, + #[hard_min(0.)] + #[default(10.)] + radius: PixelLength, +) -> Table { + 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,