Skip to content

Commit bc9fa50

Browse files
committed
Add inscribe circle node
1 parent 1b9a492 commit bc9fa50

File tree

3 files changed

+281
-0
lines changed

3 files changed

+281
-0
lines changed

node-graph/gcore/src/vector/algorithms/bezpath_algorithms.rs

Lines changed: 210 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -674,3 +674,213 @@ mod tests {
674674
assert!(bezpath_is_inside_bezpath(&line_inside, &boundary_polygon, None, None));
675675
}
676676
}
677+
678+
pub mod inscribe_circles_algorithms {
679+
use core::ops::Range;
680+
use kurbo::{ParamCurve, ParamCurveDeriv, ParamCurveExtrema};
681+
682+
const ROUND_ACCURACY: f64 = 1e-5;
683+
684+
#[derive(Clone, Copy, Debug, PartialEq)]
685+
pub struct CircleInscription {
686+
pub time_1: f64,
687+
pub time_2: f64,
688+
pub theta: f64,
689+
pub circle_centre1: glam::DVec2,
690+
pub circle_centre2: glam::DVec2,
691+
}
692+
693+
/// Find the normalised tangent at a particular time. Avoid using for t=0 or t=1 due to errors.
694+
fn tangent(segment: kurbo::PathSeg, t: f64) -> kurbo::Vec2 {
695+
let tangent = match segment {
696+
kurbo::PathSeg::Line(line) => line.deriv().eval(t),
697+
kurbo::PathSeg::Quad(quad_bez) => quad_bez.deriv().eval(t),
698+
kurbo::PathSeg::Cubic(cubic_bez) => cubic_bez.deriv().eval(t),
699+
}
700+
.to_vec2()
701+
.normalize();
702+
debug_assert!(tangent.is_finite(), "cannot round corner with NaN tangent");
703+
tangent
704+
}
705+
706+
/// Rotate 90 degrees in one direction
707+
fn offset_1(value: kurbo::Vec2, radius: f64) -> kurbo::Vec2 {
708+
kurbo::Vec2::new(-value.y, value.x) * radius
709+
}
710+
711+
/// Rotate 90 degrees in one direction
712+
fn offset_2(value: kurbo::Vec2, radius: f64) -> kurbo::Vec2 {
713+
kurbo::Vec2::new(value.y, -value.x) * radius
714+
}
715+
716+
/// Compute the tangent at t=0 for the path segment
717+
pub fn tangent_at_start(segment: kurbo::PathSeg) -> kurbo::Vec2 {
718+
let tangent = match segment {
719+
kurbo::PathSeg::Line(line) => (line.p1 - line.p0).normalize(),
720+
kurbo::PathSeg::Quad(quad_bez) => {
721+
let first = (quad_bez.p1 - quad_bez.p0).normalize();
722+
if first.is_finite() { first } else { (quad_bez.p2 - quad_bez.p0).normalize() }
723+
}
724+
kurbo::PathSeg::Cubic(cubic_bez) => {
725+
let first = (cubic_bez.p1 - cubic_bez.p0).normalize();
726+
if first.is_finite() {
727+
first
728+
} else {
729+
let second = (cubic_bez.p2 - cubic_bez.p0).normalize();
730+
if second.is_finite() { second } else { (cubic_bez.p3 - cubic_bez.p0).normalize() }
731+
}
732+
}
733+
};
734+
debug_assert!(tangent.is_finite(), "cannot round corner with NaN tangent {segment:?}");
735+
tangent
736+
}
737+
738+
/// Resolve the bounding boxes offset by radius in either direciton.
739+
fn offset_bounding_boxes(segment: kurbo::PathSeg, radius: f64) -> [kurbo::Rect; 2] {
740+
let [start_tangent, end_tangent] = [tangent_at_start(segment), -tangent_at_start(segment.reverse())];
741+
742+
let mut bbox1 = kurbo::Rect::from_points(segment.start() + offset_1(start_tangent, radius), segment.end() + offset_1(end_tangent, radius));
743+
let mut bbox2 = kurbo::Rect::from_points(segment.start() + offset_2(start_tangent, radius), segment.end() + offset_2(end_tangent, radius));
744+
// The extrema for the original curve should be the same as for the offset curve
745+
for extremum in segment.extrema() {
746+
let value = segment.eval(extremum);
747+
let derivative = tangent(segment, extremum);
748+
bbox1 = bbox1.union_pt(value + offset_1(derivative, radius));
749+
bbox2 = bbox2.union_pt(value + offset_2(derivative, radius));
750+
}
751+
debug_assert!(bbox1.is_finite() && bbox2.is_finite(), "a wild NaN appeared :(");
752+
[bbox1, bbox2]
753+
}
754+
755+
/// If the width and height both smaller than accuracy then we can end the recursion
756+
fn rect_within_accuracy(rect: kurbo::Rect, accuracy: f64) -> bool {
757+
rect.width().abs() < accuracy && rect.height().abs() < accuracy
758+
}
759+
760+
/// Resursively find position to inscribe circles
761+
fn inscribe_internal(segment1: kurbo::PathSeg, t1: Range<f64>, segment2: kurbo::PathSeg, t2: Range<f64>, radius: f64) -> Option<CircleInscription> {
762+
let bbox1 = offset_bounding_boxes(segment1.subsegment(t1.clone()), radius);
763+
let bbox2 = offset_bounding_boxes(segment2.subsegment(t2.clone()), radius);
764+
let mid_t1 = (t1.start + t1.end) / 2.;
765+
let mid_t2 = (t2.start + t2.end) / 2.;
766+
767+
// Check if the bounding boxes overlap
768+
let mut any_overlap = false;
769+
for i in 0..4usize {
770+
let [index_1, index_2] = [i >> 1, i & 1];
771+
let [first, second] = [bbox1[index_1], bbox2[index_2]];
772+
773+
// Ignore non overlapping
774+
if !first.overlaps(second) {
775+
continue;
776+
}
777+
// If the rects are small enough then complete the recursion
778+
if rect_within_accuracy(first, ROUND_ACCURACY) && rect_within_accuracy(second, ROUND_ACCURACY) {
779+
let tangents = [(segment1, mid_t1), (segment2, mid_t2)].map(|(segment, t)| tangent(segment, t));
780+
let normal_1 = [offset_1, offset_2][index_1](tangents[0], 1.);
781+
let normal_2 = [offset_1, offset_2][index_2](tangents[1], 1.);
782+
let circle_centre_1 = segment1.eval(mid_t1) + normal_1 * radius;
783+
let circle_centre_2 = segment2.eval(mid_t2) + normal_2 * radius;
784+
return Some(CircleInscription {
785+
time_1: mid_t1,
786+
time_2: mid_t2,
787+
theta: normal_1.dot(normal_2).clamp(-1., 1.).acos(),
788+
circle_centre1: glam::DVec2::new(circle_centre_1.x, circle_centre_1.y),
789+
circle_centre2: glam::DVec2::new(circle_centre_2.x, circle_centre_2.y),
790+
});
791+
}
792+
any_overlap = true;
793+
}
794+
if !any_overlap {
795+
return None;
796+
}
797+
798+
let [start_t1, end_t1] = [t1.start, t1.end];
799+
let [start_t2, end_t2] = [t2.start, t2.end];
800+
801+
// Repeat checking the intersection with the combinations of the two halves of each curve
802+
if let Some(result) = None
803+
.or_else(|| inscribe_internal(segment1, start_t1..mid_t1, segment2, start_t2..mid_t2, radius))
804+
.or_else(|| inscribe_internal(segment1, start_t1..mid_t1, segment2, mid_t2..end_t2, radius))
805+
.or_else(|| inscribe_internal(segment1, mid_t1..end_t1, segment2, start_t2..mid_t2, radius))
806+
.or_else(|| inscribe_internal(segment1, mid_t1..end_t1, segment2, mid_t2..end_t2, radius))
807+
{
808+
return Some(result);
809+
}
810+
None
811+
}
812+
813+
/// Convert [`crate::subpath::Bezier`] to [`kurbo::PathSeg`]
814+
pub fn bezier_to_path_seg(bezier: crate::subpath::Bezier) -> kurbo::PathSeg {
815+
let [start, end] = [(bezier.start().x, bezier.start().y), (bezier.end().x, bezier.end().y)];
816+
match bezier.handles {
817+
crate::subpath::BezierHandles::Linear => kurbo::Line::new(start, end).into(),
818+
crate::subpath::BezierHandles::Quadratic { handle } => kurbo::QuadBez::new(start, (handle.x, handle.y), end).into(),
819+
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(),
820+
}
821+
}
822+
823+
/// Convert [`kurbo::PathSeg`] to [`crate::subpath::BezierHandles`]
824+
pub fn path_seg_to_handles(segment: kurbo::PathSeg) -> crate::subpath::BezierHandles {
825+
match segment {
826+
kurbo::PathSeg::Line(_line) => crate::subpath::BezierHandles::Linear,
827+
kurbo::PathSeg::Quad(quad_bez) => crate::subpath::BezierHandles::Quadratic {
828+
handle: glam::DVec2::new(quad_bez.p1.x, quad_bez.p1.y),
829+
},
830+
kurbo::PathSeg::Cubic(cubic_bez) => crate::subpath::BezierHandles::Cubic {
831+
handle_start: glam::DVec2::new(cubic_bez.p1.x, cubic_bez.p1.y),
832+
handle_end: glam::DVec2::new(cubic_bez.p2.x, cubic_bez.p2.y),
833+
},
834+
}
835+
}
836+
837+
/// Attemt to inscribe circle into the start of the [`kurbo::PathSeg`]s
838+
pub fn inscribe(first: kurbo::PathSeg, second: kurbo::PathSeg, radius: f64) -> Option<CircleInscription> {
839+
inscribe_internal(first, 0.0..1., second, 0.0..1., radius)
840+
}
841+
842+
#[cfg(test)]
843+
mod inscribe_tests {
844+
#[test]
845+
fn test_perpendicular_lines() {
846+
let l1 = kurbo::PathSeg::Line(kurbo::Line::new((0., 0.), (100., 0.)));
847+
let l2 = kurbo::PathSeg::Line(kurbo::Line::new((0., 0.), (0., 100.)));
848+
849+
let result = super::inscribe(l1, l2, 5.);
850+
assert!(result.unwrap().circle_centre1.abs_diff_eq(glam::DVec2::new(5., 5.), super::ROUND_ACCURACY * 10.), "{result:?}");
851+
assert_eq!(result.unwrap().theta, std::f64::consts::FRAC_PI_2, "unexpected {result:?}");
852+
}
853+
854+
#[test]
855+
fn test_skew_lines() {
856+
let l1 = kurbo::PathSeg::Line(kurbo::Line::new((0., 0.), (100., 100.)));
857+
let l2 = kurbo::PathSeg::Line(kurbo::Line::new((0., 0.), (0., 100.)));
858+
859+
let result = super::inscribe(l1, l2, 5.);
860+
let expected_centre = glam::DVec2::new(5., 5. + 5. * std::f64::consts::SQRT_2);
861+
assert!(result.unwrap().circle_centre1.abs_diff_eq(expected_centre, super::ROUND_ACCURACY * 10.), "unexpected {result:?}");
862+
assert_eq!(result.unwrap().theta, std::f64::consts::FRAC_PI_4 * 3., "unexpected {result:?}");
863+
}
864+
865+
#[test]
866+
fn test_skew_lines2() {
867+
let l1 = kurbo::PathSeg::Line(kurbo::Line::new((0., 0.), (30., 40.)));
868+
let l2 = kurbo::PathSeg::Line(kurbo::Line::new((0., 0.), (40., 30.)));
869+
870+
let result = super::inscribe(l1, l2, 5.);
871+
let expected_centre = glam::DVec2::new(25., 25.);
872+
assert!(result.unwrap().circle_centre1.abs_diff_eq(expected_centre, super::ROUND_ACCURACY * 10.), "{result:?}");
873+
assert_eq!(result.unwrap().theta, (-24f64 / 25.).acos(), "{result:?}");
874+
}
875+
876+
#[test]
877+
fn test_perpendicular_cubic() {
878+
let l1 = kurbo::PathSeg::Cubic(kurbo::CubicBez::new((0., 0.), (0., 0.), (100., 0.), (100., 0.)));
879+
let l2 = kurbo::PathSeg::Cubic(kurbo::CubicBez::new((0., 0.), (0., 33.), (0., 67.), (0., 100.)));
880+
881+
let result = super::inscribe(l1, l2, 5.);
882+
assert!(result.unwrap().circle_centre1.abs_diff_eq(glam::DVec2::new(5., 5.), super::ROUND_ACCURACY * 10.), "{result:?}");
883+
assert_eq!(result.unwrap().theta, std::f64::consts::FRAC_PI_2, "unexpected {result:?}");
884+
}
885+
}
886+
}

