Skip to content

Commit e267413

Browse files
authored
Merge branch 'master' into improve-save-document
2 parents 59c7e9d + 7c30f61 commit e267413

File tree

7 files changed

+186
-8
lines changed

7 files changed

+186
-8
lines changed

Cargo.lock

Lines changed: 12 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

Cargo.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -159,6 +159,7 @@ syn = { version = "2.0", default-features = false, features = [
159159
"proc-macro",
160160
] }
161161
kurbo = { version = "0.11.0", features = ["serde"] }
162+
lyon_geom = "1.0"
162163
petgraph = { version = "0.7.1", default-features = false, features = [
163164
"graphmap",
164165
] }

editor/src/messages/portfolio/document/utility_types/document_metadata.rs

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -161,6 +161,17 @@ impl DocumentMetadata {
161161
.reduce(Quad::combine_bounds)
162162
}
163163

164+
/// Get the loose bounding box of the click target of the specified layer in the specified transform space
165+
pub fn loose_bounding_box_with_transform(&self, layer: LayerNodeIdentifier, transform: DAffine2) -> Option<[DVec2; 2]> {
166+
self.click_targets(layer)?
167+
.iter()
168+
.filter_map(|click_target| match click_target.target_type() {
169+
ClickTargetType::Subpath(subpath) => subpath.loose_bounding_box_with_transform(transform),
170+
ClickTargetType::FreePoint(_) => click_target.bounding_box_with_transform(transform),
171+
})
172+
.reduce(Quad::combine_bounds)
173+
}
174+
164175
/// Calculate the corners of the bounding box but with a nonzero size.
165176
///
166177
/// If the layer bounds are `0` in either axis then they are changed to be `1`.

