Repository: GraphiteEditor/Graphite
Pull request #2611: Refactor the 'Position on Path' and 'Tangent on Path' nodes to use the Kurbo API
Author: login: indierusty
Created: 2025-04-22T16:49:56Z | Merged: 2025-04-30T04:51:10Z

Partly closes #2635.

Linked issue #2635: Tracking Issue: Refactor nodes to use Kurbo instead of Bezier-rs
- [x] 'Offset Path' node (#2596, #2946)
- [x] 'Solidify Stroke' node (#2608)
- [x] 'Position on Path' and 'Tangent on Path' nodes (#2611)
- [x] 'Sample Points' node (#2629)
- [x] 'Scatter Points' node (#2634, #2657)
- [x] 'Bounding Box' node (#2662)
- [x] 'Morph' node (#2696)
- [x] 'Spline' node (#2701)
- [x] 'Bevel' node (#2293)
- [x] 'Area' node (#2293)
- [x] 'Round Corners' node (#2964)
- [x] 'Auto-Tangents' node (#2964)
- [x] 'Centroid' node (#3036)

Diff:
diff --git a/node-graph/gcore/src/vector/algorithms/bezpath_algorithms.rs b/node-graph/gcore/src/vector/algorithms/bezpath_algorithms.rs
new file mode 100644
index 0000000000..406808eb8c
--- /dev/null
+++ b/node-graph/gcore/src/vector/algorithms/bezpath_algorithms.rs
@@ -0,0 +1,108 @@
+/// Accuracy to find the position on [kurbo::Bezpath].
+const POSITION_ACCURACY: f64 = 1e-3;
+/// Accuracy to find the length of the [kurbo::PathSeg].
+const PERIMETER_ACCURACY: f64 = 1e-3;
+
+use kurbo::{BezPath, ParamCurve, ParamCurveDeriv, PathSeg, Point, Shape};
+
+pub fn position_on_bezpath(bezpath: &BezPath, t: f64, euclidian: bool) -> Point {
+	let (segment_index, t) = tvalue_to_parametric(bezpath, t, euclidian);
+	bezpath.get_seg(segment_index + 1).unwrap().eval(t)
+}
+
+pub fn tangent_on_bezpath(bezpath: &BezPath, t: f64, euclidian: bool) -> Point {
+	let (segment_index, t) = tvalue_to_parametric(bezpath, t, euclidian);
+	let segment = bezpath.get_seg(segment_index + 1).unwrap();
+	match segment {
+		PathSeg::Line(line) => line.deriv().eval(t),
+		PathSeg::Quad(quad_bez) => quad_bez.deriv().eval(t),
+		PathSeg::Cubic(cubic_bez) => cubic_bez.deriv().eval(t),
+	}
+}
+
+pub fn tvalue_to_parametric(bezpath: &BezPath, t: f64, euclidian: bool) -> (usize, f64) {
+	if euclidian {
+		let (segment_index, t) = t_value_to_parametric(bezpath, BezPathTValue::GlobalEuclidean(t));
+		let segment = bezpath.get_seg(segment_index + 1).unwrap();
+		return (segment_index, eval_pathseg_euclidian(segment, t, POSITION_ACCURACY));
+	}
+	t_value_to_parametric(bezpath, BezPathTValue::GlobalParametric(t))
+}
+
+/// Finds the t value of point on the given path segment i.e fractional distance along the segment's total length.
+/// It uses a binary search to find the value `t` such that the ratio `length_upto_t / total_length` approximates the input `distance`.
+fn eval_pathseg_euclidian(path: kurbo::PathSeg, distance: f64, accuracy: f64) -> f64 {
+	let mut low_t = 0.;
+	let mut mid_t = 0.5;
+	let mut high_t = 1.;
+
+	let total_length = path.perimeter(accuracy);
+
+	if !total_length.is_finite() || total_length <= f64::EPSILON {
+		return 0.;
+	}
+
+	let distance = distance.clamp(0., 1.);
+
+	while high_t - low_t > accuracy {
+		let current_length = path.subsegment(0.0..mid_t).perimeter(accuracy);
+		let current_distance = current_length / total_length;
+
+		if current_distance > distance {
+			high_t = mid_t;
+		} else {
+			low_t = mid_t;
+		}
+		mid_t = (high_t + low_t) / 2.;
+	}
+
+	mid_t
+}
+
+/// Converts from a bezpath (composed of multiple segments) to a point along a certain segment represented.
+/// The returned tuple represents the segment index and the `t` value along that segment.
+/// Both the input global `t` value and the output `t` value are in euclidean space, meaning there is a constant rate of change along the arc length.
+fn global_euclidean_to_local_euclidean(bezpath: &kurbo::BezPath, global_t: f64, lengths: &[f64], total_length: f64) -> (usize, f64) {
+	let mut accumulator = 0.;
+	for (index, length) in lengths.iter().enumerate() {
+		let length_ratio = length / total_length;
+		if (index == 0 || accumulator <= global_t) && global_t <= accumulator + length_ratio {
+			return (index, ((global_t - accumulator) / length_ratio).clamp(0., 1.));
+		}
+		accumulator += length_ratio;
+	}
+	(bezpath.segments().count() - 2, 1.)
+}
+
+enum BezPathTValue {
+	GlobalEuclidean(f64),
+	GlobalParametric(f64),
+}
+
+/// Convert a [BezPathTValue] to a parametric `(segment_index, t)` tuple.
+/// - Asserts that `t` values contained within the `SubpathTValue` argument lie in the range [0, 1].
+fn t_value_to_parametric(bezpath: &kurbo::BezPath, t: BezPathTValue) -> (usize, f64) {
+	let segment_len = bezpath.segments().count();
+	assert!(segment_len >= 1);
+
+	match t {
+		BezPathTValue::GlobalEuclidean(t) => {
+			let lengths = bezpath.segments().map(|bezier| bezier.perimeter(PERIMETER_ACCURACY)).collect::<Vec<f64>>();
+			let total_length: f64 = lengths.iter().sum();
+			global_euclidean_to_local_euclidean(bezpath, t, lengths.as_slice(), total_length)
+		}
+		BezPathTValue::GlobalParametric(global_t) => {
+			assert!((0.0..=1.).contains(&global_t));
+
+			if global_t == 1. {
+				return (segment_len - 1, 1.);
+			}
+
+			let scaled_t = global_t * segment_len as f64;
+			let segment_index = scaled_t.floor() as usize;
+			let t = scaled_t - segment_index as f64;
+
+			(segment_index, t)
+		}
+	}
+}
diff --git a/node-graph/gcore/src/vector/algorithms/mod.rs b/node-graph/gcore/src/vector/algorithms/mod.rs
index a03cf7c6af..54fffd0d46 100644
--- a/node-graph/gcore/src/vector/algorithms/mod.rs
+++ b/node-graph/gcore/src/vector/algorithms/mod.rs
@@ -1,3 +1,4 @@
+pub mod bezpath_algorithms;
 mod instance;
 mod merge_by_distance;
 pub mod offset_subpath;
diff --git a/node-graph/gcore/src/vector/vector_nodes.rs b/node-graph/gcore/src/vector/vector_nodes.rs
index 013d61f690..53e786fa9e 100644
--- a/node-graph/gcore/src/vector/vector_nodes.rs
+++ b/node-graph/gcore/src/vector/vector_nodes.rs
@@ -1,5 +1,6 @@
+use super::algorithms::bezpath_algorithms::{position_on_bezpath, tangent_on_bezpath};
 use super::algorithms::offset_subpath::offset_subpath;
-use super::misc::CentroidType;
+use super::misc::{CentroidType, point_to_dvec2};
 use super::style::{Fill, Gradient, GradientStops, Stroke};
 use super::{PointId, SegmentDomain, SegmentId, StrokeId, VectorData, VectorDataTable};
 use crate::instances::{Instance, InstanceMut, Instances};
@@ -14,6 +15,7 @@ use bezier_rs::{Join, ManipulatorGroup, Subpath, SubpathTValue, TValue};
 use core::f64::consts::PI;
 use core::hash::{Hash, Hasher};
 use glam::{DAffine2, DVec2};
+use kurbo::Affine;
 use rand::{Rng, SeedableRng};
 use std::collections::hash_map::DefaultHasher;
 
@@ -1304,16 +1306,17 @@ async fn position_on_path(
 	let vector_data_transform = vector_data.transform();
 	let vector_data = vector_data.one_instance_ref().instance;
 
-	let subpaths_count = vector_data.stroke_bezier_paths().count() as f64;
-	let progress = progress.clamp(0., subpaths_count);
-	let progress = if reverse { subpaths_count - progress } else { progress };
-	let index = if progress >= subpaths_count { (subpaths_count - 1.) as usize } else { progress as usize };
+	let mut bezpaths = vector_data.stroke_bezpath_iter().collect::<Vec<kurbo::BezPath>>();
+	let bezpath_count = bezpaths.len() as f64;
+	let progress = progress.clamp(0., bezpath_count);
+	let progress = if reverse { bezpath_count - progress } else { progress };
+	let index = if progress >= bezpath_count { (bezpath_count - 1.) as usize } else { progress as usize };
 
-	vector_data.stroke_bezier_paths().nth(index).map_or(DVec2::ZERO, |mut subpath| {
-		subpath.apply_transform(vector_data_transform);
+	bezpaths.get_mut(index).map_or(DVec2::ZERO, |bezpath| {
+		let t = if progress == bezpath_count { 1. } else { progress.fract() };
+		bezpath.apply_affine(Affine::new(vector_data_transform.to_cols_array()));
 
-		let t = if progress == subpaths_count { 1. } else { progress.fract() };
-		subpath.evaluate(if euclidian { SubpathTValue::GlobalEuclidean(t) } else { SubpathTValue::GlobalParametric(t) })
+		point_to_dvec2(position_on_bezpath(bezpath, t, euclidian))
 	})
 }
 
@@ -1336,19 +1339,20 @@ async fn tangent_on_path(
 	let vector_data_transform = vector_data.transform();
 	let vector_data = vector_data.one_instance_ref().instance;
 
-	let subpaths_count = vector_data.stroke_bezier_paths().count() as f64;
-	let progress = progress.clamp(0., subpaths_count);
-	let progress = if reverse { subpaths_count - progress } else { progress };
-	let index = if progress >= subpaths_count { (subpaths_count - 1.) as usize } else { progress as usize };
+	let mut bezpaths = vector_data.stroke_bezpath_iter().collect::<Vec<kurbo::BezPath>>();
+	let bezpath_count = bezpaths.len() as f64;
+	let progress = progress.clamp(0., bezpath_count);
+	let progress = if reverse { bezpath_count - progress } else { progress };
+	let index = if progress >= bezpath_count { (bezpath_count - 1.) as usize } else { progress as usize };
 
-	vector_data.stroke_bezier_paths().nth(index).map_or(0., |mut subpath| {
-		subpath.apply_transform(vector_data_transform);
+	bezpaths.get_mut(index).map_or(0., |bezpath| {
+		let t = if progress == bezpath_count { 1. } else { progress.fract() };
+		bezpath.apply_affine(Affine::new(vector_data_transform.to_cols_array()));
 
-		let t = if progress == subpaths_count { 1. } else { progress.fract() };
-		let mut tangent = subpath.tangent(if euclidian { SubpathTValue::GlobalEuclidean(t) } else { SubpathTValue::GlobalParametric(t) });
+		let mut tangent = point_to_dvec2(tangent_on_bezpath(bezpath, t, euclidian));
 		if tangent == DVec2::ZERO {
 			let t = t + if t > 0.5 { -0.001 } else { 0.001 };
-			tangent = subpath.tangent(if euclidian { SubpathTValue::GlobalEuclidean(t) } else { SubpathTValue::GlobalParametric(t) });
+			tangent = point_to_dvec2(tangent_on_bezpath(bezpath, t, euclidian));
 		}
 		if tangent == DVec2::ZERO {
 			return 0.;


Reviews:
user: login: Keavon
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-04-25T07:07:57Z
user: login: indierusty
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-25T07:10:11Z
user: login: Keavon
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-04-25T07:11:14Z
user: login: indierusty
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-25T07:15:45Z
user: login: Keavon
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-04-25T07:16:07Z
user: login: indierusty
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-25T07:21:11Z
user: login: Keavon
state: APPROVED
author_association: MEMBER
submitted_at: 2025-04-30T04:30:40Z

Review comments:
diff_hunk: @@ -111,3 +111,7 @@ pub fn point_to_dvec2(point: Point) -> DVec2 {
 pub fn dvec2_to_point(value: DVec2) -> Point {
 	Point { x: value.x, y: value.y }
 }
+
+pub fn daffine2_to_affine(value: DAffine2) -> Affine {
+	Affine::new(value.to_cols_array())
path: node-graph/gcore/src/vector/misc.rs
user: login: Keavon
body: This only gets used once, it's better to just do it inline.
created_at: 2025-04-25T07:07:56Z
author_association: MEMBER
diff_hunk: @@ -111,3 +111,7 @@ pub fn point_to_dvec2(point: Point) -> DVec2 {
 pub fn dvec2_to_point(value: DVec2) -> Point {
 	Point { x: value.x, y: value.y }
 }
+
+pub fn daffine2_to_affine(value: DAffine2) -> Affine {
+	Affine::new(value.to_cols_array())
path: node-graph/gcore/src/vector/misc.rs
user: login: indierusty
body: It will be used in tangent_on_path and probably at more places.
created_at: 2025-04-25T07:10:11Z
author_association: CONTRIBUTOR
diff_hunk: @@ -111,3 +111,7 @@ pub fn point_to_dvec2(point: Point) -> DVec2 {
 pub fn dvec2_to_point(value: DVec2) -> Point {
 	Point { x: value.x, y: value.y }
 }
+
+pub fn daffine2_to_affine(value: DAffine2) -> Affine {
+	Affine::new(value.to_cols_array())
path: node-graph/gcore/src/vector/misc.rs
user: login: Keavon
body: It's still only a few characters longer than the function name. I don't think it's worth keeping a function definition for something that's already so tersely inlined.
created_at: 2025-04-25T07:11:14Z
author_association: MEMBER
diff_hunk: @@ -111,3 +111,7 @@ pub fn point_to_dvec2(point: Point) -> DVec2 {
 pub fn dvec2_to_point(value: DVec2) -> Point {
 	Point { x: value.x, y: value.y }
 }
+
+pub fn daffine2_to_affine(value: DAffine2) -> Affine {
+	Affine::new(value.to_cols_array())
path: node-graph/gcore/src/vector/misc.rs
user: login: indierusty
body: Got it. Should I make a commit for this?
created_at: 2025-04-25T07:15:45Z
author_association: CONTRIBUTOR
diff_hunk: @@ -111,3 +111,7 @@ pub fn point_to_dvec2(point: Point) -> DVec2 {
 pub fn dvec2_to_point(value: DVec2) -> Point {
 	Point { x: value.x, y: value.y }
 }
+
+pub fn daffine2_to_affine(value: DAffine2) -> Affine {
+	Affine::new(value.to_cols_array())
path: node-graph/gcore/src/vector/misc.rs
user: login: Keavon
body: Doesn't have to be its own commit.
created_at: 2025-04-25T07:16:07Z
author_association: MEMBER
diff_hunk: @@ -111,3 +111,7 @@ pub fn point_to_dvec2(point: Point) -> DVec2 {
 pub fn dvec2_to_point(value: DVec2) -> Point {
 	Point { x: value.x, y: value.y }
 }
+
+pub fn daffine2_to_affine(value: DAffine2) -> Affine {
+	Affine::new(value.to_cols_array())
path: node-graph/gcore/src/vector/misc.rs
user: login: indierusty
body: Fixed it.
created_at: 2025-04-25T07:21:11Z
author_association: CONTRIBUTOR

PR comments:
user: login: Keavon
created_at: 2025-04-27T01:47:03Z
body: !build
author_association: MEMBER
user: login: github-actions[bot]
created_at: 2025-04-27T01:56:54Z
body: | 📦 **Build Complete for** 43c5e482622b758afbebaf5f6ea26545204454ff |
|-|
| https://1974ef60.graphite.pages.dev |
author_association: NONE