From 6a52616cc8f35c33915edc87cdc02778dd478c4f Mon Sep 17 00:00:00 2001 From: hypercube <0hypercube@gmail.com> Date: Thu, 30 Oct 2025 20:29:43 +0000 Subject: [PATCH 1/3] Add inscribe circle node --- .../vector/algorithms/bezpath_algorithms.rs | 210 ++++++++++++++++++ .../src/vector/vector_attributes.rs | 4 + node-graph/nodes/vector/src/vector_nodes.rs | 67 ++++++ 3 files changed, 281 insertions(+) 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..c002255057 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,213 @@ mod tests { assert!(bezpath_is_inside_bezpath(&line_inside, &boundary_polygon, None, None)); } } + +pub mod inscribe_circles_algorithms { + use core::ops::Range; + use kurbo::{ParamCurve, ParamCurveDeriv, ParamCurveExtrema}; + + const ROUND_ACCURACY: f64 = 1e-5; + + #[derive(Clone, Copy, Debug, PartialEq)] + pub struct CircleInscription { + pub time_1: f64, + pub time_2: f64, + pub theta: f64, + pub circle_centre1: glam::DVec2, + pub circle_centre2: 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) -> kurbo::Vec2 { + 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(); + debug_assert!(tangent.is_finite(), "cannot round corner with NaN tangent"); + tangent + } + + /// Rotate 90 degrees in one direction + fn offset_1(value: kurbo::Vec2, radius: f64) -> kurbo::Vec2 { + kurbo::Vec2::new(-value.y, value.x) * radius + } + + /// Rotate 90 degrees in one direction + fn offset_2(value: kurbo::Vec2, radius: f64) -> kurbo::Vec2 { + kurbo::Vec2::new(value.y, -value.x) * radius + } + + /// 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 + } + + /// Resolve the bounding boxes offset by radius in either direciton. + fn offset_bounding_boxes(segment: kurbo::PathSeg, radius: f64) -> [kurbo::Rect; 2] { + let [start_tangent, end_tangent] = [tangent_at_start(segment), -tangent_at_start(segment.reverse())]; + + let mut bbox1 = kurbo::Rect::from_points(segment.start() + offset_1(start_tangent, radius), segment.end() + offset_1(end_tangent, radius)); + let mut bbox2 = kurbo::Rect::from_points(segment.start() + offset_2(start_tangent, radius), segment.end() + offset_2(end_tangent, radius)); + // The extrema for the original curve should be the same as for the offset curve + for extremum in segment.extrema() { + let value = segment.eval(extremum); + let derivative = tangent(segment, extremum); + bbox1 = bbox1.union_pt(value + offset_1(derivative, radius)); + bbox2 = bbox2.union_pt(value + offset_2(derivative, radius)); + } + debug_assert!(bbox1.is_finite() && bbox2.is_finite(), "a wild NaN appeared :("); + [bbox1, bbox2] + } + + /// If the width and height both smaller than accuracy then we can end the recursion + fn rect_within_accuracy(rect: kurbo::Rect, accuracy: f64) -> bool { + rect.width().abs() < accuracy && rect.height().abs() < accuracy + } + + /// Resursively find position to inscribe circles + fn inscribe_internal(segment1: kurbo::PathSeg, t1: Range, segment2: kurbo::PathSeg, t2: Range, radius: f64) -> Option { + let bbox1 = offset_bounding_boxes(segment1.subsegment(t1.clone()), radius); + let bbox2 = offset_bounding_boxes(segment2.subsegment(t2.clone()), radius); + let mid_t1 = (t1.start + t1.end) / 2.; + let mid_t2 = (t2.start + t2.end) / 2.; + + // Check if the bounding boxes overlap + let mut any_overlap = false; + for i in 0..4usize { + let [index_1, index_2] = [i >> 1, i & 1]; + let [first, second] = [bbox1[index_1], bbox2[index_2]]; + + // Ignore non overlapping + if !first.overlaps(second) { + continue; + } + // If the rects are small enough then complete the recursion + if rect_within_accuracy(first, ROUND_ACCURACY) && rect_within_accuracy(second, ROUND_ACCURACY) { + let tangents = [(segment1, mid_t1), (segment2, mid_t2)].map(|(segment, t)| tangent(segment, t)); + let normal_1 = [offset_1, offset_2][index_1](tangents[0], 1.); + let normal_2 = [offset_1, offset_2][index_2](tangents[1], 1.); + let circle_centre_1 = segment1.eval(mid_t1) + normal_1 * radius; + let circle_centre_2 = segment2.eval(mid_t2) + normal_2 * radius; + return Some(CircleInscription { + time_1: mid_t1, + time_2: mid_t2, + theta: normal_1.dot(normal_2).clamp(-1., 1.).acos(), + circle_centre1: glam::DVec2::new(circle_centre_1.x, circle_centre_1.y), + circle_centre2: glam::DVec2::new(circle_centre_2.x, circle_centre_2.y), + }); + } + any_overlap = true; + } + if !any_overlap { + return None; + } + + let [start_t1, end_t1] = [t1.start, t1.end]; + let [start_t2, end_t2] = [t2.start, t2.end]; + + // Repeat checking the intersection with the combinations of the two halves of each curve + if let Some(result) = None + .or_else(|| inscribe_internal(segment1, start_t1..mid_t1, segment2, start_t2..mid_t2, radius)) + .or_else(|| inscribe_internal(segment1, start_t1..mid_t1, segment2, mid_t2..end_t2, radius)) + .or_else(|| inscribe_internal(segment1, mid_t1..end_t1, segment2, start_t2..mid_t2, radius)) + .or_else(|| inscribe_internal(segment1, mid_t1..end_t1, segment2, mid_t2..end_t2, radius)) + { + return Some(result); + } + None + } + + /// 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), + }, + } + } + + /// Attemt to inscribe circle into the start of the [`kurbo::PathSeg`]s + pub fn inscribe(first: kurbo::PathSeg, second: kurbo::PathSeg, radius: f64) -> Option { + inscribe_internal(first, 0.0..1., second, 0.0..1., radius) + } + + #[cfg(test)] + mod inscribe_tests { + #[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_centre1.abs_diff_eq(glam::DVec2::new(5., 5.), super::ROUND_ACCURACY * 10.), "{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(5., 5. + 5. * std::f64::consts::SQRT_2); + assert!(result.unwrap().circle_centre1.abs_diff_eq(expected_centre, super::ROUND_ACCURACY * 10.), "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., 25.); + assert!(result.unwrap().circle_centre1.abs_diff_eq(expected_centre, super::ROUND_ACCURACY * 10.), "{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_centre1.abs_diff_eq(glam::DVec2::new(5., 5.), super::ROUND_ACCURACY * 10.), "{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..ec81a20994 100644 --- a/node-graph/nodes/vector/src/vector_nodes.rs +++ b/node-graph/nodes/vector/src/vector_nodes.rs @@ -553,6 +553,73 @@ async fn round_corners( .collect() } +/// Attempt to inscribe circles at the anchors (that have exactly two segments connected). +#[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]; + 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; + }; + 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 }; + + let Some(pos) = bezpath_algorithms::inscribe_circles_algorithms::inscribe(first, second, radius) else { + continue; + }; + let [first, second] = [first.subsegment(pos.time_1..1.0), second.subsegment(pos.time_2..1.0)]; + let start_positions = [first, second].map(|segment| DVec2::new(segment.start().x, segment.start().y)); + 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 * radius, start_positions[1] - start_tangents[1] * k * radius]; + let rounded_handles = BezierHandles::Cubic { + handle_start: handle_positions[0], + handle_end: handle_positions[1], + }; + 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); + 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, From cdb7d207fb5e144396802979bcaeb2f572f59903 Mon Sep 17 00:00:00 2001 From: hypercube <0hypercube@gmail.com> Date: Sat, 1 Nov 2025 07:36:01 +0000 Subject: [PATCH 2/3] Fix issue with transforms --- node-graph/nodes/vector/src/vector_nodes.rs | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/node-graph/nodes/vector/src/vector_nodes.rs b/node-graph/nodes/vector/src/vector_nodes.rs index ec81a20994..4274308342 100644 --- a/node-graph/nodes/vector/src/vector_nodes.rs +++ b/node-graph/nodes/vector/src/vector_nodes.rs @@ -568,12 +568,16 @@ async fn inscribe_circles( 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))) @@ -581,11 +585,16 @@ async fn inscribe_circles( 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.time_1..1.0), second.subsegment(pos.time_2..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() { @@ -597,9 +606,18 @@ async fn inscribe_circles( 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(); From 06749ecea52f3085903bca5a0b242f3a83f9538c Mon Sep 17 00:00:00 2001 From: hypercube <0hypercube@gmail.com> Date: Sun, 23 Nov 2025 19:08:40 +0000 Subject: [PATCH 3/3] Rewrite to match inkscape's live path effect --- .../vector/algorithms/bezpath_algorithms.rs | 157 +++++++----------- node-graph/nodes/vector/src/vector_nodes.rs | 9 +- 2 files changed, 62 insertions(+), 104 deletions(-) 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 c002255057..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 @@ -676,22 +676,19 @@ mod tests { } pub mod inscribe_circles_algorithms { - use core::ops::Range; - use kurbo::{ParamCurve, ParamCurveDeriv, ParamCurveExtrema}; - - const ROUND_ACCURACY: f64 = 1e-5; + use kurbo::{ParamCurve, ParamCurveDeriv, common::solve_itp}; #[derive(Clone, Copy, Debug, PartialEq)] pub struct CircleInscription { - pub time_1: f64, - pub time_2: f64, + pub t_first: f64, + pub t_second: f64, pub theta: f64, - pub circle_centre1: glam::DVec2, - pub circle_centre2: glam::DVec2, + 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) -> kurbo::Vec2 { + 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), @@ -699,20 +696,11 @@ pub mod inscribe_circles_algorithms { } .to_vec2() .normalize(); + let tangent = glam::DVec2::new(tangent.x, tangent.y); debug_assert!(tangent.is_finite(), "cannot round corner with NaN tangent"); tangent } - /// Rotate 90 degrees in one direction - fn offset_1(value: kurbo::Vec2, radius: f64) -> kurbo::Vec2 { - kurbo::Vec2::new(-value.y, value.x) * radius - } - - /// Rotate 90 degrees in one direction - fn offset_2(value: kurbo::Vec2, radius: f64) -> kurbo::Vec2 { - kurbo::Vec2::new(value.y, -value.x) * radius - } - /// Compute the tangent at t=0 for the path segment pub fn tangent_at_start(segment: kurbo::PathSeg) -> kurbo::Vec2 { let tangent = match segment { @@ -735,81 +723,6 @@ pub mod inscribe_circles_algorithms { tangent } - /// Resolve the bounding boxes offset by radius in either direciton. - fn offset_bounding_boxes(segment: kurbo::PathSeg, radius: f64) -> [kurbo::Rect; 2] { - let [start_tangent, end_tangent] = [tangent_at_start(segment), -tangent_at_start(segment.reverse())]; - - let mut bbox1 = kurbo::Rect::from_points(segment.start() + offset_1(start_tangent, radius), segment.end() + offset_1(end_tangent, radius)); - let mut bbox2 = kurbo::Rect::from_points(segment.start() + offset_2(start_tangent, radius), segment.end() + offset_2(end_tangent, radius)); - // The extrema for the original curve should be the same as for the offset curve - for extremum in segment.extrema() { - let value = segment.eval(extremum); - let derivative = tangent(segment, extremum); - bbox1 = bbox1.union_pt(value + offset_1(derivative, radius)); - bbox2 = bbox2.union_pt(value + offset_2(derivative, radius)); - } - debug_assert!(bbox1.is_finite() && bbox2.is_finite(), "a wild NaN appeared :("); - [bbox1, bbox2] - } - - /// If the width and height both smaller than accuracy then we can end the recursion - fn rect_within_accuracy(rect: kurbo::Rect, accuracy: f64) -> bool { - rect.width().abs() < accuracy && rect.height().abs() < accuracy - } - - /// Resursively find position to inscribe circles - fn inscribe_internal(segment1: kurbo::PathSeg, t1: Range, segment2: kurbo::PathSeg, t2: Range, radius: f64) -> Option { - let bbox1 = offset_bounding_boxes(segment1.subsegment(t1.clone()), radius); - let bbox2 = offset_bounding_boxes(segment2.subsegment(t2.clone()), radius); - let mid_t1 = (t1.start + t1.end) / 2.; - let mid_t2 = (t2.start + t2.end) / 2.; - - // Check if the bounding boxes overlap - let mut any_overlap = false; - for i in 0..4usize { - let [index_1, index_2] = [i >> 1, i & 1]; - let [first, second] = [bbox1[index_1], bbox2[index_2]]; - - // Ignore non overlapping - if !first.overlaps(second) { - continue; - } - // If the rects are small enough then complete the recursion - if rect_within_accuracy(first, ROUND_ACCURACY) && rect_within_accuracy(second, ROUND_ACCURACY) { - let tangents = [(segment1, mid_t1), (segment2, mid_t2)].map(|(segment, t)| tangent(segment, t)); - let normal_1 = [offset_1, offset_2][index_1](tangents[0], 1.); - let normal_2 = [offset_1, offset_2][index_2](tangents[1], 1.); - let circle_centre_1 = segment1.eval(mid_t1) + normal_1 * radius; - let circle_centre_2 = segment2.eval(mid_t2) + normal_2 * radius; - return Some(CircleInscription { - time_1: mid_t1, - time_2: mid_t2, - theta: normal_1.dot(normal_2).clamp(-1., 1.).acos(), - circle_centre1: glam::DVec2::new(circle_centre_1.x, circle_centre_1.y), - circle_centre2: glam::DVec2::new(circle_centre_2.x, circle_centre_2.y), - }); - } - any_overlap = true; - } - if !any_overlap { - return None; - } - - let [start_t1, end_t1] = [t1.start, t1.end]; - let [start_t2, end_t2] = [t2.start, t2.end]; - - // Repeat checking the intersection with the combinations of the two halves of each curve - if let Some(result) = None - .or_else(|| inscribe_internal(segment1, start_t1..mid_t1, segment2, start_t2..mid_t2, radius)) - .or_else(|| inscribe_internal(segment1, start_t1..mid_t1, segment2, mid_t2..end_t2, radius)) - .or_else(|| inscribe_internal(segment1, mid_t1..end_t1, segment2, start_t2..mid_t2, radius)) - .or_else(|| inscribe_internal(segment1, mid_t1..end_t1, segment2, mid_t2..end_t2, radius)) - { - return Some(result); - } - None - } - /// 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)]; @@ -834,20 +747,62 @@ pub mod inscribe_circles_algorithms { } } + /// 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 { - inscribe_internal(first, 0.0..1., second, 0.0..1., radius) + 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_centre1.abs_diff_eq(glam::DVec2::new(5., 5.), super::ROUND_ACCURACY * 10.), "{result:?}"); + 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:?}"); } @@ -857,8 +812,8 @@ pub mod inscribe_circles_algorithms { 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(5., 5. + 5. * std::f64::consts::SQRT_2); - assert!(result.unwrap().circle_centre1.abs_diff_eq(expected_centre, super::ROUND_ACCURACY * 10.), "unexpected {result:?}"); + 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:?}"); } @@ -868,8 +823,8 @@ pub mod inscribe_circles_algorithms { 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., 25.); - assert!(result.unwrap().circle_centre1.abs_diff_eq(expected_centre, super::ROUND_ACCURACY * 10.), "{result:?}"); + 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:?}"); } @@ -879,7 +834,7 @@ pub mod inscribe_circles_algorithms { 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_centre1.abs_diff_eq(glam::DVec2::new(5., 5.), super::ROUND_ACCURACY * 10.), "{result:?}"); + 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/nodes/vector/src/vector_nodes.rs b/node-graph/nodes/vector/src/vector_nodes.rs index 4274308342..e8d106faf7 100644 --- a/node-graph/nodes/vector/src/vector_nodes.rs +++ b/node-graph/nodes/vector/src/vector_nodes.rs @@ -553,7 +553,7 @@ async fn round_corners( .collect() } -/// Attempt to inscribe circles at the anchors (that have exactly two segments connected). +/// 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, @@ -591,7 +591,7 @@ async fn inscribe_circles( }; // Split path based on inscription - let [first, second] = [first.subsegment(pos.time_1..1.0), second.subsegment(pos.time_2..1.0)]; + 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 @@ -601,7 +601,10 @@ async fn inscribe_circles( warn!("k is not finite corner {pos:?}, skipping"); continue; } - let handle_positions = [start_positions[0] - start_tangents[0] * k * radius, start_positions[1] - start_tangents[1] * k * radius]; + 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],