editor/src/messages/tool/common_functionality/snapping.rs

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -332,7 +332,8 @@ impl SnapManager {
332332
}
333333
return;
334334
}
335-
let Some(bounds) = document.metadata().bounding_box_with_transform(layer, DAffine2::IDENTITY) else {
335+
// We use a loose bounding box here since these are potential candidates which will be filtered later anyway
336+
let Some(bounds) = document.metadata().loose_bounding_box_with_transform(layer, DAffine2::IDENTITY) else {
336337
return;
337338
};
338339
let layer_bounds = document.metadata().transform_to_document(layer) * Quad::from_box(bounds);

editor/src/messages/tool/common_functionality/snapping/layer_snapper.rs

Lines changed: 100 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@ use crate::consts::HIDE_HANDLE_DISTANCE;
33
use crate::messages::portfolio::document::utility_types::document_metadata::LayerNodeIdentifier;
44
use crate::messages::portfolio::document::utility_types::misc::*;
55
use crate::messages::prelude::*;
6-
use glam::{DAffine2, DVec2};
6+
use glam::{DAffine2, DVec2, FloatExt};
77
use graphene_std::math::math_ext::QuadExt;
88
use graphene_std::renderer::Quad;
99
use graphene_std::subpath::pathseg_points;
@@ -13,7 +13,7 @@ use graphene_std::vector::algorithms::bezpath_algorithms::{pathseg_normals_to_po
1313
use graphene_std::vector::algorithms::intersection::filtered_segment_intersections;
1414
use graphene_std::vector::misc::dvec2_to_point;
1515
use graphene_std::vector::misc::point_to_dvec2;
16-
use kurbo::{Affine, DEFAULT_ACCURACY, Nearest, ParamCurve, ParamCurveNearest, PathSeg};
16+
use kurbo::{Affine, ParamCurve, PathSeg};
1717

1818
#[derive(Clone, Debug, Default)]
1919
pub struct LayerSnapper {
@@ -107,9 +107,11 @@ impl LayerSnapper {
107107
if path.document_curve.start().distance_squared(path.document_curve.end()) < tolerance * tolerance * 2. {
108108
continue;
109109
}
110-
let Nearest { distance_sq, t } = path.document_curve.nearest(dvec2_to_point(point.document_point), DEFAULT_ACCURACY);
111-
let snapped_point_document = point_to_dvec2(path.document_curve.eval(t));
112-
let distance = distance_sq.sqrt();
110+
let Some((distance_squared, closest)) = path.approx_nearest_point(point.document_point, 10) else {
111+
continue;
112+
};
113+
let snapped_point_document = point_to_dvec2(closest);
114+
let distance = distance_squared.sqrt();
113115

114116
if distance < tolerance {
115117
snap_results.curves.push(SnappedCurve {
@@ -322,6 +324,99 @@ struct SnapCandidatePath {
322324
bounds: Option<Quad>,
323325
}
324326

327+
impl SnapCandidatePath {
328+
/// Calculates the point on the curve which lies closest to `point`.
329+
///
330+
/// ## Algorithm:
331+
/// 1. We first perform a coarse scan of the path segment to find the most promising starting point.
332+
/// 2. Afterwards we refine this point by performing a binary search to either side assuming that the segment contains at most one extremal point.
333+
/// 3. The smaller of the two resulting distances is returned.
334+
///
335+
/// ## Visualization:
336+
/// ```text
337+
/// Query Point (×)
338+
/// ×
339+
/// /|\
340+
/// / | \ distance checks
341+
/// / | \
342+
/// v v v
343+
/// ●---●---●---●---● <- Curve with coarse scan points
344+
/// 0 0.25 0.5 0.75 1 (parameter t values)
345+
/// ^ ^
346+
/// | | |
347+
/// min mid max
348+
/// Find closest scan point
349+
///
350+
/// Refine left region using binary search:
351+
///
352+
/// ●------●------●
353+
/// 0.25 0.375 0.5
354+
///
355+
/// Result: | (=0.4)
356+
/// And the right region:
357+
///
358+
/// ●------●------●
359+
/// 0.5 0.625 0.75
360+
/// Result: | (=0.5)
361+
///
362+
/// The t value with minimal dist is thus 0.4
363+
/// Return: (dist_closest, point_on_curve)
364+
/// ```
365+
pub fn approx_nearest_point(&self, point: DVec2, lut_steps: usize) -> Option<(f64, kurbo::Point)> {
366+
let point = dvec2_to_point(point);
367+
368+
let time_values = (0..lut_steps).map(|x| x as f64 / lut_steps as f64);
369+
let points = time_values.map(|t| (t, self.document_curve.eval(t)));
370+
let points_with_distances = points.map(|(t, p)| (t, p.distance_squared(point), p));
371+
let (t, _, _) = points_with_distances.min_by(|(_, a, _), (_, b, _)| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal))?;
372+
373+
let min_t = (t - (lut_steps as f64).recip()).max(0.);
374+
let max_t = (t + (lut_steps as f64).recip()).min(1.);
375+
let left = self.refine_nearest_point(point, min_t, t);
376+
let right = self.refine_nearest_point(point, t, max_t);
377+
378+
if left.0 < right.0 { Some(left) } else { Some(right) }
379+
}
380+
381+
/// Refines the nearest point search within a given parameter range using binary search.
382+
///
383+
/// This method performs iterative refinement by:
384+
/// 1. Evaluating the midpoint of the current parameter range
385+
/// 2. Comparing distances at the endpoints and midpoint
386+
/// 3. Narrowing the search range to the side with the shorter distance
387+
/// 4. Continuing until convergence (when the range becomes very small)
388+
///
389+
/// Returns a tuple of (parameter_t, closest_point) where parameter_t is in the range [min_t, max_t].
390+
fn refine_nearest_point(&self, point: kurbo::Point, mut min_t: f64, mut max_t: f64) -> (f64, kurbo::Point) {
391+
let mut min_dist = self.document_curve.eval(min_t).distance_squared(point);
392+
let mut max_dist = self.document_curve.eval(max_t).distance_squared(point);
393+
let mut mid_t = max_t.lerp(min_t, 0.5);
394+
let mut mid_point = self.document_curve.eval(mid_t);
395+
let mut mid_dist = mid_point.distance_squared(point);
396+
397+
for _ in 0..10 {
398+
if (min_dist - max_dist).abs() < 1e-3 {
399+
return (mid_dist, mid_point);
400+
}
401+
if mid_dist > min_dist && mid_dist > max_dist {
402+
return (mid_dist, mid_point);
403+
}
404+
if max_dist > min_dist {
405+
max_t = mid_t;
406+
max_dist = mid_dist;
407+
} else {
408+
min_t = mid_t;
409+
min_dist = mid_dist;
410+
}
411+
mid_t = max_t.lerp(min_t, 0.5);
412+
mid_point = self.document_curve.eval(mid_t);
413+
mid_dist = mid_point.distance_squared(point);
414+
}
415+
416+
(mid_dist, mid_point)
417+
}
418+
}
419+
325420
#[derive(Clone, Debug, Default)]
326421
pub struct SnapCandidatePoint {
327422
pub document_point: DVec2,

node-graph/gcore/Cargo.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,7 @@ tinyvec = { workspace = true }
3535
parley = { workspace = true }
3636
skrifa = { workspace = true }
3737
kurbo = { workspace = true }
38+
lyon_geom = { workspace = true }
3839
log = { workspace = true }
3940
base64 = { workspace = true }
4041
poly-cool = { workspace = true }

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

Lines changed: 59 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,24 @@
11
use super::contants::MIN_SEPARATION_VALUE;
22
use kurbo::{BezPath, DEFAULT_ACCURACY, ParamCurve, PathSeg, Shape};
3+
use lyon_geom::{CubicBezierSegment, Point};
4+
5+
/// Converts a kurbo cubic bezier to a lyon_geom CubicBezierSegment
6+
fn kurbo_cubic_to_lyon(cubic: kurbo::CubicBez) -> CubicBezierSegment<f64> {
7+
CubicBezierSegment {
8+
from: Point::new(cubic.p0.x, cubic.p0.y),
9+
ctrl1: Point::new(cubic.p1.x, cubic.p1.y),
10+
ctrl2: Point::new(cubic.p2.x, cubic.p2.y),
11+
to: Point::new(cubic.p3.x, cubic.p3.y),
12+
}
13+
}
14+
15+
/// Fast cubic-cubic intersection using lyon_geom's analytical approach
16+
fn cubic_cubic_intersections_lyon(cubic1: kurbo::CubicBez, cubic2: kurbo::CubicBez) -> Vec<(f64, f64)> {
17+
let lyon_cubic1 = kurbo_cubic_to_lyon(cubic1);
18+
let lyon_cubic2 = kurbo_cubic_to_lyon(cubic2);
19+
20+
lyon_cubic1.cubic_intersections_t(&lyon_cubic2).to_vec()
21+
}
322

423
/// Calculates the intersection points the bezpath has with a given segment and returns a list of `(usize, f64)` tuples,
524
/// where the `usize` represents the index of the segment in the bezpath, and the `f64` represents the `t`-value local to
@@ -37,6 +56,8 @@ pub fn segment_intersections(segment1: PathSeg, segment2: PathSeg, accuracy: Opt
3756
match (segment1, segment2) {
3857
(PathSeg::Line(line), segment2) => segment2.intersect_line(line).iter().map(|i| (i.line_t, i.segment_t)).collect(),
3958
(segment1, PathSeg::Line(line)) => segment1.intersect_line(line).iter().map(|i| (i.segment_t, i.line_t)).collect(),
59+
// Fast path for cubic-cubic intersections using lyon_geom
60+
(PathSeg::Cubic(cubic1), PathSeg::Cubic(cubic2)) => cubic_cubic_intersections_lyon(cubic1, cubic2),
4061
(segment1, segment2) => {
4162
let mut intersections = Vec::new();
4263
segment_intersections_inner(segment1, 0., 1., segment2, 0., 1., accuracy, &mut intersections);
@@ -51,6 +72,21 @@ pub fn subsegment_intersections(segment1: PathSeg, min_t1: f64, max_t1: f64, seg
5172
match (segment1, segment2) {
5273
(PathSeg::Line(line), segment2) => segment2.intersect_line(line).iter().map(|i| (i.line_t, i.segment_t)).collect(),
5374
(segment1, PathSeg::Line(line)) => segment1.intersect_line(line).iter().map(|i| (i.segment_t, i.line_t)).collect(),
75+
// Fast path for cubic-cubic intersections using lyon_geom with subsegment parameters
76+
(PathSeg::Cubic(cubic1), PathSeg::Cubic(cubic2)) => {
77+
let sub_cubic1 = cubic1.subsegment(min_t1..max_t1);
78+
let sub_cubic2 = cubic2.subsegment(min_t2..max_t2);
79+
80+
cubic_cubic_intersections_lyon(sub_cubic1, sub_cubic2)
81+
.into_iter()
82+
// Convert subsegment t-values back to original segment t-values
83+
.map(|(t1, t2)| {
84+
let original_t1 = min_t1 + t1 * (max_t1 - min_t1);
85+
let original_t2 = min_t2 + t2 * (max_t2 - min_t2);
86+
(original_t1, original_t2)
87+
})
88+
.collect()
89+
}
5490
(segment1, segment2) => {
5591
let mut intersections = Vec::new();
5692
segment_intersections_inner(segment1, min_t1, max_t1, segment2, min_t2, max_t2, accuracy, &mut intersections);
@@ -59,12 +95,33 @@ pub fn subsegment_intersections(segment1: PathSeg, min_t1: f64, max_t1: f64, seg
5995
}
6096
}
6197

98+
fn approx_bounding_box(path_seg: PathSeg) -> kurbo::Rect {
99+
use kurbo::Rect;
100+
match path_seg {
101+
PathSeg::Line(line) => kurbo::Rect::from_points(line.p0, line.p1),
102+
PathSeg::Quad(quad_bez) => {
103+
let r1 = Rect::from_points(quad_bez.p0, quad_bez.p1);
104+
let r2 = Rect::from_points(quad_bez.p1, quad_bez.p2);
105+
r1.union(r2)
106+
}
107+
PathSeg::Cubic(cubic_bez) => {
108+
let r1 = Rect::from_points(cubic_bez.p0, cubic_bez.p1);
109+
let r2 = Rect::from_points(cubic_bez.p2, cubic_bez.p3);
110+
r1.union(r2)
111+
}
112+
}
113+
}
114+
62115
/// Implements [https://pomax.github.io/bezierinfo/#curveintersection] to find intersection between two Bezier segments
63116
/// by splitting the segment recursively until the size of the subsegment's bounding box is smaller than the accuracy.
64117
#[allow(clippy::too_many_arguments)]
65118
fn segment_intersections_inner(segment1: PathSeg, min_t1: f64, max_t1: f64, segment2: PathSeg, min_t2: f64, max_t2: f64, accuracy: f64, intersections: &mut Vec<(f64, f64)>) {
66-
let bbox1 = segment1.subsegment(min_t1..max_t1).bounding_box();
67-
let bbox2 = segment2.subsegment(min_t2..max_t2).bounding_box();
119+
let bbox1 = approx_bounding_box(segment1.subsegment(min_t1..max_t1));
120+
let bbox2 = approx_bounding_box(segment2.subsegment(min_t2..max_t2));
121+
122+
if intersections.len() > 50 {
123+
return;
124+
}
68125

69126
let mid_t1 = (min_t1 + max_t1) / 2.;
70127
let mid_t2 = (min_t2 + max_t2) / 2.;

0 commit comments

Comments
 (0)