mirror of
https://github.com/GraphiteEditor/Graphite.git
synced 2026-09-18 18:38:05 +08:00
Improve snapping performance (#3067)
* Use lyon_geom for intersection calculation of bezier segments * Implement approximate nearest point calculation and loosen bounding boxes * Add algorithm explanation * Update editor/src/messages/tool/common_functionality/snapping/layer_snapper.rs Co-authored-by: Keavon Chambers <keavon@keavon.com> --------- Co-authored-by: Keavon Chambers <keavon@keavon.com>
This commit is contained in:
@@ -161,6 +161,17 @@ impl DocumentMetadata {
|
||||
.reduce(Quad::combine_bounds)
|
||||
}
|
||||
|
||||
/// Get the loose bounding box of the click target of the specified layer in the specified transform space
|
||||
pub fn loose_bounding_box_with_transform(&self, layer: LayerNodeIdentifier, transform: DAffine2) -> Option<[DVec2; 2]> {
|
||||
self.click_targets(layer)?
|
||||
.iter()
|
||||
.filter_map(|click_target| match click_target.target_type() {
|
||||
ClickTargetType::Subpath(subpath) => subpath.loose_bounding_box_with_transform(transform),
|
||||
ClickTargetType::FreePoint(_) => click_target.bounding_box_with_transform(transform),
|
||||
})
|
||||
.reduce(Quad::combine_bounds)
|
||||
}
|
||||
|
||||
/// Calculate the corners of the bounding box but with a nonzero size.
|
||||
///
|
||||
/// If the layer bounds are `0` in either axis then they are changed to be `1`.
|
||||
|
||||
@@ -332,7 +332,8 @@ impl SnapManager {
|
||||
}
|
||||
return;
|
||||
}
|
||||
let Some(bounds) = document.metadata().bounding_box_with_transform(layer, DAffine2::IDENTITY) else {
|
||||
// We use a loose bounding box here since these are potential candidates which will be filtered later anyway
|
||||
let Some(bounds) = document.metadata().loose_bounding_box_with_transform(layer, DAffine2::IDENTITY) else {
|
||||
return;
|
||||
};
|
||||
let layer_bounds = document.metadata().transform_to_document(layer) * Quad::from_box(bounds);
|
||||
|
||||
@@ -3,7 +3,7 @@ use crate::consts::HIDE_HANDLE_DISTANCE;
|
||||
use crate::messages::portfolio::document::utility_types::document_metadata::LayerNodeIdentifier;
|
||||
use crate::messages::portfolio::document::utility_types::misc::*;
|
||||
use crate::messages::prelude::*;
|
||||
use glam::{DAffine2, DVec2};
|
||||
use glam::{DAffine2, DVec2, FloatExt};
|
||||
use graphene_std::math::math_ext::QuadExt;
|
||||
use graphene_std::renderer::Quad;
|
||||
use graphene_std::subpath::pathseg_points;
|
||||
@@ -13,7 +13,7 @@ use graphene_std::vector::algorithms::bezpath_algorithms::{pathseg_normals_to_po
|
||||
use graphene_std::vector::algorithms::intersection::filtered_segment_intersections;
|
||||
use graphene_std::vector::misc::dvec2_to_point;
|
||||
use graphene_std::vector::misc::point_to_dvec2;
|
||||
use kurbo::{Affine, DEFAULT_ACCURACY, Nearest, ParamCurve, ParamCurveNearest, PathSeg};
|
||||
use kurbo::{Affine, ParamCurve, PathSeg};
|
||||
|
||||
#[derive(Clone, Debug, Default)]
|
||||
pub struct LayerSnapper {
|
||||
@@ -107,9 +107,11 @@ impl LayerSnapper {
|
||||
if path.document_curve.start().distance_squared(path.document_curve.end()) < tolerance * tolerance * 2. {
|
||||
continue;
|
||||
}
|
||||
let Nearest { distance_sq, t } = path.document_curve.nearest(dvec2_to_point(point.document_point), DEFAULT_ACCURACY);
|
||||
let snapped_point_document = point_to_dvec2(path.document_curve.eval(t));
|
||||
let distance = distance_sq.sqrt();
|
||||
let Some((distance_squared, closest)) = path.approx_nearest_point(point.document_point, 10) else {
|
||||
continue;
|
||||
};
|
||||
let snapped_point_document = point_to_dvec2(closest);
|
||||
let distance = distance_squared.sqrt();
|
||||
|
||||
if distance < tolerance {
|
||||
snap_results.curves.push(SnappedCurve {
|
||||
@@ -322,6 +324,99 @@ struct SnapCandidatePath {
|
||||
bounds: Option<Quad>,
|
||||
}
|
||||
|
||||
impl SnapCandidatePath {
|
||||
/// Calculates the point on the curve which lies closest to `point`.
|
||||
///
|
||||
/// ## Algorithm:
|
||||
/// 1. We first perform a coarse scan of the path segment to find the most promising starting point.
|
||||
/// 2. Afterwards we refine this point by performing a binary search to either side assuming that the segment contains at most one extremal point.
|
||||
/// 3. The smaller of the two resulting distances is returned.
|
||||
///
|
||||
/// ## Visualization:
|
||||
/// ```text
|
||||
/// Query Point (×)
|
||||
/// ×
|
||||
/// /|\
|
||||
/// / | \ distance checks
|
||||
/// / | \
|
||||
/// v v v
|
||||
/// ●---●---●---●---● <- Curve with coarse scan points
|
||||
/// 0 0.25 0.5 0.75 1 (parameter t values)
|
||||
/// ^ ^
|
||||
/// | | |
|
||||
/// min mid max
|
||||
/// Find closest scan point
|
||||
///
|
||||
/// Refine left region using binary search:
|
||||
///
|
||||
/// ●------●------●
|
||||
/// 0.25 0.375 0.5
|
||||
///
|
||||
/// Result: | (=0.4)
|
||||
/// And the right region:
|
||||
///
|
||||
/// ●------●------●
|
||||
/// 0.5 0.625 0.75
|
||||
/// Result: | (=0.5)
|
||||
///
|
||||
/// The t value with minimal dist is thus 0.4
|
||||
/// Return: (dist_closest, point_on_curve)
|
||||
/// ```
|
||||
pub fn approx_nearest_point(&self, point: DVec2, lut_steps: usize) -> Option<(f64, kurbo::Point)> {
|
||||
let point = dvec2_to_point(point);
|
||||
|
||||
let time_values = (0..lut_steps).map(|x| x as f64 / lut_steps as f64);
|
||||
let points = time_values.map(|t| (t, self.document_curve.eval(t)));
|
||||
let points_with_distances = points.map(|(t, p)| (t, p.distance_squared(point), p));
|
||||
let (t, _, _) = points_with_distances.min_by(|(_, a, _), (_, b, _)| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal))?;
|
||||
|
||||
let min_t = (t - (lut_steps as f64).recip()).max(0.);
|
||||
let max_t = (t + (lut_steps as f64).recip()).min(1.);
|
||||
let left = self.refine_nearest_point(point, min_t, t);
|
||||
let right = self.refine_nearest_point(point, t, max_t);
|
||||
|
||||
if left.0 < right.0 { Some(left) } else { Some(right) }
|
||||
}
|
||||
|
||||
/// Refines the nearest point search within a given parameter range using binary search.
|
||||
///
|
||||
/// This method performs iterative refinement by:
|
||||
/// 1. Evaluating the midpoint of the current parameter range
|
||||
/// 2. Comparing distances at the endpoints and midpoint
|
||||
/// 3. Narrowing the search range to the side with the shorter distance
|
||||
/// 4. Continuing until convergence (when the range becomes very small)
|
||||
///
|
||||
/// Returns a tuple of (parameter_t, closest_point) where parameter_t is in the range [min_t, max_t].
|
||||
fn refine_nearest_point(&self, point: kurbo::Point, mut min_t: f64, mut max_t: f64) -> (f64, kurbo::Point) {
|
||||
let mut min_dist = self.document_curve.eval(min_t).distance_squared(point);
|
||||
let mut max_dist = self.document_curve.eval(max_t).distance_squared(point);
|
||||
let mut mid_t = max_t.lerp(min_t, 0.5);
|
||||
let mut mid_point = self.document_curve.eval(mid_t);
|
||||
let mut mid_dist = mid_point.distance_squared(point);
|
||||
|
||||
for _ in 0..10 {
|
||||
if (min_dist - max_dist).abs() < 1e-3 {
|
||||
return (mid_dist, mid_point);
|
||||
}
|
||||
if mid_dist > min_dist && mid_dist > max_dist {
|
||||
return (mid_dist, mid_point);
|
||||
}
|
||||
if max_dist > min_dist {
|
||||
max_t = mid_t;
|
||||
max_dist = mid_dist;
|
||||
} else {
|
||||
min_t = mid_t;
|
||||
min_dist = mid_dist;
|
||||
}
|
||||
mid_t = max_t.lerp(min_t, 0.5);
|
||||
mid_point = self.document_curve.eval(mid_t);
|
||||
mid_dist = mid_point.distance_squared(point);
|
||||
}
|
||||
|
||||
(mid_dist, mid_point)
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Default)]
|
||||
pub struct SnapCandidatePoint {
|
||||
pub document_point: DVec2,
|
||||
|
||||
Reference in New Issue
Block a user