node-graph/gcore/src/vector/vector_attributes.rs

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -300,6 +300,10 @@ impl SegmentDomain {
300300
self.end_point[segment_index] = new;
301301
}
302302

303+
pub fn set_handles(&mut self, segment_index: usize, new: BezierHandles) {
304+
self.handles[segment_index] = new;
305+
}
306+
303307
pub fn handles(&self) -> &[BezierHandles] {
304308
&self.handles
305309
}

node-graph/gcore/src/vector/vector_nodes.rs

Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -530,6 +530,73 @@ async fn round_corners(
530530
.collect()
531531
}
532532

533+
/// Attempt to inscribe circles at the anchors (that have exactly two segments connected).
534+
#[node_macro::node(category("Vector: Modifier"), path(graphene_core::vector))]
535+
async fn inscribe_circles(
536+
_: impl Ctx,
537+
mut source: Table<Vector>,
538+
#[hard_min(0.)]
539+
#[default(10.)]
540+
radius: PixelLength,
541+
) -> Table<Vector> {
542+
for TableRowMut { transform, element: vector, .. } in source.iter_mut() {
543+
let mut new_point_id = vector.point_domain.next_id();
544+
let mut new_segment_id = vector.segment_domain.next_id();
545+
let point_ids_count = vector.point_domain.ids().len();
546+
for point_index in 0..point_ids_count {
547+
let point_id = vector.point_domain.ids()[point_index];
548+
let [Some((first_index, first)), Some((second_index, second)), None] = ({
549+
let mut connected_segments = vector.segment_bezier_iter().enumerate().filter(|&(_, (_, _, start, end))| (start == point_id) != (end == point_id));
550+
[connected_segments.next(), connected_segments.next(), connected_segments.next()]
551+
}) else {
552+
continue;
553+
};
554+
let flipped = [first.3, second.3].map(|end| end == point_id);
555+
let [first, second] = [first.1, second.1]
556+
.map(|t| t.apply_transformation(|x| transform.transform_point2(x)))
557+
.map(bezpath_algorithms::inscribe_circles_algorithms::bezier_to_path_seg);
558+
let first = if flipped[0] { first.reverse() } else { first };
559+
let second = if flipped[1] { second.reverse() } else { second };
560+
561+
let Some(pos) = bezpath_algorithms::inscribe_circles_algorithms::inscribe(first, second, radius) else {
562+
continue;
563+
};
564+
let [first, second] = [first.subsegment(pos.time_1..1.0), second.subsegment(pos.time_2..1.0)];
565+
let start_positions = [first, second].map(|segment| DVec2::new(segment.start().x, segment.start().y));
566+
let start_tangents = [first, second].map(bezpath_algorithms::inscribe_circles_algorithms::tangent_at_start).map(|v| DVec2::new(v.x, v.y));
567+
let k = (4. / 3.) * (pos.theta / 4.).tan();
568+
if !k.is_finite() {
569+
warn!("k is not finite corner {pos:?}, skipping");
570+
continue;
571+
}
572+
let handle_positions = [start_positions[0] - start_tangents[0] * k * radius, start_positions[1] - start_tangents[1] * k * radius];
573+
let rounded_handles = BezierHandles::Cubic {
574+
handle_start: handle_positions[0],
575+
handle_end: handle_positions[1],
576+
};
577+
let first = if flipped[0] { first.reverse() } else { first };
578+
let second = if flipped[1] { second.reverse() } else { second };
579+
let handles = [first, second].map(bezpath_algorithms::inscribe_circles_algorithms::path_seg_to_handles);
580+
vector.segment_domain.set_handles(first_index, handles[0]);
581+
vector.segment_domain.set_handles(second_index, handles[1]);
582+
let end_point_index = vector.point_domain.len();
583+
if flipped[1] {
584+
vector.segment_domain.set_end_point(second_index, end_point_index);
585+
} else {
586+
vector.segment_domain.set_start_point(second_index, end_point_index);
587+
}
588+
589+
vector.point_domain.set_position(point_index, start_positions[0]);
590+
vector.point_domain.push(new_point_id.next_id(), start_positions[1]);
591+
vector
592+
.segment_domain
593+
.push(new_segment_id.next_id(), point_index, end_point_index, rounded_handles, StrokeId::generate());
594+
}
595+
}
596+
597+
source
598+
}
599+
533600
#[node_macro::node(name("Merge by Distance"), category("Vector: Modifier"), path(graphene_core::vector))]
534601
pub fn merge_by_distance(
535602
_: impl Ctx,

0 commit comments

Comments
 (0)