Skip to content

Commit caebe76

Browse files
authored
Fix the Path tool's G G anchor slide so it doesn't jitter or distort as badly (#4948)
1 parent 9aa5438 commit caebe76

3 files changed

Lines changed: 135 additions & 110 deletions

File tree

‎.github/pr-title-review/shared.ts‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,8 @@ export const ISSUES = {
1717
lists_changes: "It strings together too many unrelated changes to read as a headline.",
1818
describes_mechanism: "It describes how the change works, rather than what users get from it.",
1919
inaccurate: "It claims something the changes in this PR don't do.",
20-
formatting: 'It should be in sentence case without a trailing period, and without prefixes like "feat:", issue numbers, or emoji.',
20+
capitalization: 'It should be in sentence case, with proper nouns and names capitalized, like "Vello", "WGPU", "the Pen tool", "the Layers panel", or "the Shift key".',
21+
formatting: 'It should have no trailing period, and no prefix like "feat:", issue number, emoji, or tag like "[WIP]".',
2122
spelling: "It has a misspelling or a non-American English spelling.",
2223
} as const;
2324
export type IssueCode = keyof typeof ISSUES;

‎editor/src/messages/tool/common_functionality/utility_functions.rs‎

Lines changed: 125 additions & 90 deletions
Original file line numberDiff line numberDiff line change
@@ -9,13 +9,13 @@ use crate::messages::tool::common_functionality::graph_modification_utils::{Node
99
use crate::messages::tool::common_functionality::transformation_cage::SelectedEdges;
1010
use crate::messages::tool::tool_messages::path_tool::PathOverlayMode;
1111
use crate::messages::tool::utility_types::ToolType;
12-
use glam::{DAffine2, DVec2};
12+
use glam::{DAffine2, DMat2, DVec2};
1313
use graph_craft::document::value::TaggedValue;
1414
use graphene_std::renderer::Quad;
15-
use graphene_std::vector::algorithms::bezpath_algorithms::pathseg_compute_lookup_table;
16-
use graphene_std::vector::misc::{HandleId, ManipulatorPointId, dvec2_to_point};
15+
use graphene_std::vector::algorithms::util::pathseg_tangent;
16+
use graphene_std::vector::misc::{HandleId, ManipulatorPointId, dvec2_to_point, point_to_dvec2};
1717
use graphene_std::vector::{HandleExt, PointId, SegmentId, VectorGeometry, VectorModificationType};
18-
use kurbo::{CubicBez, DEFAULT_ACCURACY, ParamCurve, PathSeg, Point, Shape};
18+
use kurbo::{CubicBez, DEFAULT_ACCURACY, ParamCurve, ParamCurveDeriv, PathSeg, Shape};
1919

2020
/// Determines if a path should be extended. Goal in viewport space. Returns the path and if it is extending from the start, if applicable.
2121
pub fn should_extend(document: &DocumentMessageHandler, goal: DVec2, tolerance: f64, layers: impl Iterator<Item = LayerNodeIdentifier>) -> Option<(LayerNodeIdentifier, PointId, DVec2)> {
@@ -499,104 +499,120 @@ pub fn transforming_transform_cage(
499499
(false, false, false)
500500
}
501501

502-
/// Calculates similarity metric between new bezier curve and two old beziers by using sampled points.
503-
#[allow(clippy::too_many_arguments)]
504-
pub fn log_optimization(a: f64, b: f64, p1: DVec2, p3: DVec2, d1: DVec2, d2: DVec2, points1: &[DVec2], n: usize) -> f64 {
505-
let start_handle_length = a.exp();
506-
let end_handle_length = b.exp();
507-
508-
// Compute the handle positions of new bezier curve
509-
let c1 = p1 + d1 * start_handle_length;
510-
let c2 = p3 + d2 * end_handle_length;
511-
512-
let new_curve = PathSeg::Cubic(CubicBez::new(Point::new(p1.x, p1.y), Point::new(c1.x, c1.y), Point::new(c2.x, c2.y), Point::new(p3.x, p3.y)));
513-
514-
// Sample 2*n points from new curve and get the L2 metric between all of points
515-
let points = pathseg_compute_lookup_table(new_curve, Some(2 * n), false);
516-
517-
let dist = points1.iter().zip(points).map(|(p1, p2)| (p1.x - p2.x).powi(2) + (p1.y - p2.y).powi(2)).sum::<f64>();
518-
519-
dist / (2 * n) as f64
520-
}
521-
522-
/// Calculates optimal handle lengths with adam optimization.
523-
#[allow(clippy::too_many_arguments)]
524-
pub fn find_two_param_best_approximate(p1: DVec2, p3: DVec2, d1: DVec2, d2: DVec2, min_len1: f64, min_len2: f64, further_segment: PathSeg, other_segment: PathSeg) -> (DVec2, DVec2) {
525-
let h = 1e-6;
526-
let tol = 1e-6;
527-
let max_iter = 200;
528-
529-
let mut a = (5_f64).ln();
530-
let mut b = (5_f64).ln();
531-
532-
let mut m_a = 0.;
533-
let mut v_a = 0.;
534-
let mut m_b = 0.;
535-
let mut v_b = 0.;
536-
537-
let initial_alpha = 0.05;
538-
let decay_rate: f64 = 0.99;
539-
540-
let beta1 = 0.9;
541-
let beta2 = 0.999;
542-
let epsilon = 1e-8;
543-
544-
let n = 20;
545-
546-
let further_segment = if further_segment.start().distance(dvec2_to_point(p1)) >= f64::EPSILON {
547-
further_segment.reverse()
502+
/// Points sampled along each of the two segments that [`fit_handles_to_segment_pair`] fits.
503+
const SEGMENT_PAIR_FIT_SAMPLES: usize = 16;
504+
/// Passes in [`fit_handles_to_segment_pair`] that each move the samples to their closest points on the fitted cubic and refit it.
505+
const SEGMENT_PAIR_FIT_PASSES: usize = 12;
506+
/// How much a sample's error along the fitted cubic counts relative to its distance from it, which damps each pass.
507+
const SEGMENT_PAIR_FIT_TANGENTIAL_WEIGHT: f64 = 0.01;
508+
509+
/// Finds the handles (relative to their anchors) that make the cubic from `start` to `end` best match `first_segment` followed by `second_segment`.
510+
/// The handles keep the given directions. The fit minimizes the squared distance from the segments to the cubic, so a pair split from one cubic is rebuilt exactly.
511+
pub fn fit_handles_to_segment_pair(start: DVec2, end: DVec2, start_direction: DVec2, end_direction: DVec2, first_segment: PathSeg, second_segment: PathSeg) -> (DVec2, DVec2) {
512+
let start_direction = start_direction.try_normalize().unwrap_or_default();
513+
let end_direction = end_direction.try_normalize().unwrap_or_default();
514+
515+
// Orient the segments to run from `start` through the anchor they share to `end`
516+
let first_segment = if first_segment.start().distance(dvec2_to_point(start)) > first_segment.end().distance(dvec2_to_point(start)) {
517+
first_segment.reverse()
548518
} else {
549-
further_segment
519+
first_segment
550520
};
551-
552-
let other_segment = if other_segment.end().distance(dvec2_to_point(p3)) >= f64::EPSILON {
553-
other_segment.reverse()
521+
let second_segment = if second_segment.end().distance(dvec2_to_point(end)) > second_segment.start().distance(dvec2_to_point(end)) {
522+
second_segment.reverse()
554523
} else {
555-
other_segment
524+
second_segment
556525
};
557526

558-
// Now we sample points proportional to the lengths of the beziers
559-
let l1 = further_segment.perimeter(DEFAULT_ACCURACY);
560-
let l2 = other_segment.perimeter(DEFAULT_ACCURACY);
561-
let ratio = l1 / (l1 + l2);
562-
let n_points1 = ((2 * n) as f64 * ratio).floor() as usize;
563-
let mut points1 = pathseg_compute_lookup_table(further_segment, Some(n_points1), false).collect::<Vec<_>>();
564-
let mut points2 = pathseg_compute_lookup_table(other_segment, Some(n), false).collect::<Vec<_>>();
565-
points1.append(&mut points2);
566-
567-
let f = |a: f64, b: f64| -> f64 { log_optimization(a, b, p1, p3, d1, d2, &points1, n) };
568-
569-
for t in 1..=max_iter {
570-
let dfa = (f(a + h, b) - f(a - h, b)) / (2. * h);
571-
let dfb = (f(a, b + h) - f(a, b - h)) / (2. * h);
572-
573-
m_a = beta1 * m_a + (1. - beta1) * dfa;
574-
m_b = beta1 * m_b + (1. - beta1) * dfb;
575-
576-
v_a = beta2 * v_a + (1. - beta2) * dfa * dfa;
577-
v_b = beta2 * v_b + (1. - beta2) * dfb * dfb;
527+
// Each segment's samples count in proportion to its share of the total length
528+
let first_length = first_segment.perimeter(DEFAULT_ACCURACY);
529+
let second_length = second_segment.perimeter(DEFAULT_ACCURACY);
530+
let first_weight = if first_length + second_length > 0. { first_length / (first_length + second_length) } else { 1. };
531+
532+
// Start each sample at its parameter on the cubic the pair would have been split from, whose split is where the segments' speeds match
533+
let first_speed = pathseg_tangent(first_segment, 1.).length();
534+
let second_speed = pathseg_tangent(second_segment, 0.).length();
535+
let joint = if first_speed > 0. { first_speed / (first_speed + second_speed) } else { first_weight };
536+
let mut samples = (1..=SEGMENT_PAIR_FIT_SAMPLES)
537+
.flat_map(|i| {
538+
let t = i as f64 / SEGMENT_PAIR_FIT_SAMPLES as f64;
539+
[
540+
(joint * t, point_to_dvec2(first_segment.eval(t)), first_weight),
541+
(joint + (1. - joint) * t, point_to_dvec2(second_segment.eval(t)), 1. - first_weight),
542+
]
543+
})
544+
.collect::<Vec<_>>();
545+
546+
// Weighted least squares for the handle lengths, which the cubic is linear in, so this solves the 2x2 normal equations directly.
547+
// Error is measured from each sample to its parameter's point on the cubic, mostly perpendicular to `fitted` there once a fit exists.
548+
let solve = |samples: &[(f64, DVec2, f64)], fitted: Option<CubicBez>| -> DVec2 {
549+
let fitted_derivative = fitted.map(|fitted| fitted.deriv());
550+
let mut system = DMat2::ZERO;
551+
let mut right_side = DVec2::ZERO;
552+
553+
for &(parameter, target, weight) in samples {
554+
// Split the Bernstein form into the part fixed by the anchors and the parts that scale with each handle length
555+
let complement = 1. - parameter;
556+
let start_basis = 3. * complement * complement * parameter;
557+
let end_basis = 3. * complement * parameter * parameter;
558+
let fixed = (complement * complement * complement + start_basis) * start + (end_basis + parameter * parameter * parameter) * end;
559+
let jacobian = DMat2::from_cols(start_basis * start_direction, end_basis * end_direction);
560+
561+
let metric = match fitted_derivative {
562+
Some(fitted_derivative) => {
563+
let tangent = point_to_dvec2(fitted_derivative.eval(parameter)).try_normalize().unwrap_or(DVec2::X);
564+
let normal = tangent.perp();
565+
DMat2::from_cols(normal * normal.x, normal * normal.y) + DMat2::from_cols(tangent * tangent.x, tangent * tangent.y) * SEGMENT_PAIR_FIT_TANGENTIAL_WEIGHT
566+
}
567+
None => DMat2::IDENTITY,
568+
} * weight;
578569

579-
let m_a_hat = m_a / (1. - beta1.powi(t));
580-
let v_a_hat = v_a / (1. - beta2.powi(t));
581-
let m_b_hat = m_b / (1. - beta1.powi(t));
582-
let v_b_hat = v_b / (1. - beta2.powi(t));
570+
system += jacobian.transpose() * metric * jacobian;
571+
right_side += jacobian.transpose() * metric * (target - fixed);
572+
}
583573

584-
let alpha_t = initial_alpha * decay_rate.powi(t);
574+
// Use the unconstrained optimum, unless it gives a handle negative length
575+
if system.determinant() > f64::EPSILON * system.x_axis.x * system.y_axis.y {
576+
let lengths = system.inverse() * right_side;
577+
if lengths.x >= 0. && lengths.y >= 0. {
578+
return lengths;
579+
}
580+
}
585581

586-
// Update log-lengths
587-
a -= alpha_t * m_a_hat / (v_a_hat.sqrt() + epsilon);
588-
b -= alpha_t * m_b_hat / (v_b_hat.sqrt() + epsilon);
582+
// Otherwise the optimum collapses one handle and gives the other its best length from there
583+
let start_only = DVec2::new(if system.x_axis.x > 0. { (right_side.x / system.x_axis.x).max(0.) } else { 0. }, 0.);
584+
let end_only = DVec2::new(0., if system.y_axis.y > 0. { (right_side.y / system.y_axis.y).max(0.) } else { 0. });
585+
let error = |lengths: DVec2| lengths.dot(system * lengths) - 2. * right_side.dot(lengths);
586+
if error(start_only) <= error(end_only) { start_only } else { end_only }
587+
};
589588

590-
// Convergence check
591-
if dfa.abs() < tol && dfb.abs() < tol {
592-
break;
589+
let mut lengths = solve(&samples, None);
590+
591+
// Tangent distance minimization: Gauss-Newton on each sample's distance to the cubic, rather than to its parameter's point
592+
for _ in 0..SEGMENT_PAIR_FIT_PASSES {
593+
let fitted = CubicBez::new(
594+
dvec2_to_point(start),
595+
dvec2_to_point(start + start_direction * lengths.x),
596+
dvec2_to_point(end + end_direction * lengths.y),
597+
dvec2_to_point(end),
598+
);
599+
let derivative = fitted.deriv();
600+
601+
// Move each sample's parameter toward its closest point on the cubic
602+
for (parameter, target, _) in &mut samples {
603+
for _ in 0..2 {
604+
let offset = point_to_dvec2(fitted.eval(*parameter)) - *target;
605+
let tangent = point_to_dvec2(derivative.eval(*parameter));
606+
if tangent.length_squared() > 0. {
607+
*parameter = (*parameter - offset.dot(tangent) / tangent.length_squared()).clamp(0., 1.);
608+
}
609+
}
593610
}
594-
}
595611

596-
let len1 = a.exp().max(min_len1);
597-
let len2 = b.exp().max(min_len2);
612+
lengths = solve(&samples, Some(fitted));
613+
}
598614

599-
(d1 * len1, d2 * len2)
615+
(start_direction * lengths.x, end_direction * lengths.y)
600616
}
601617

602618
pub fn make_path_editable_is_allowed(network_interface: &mut NodeNetworkInterface) -> Option<LayerNodeIdentifier> {
@@ -724,6 +740,25 @@ mod tests {
724740
assert!(!starts.is_empty(), "the visible rectangles' anchors should snap");
725741
}
726742

743+
#[test]
744+
fn fitting_a_segment_pair_rebuilds_the_cubic_it_was_split_from() {
745+
let original = CubicBez::new((0., 0.), (0., -400.), (600., -400.), (600., 0.));
746+
747+
// An anchor inserted along the cubic and slid further along it leaves two segments that one cubic covers exactly
748+
for (inserted, slid) in [(0.1, 0.15), (0.3, 0.6), (0.5, 0.95)] {
749+
let further_segment = PathSeg::Cubic(original.subsegment(0. ..inserted));
750+
let absorbed_segment = PathSeg::Cubic(original.subsegment(inserted..slid));
751+
let expected = original.subsegment(0. ..slid);
752+
753+
let start = point_to_dvec2(expected.p0);
754+
let end = point_to_dvec2(expected.p3);
755+
let (start_handle, end_handle) = fit_handles_to_segment_pair(start, end, point_to_dvec2(expected.p1) - start, point_to_dvec2(expected.p2) - end, further_segment, absorbed_segment);
756+
757+
assert!((start + start_handle).abs_diff_eq(point_to_dvec2(expected.p1), 1e-6), "start handle for {inserted} slid to {slid}");
758+
assert!((end + end_handle).abs_diff_eq(point_to_dvec2(expected.p2), 1e-6), "end handle for {inserted} slid to {slid}");
759+
}
760+
}
761+
727762
#[test]
728763
fn is_almost_colinear_test() {
729764
assert!(!is_almost_colinear(DVec2::ZERO, DVec2::X, DVec2::X));

‎editor/src/messages/tool/tool_messages/path_tool.rs‎

Lines changed: 8 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,7 @@ use crate::messages::tool::common_functionality::shape_editor::{
2121
ClosestSegment, ManipulatorAngle, OpposingHandleLengths, SelectedLayerState, SelectedPointsInfo, SelectionChange, SelectionShape, SelectionShapeType, ShapeState,
2222
};
2323
use crate::messages::tool::common_functionality::snap_session::{FeatureMap, Locus, Moving, PointOptions, SnapSession, SnapView, nudge_translation, snapping_hints};
24-
use crate::messages::tool::common_functionality::utility_functions::{calculate_segment_angle, find_two_param_best_approximate, make_path_editable_is_allowed};
24+
use crate::messages::tool::common_functionality::utility_functions::{calculate_segment_angle, fit_handles_to_segment_pair, make_path_editable_is_allowed};
2525
use graphene_std::Color;
2626
use graphene_std::list::List;
2727
use graphene_std::renderer::Quad;
@@ -1390,7 +1390,9 @@ impl PathToolData {
13901390
let modification_type = closer_secondary_handle.set_relative_position(relative_position2);
13911391
responses.add(GraphOperationMessage::Vector { layer, modification_type });
13921392

1393-
let end_handle_direction = if anchor == closer_segment.start { -relative_position1 } else { -relative_position2 };
1393+
// The new anchor's handles stay colinear along the path's tangent there
1394+
let closer_tangent = pathseg_tangent(closer_segment.bezier, t_value);
1395+
let end_direction = if anchor == closer_segment.start { -closer_tangent } else { closer_tangent };
13941396

13951397
let further_segment_points = pathseg_points(further_segment.bezier);
13961398

@@ -1409,26 +1411,13 @@ impl PathToolData {
14091411
further_segment_points.p1,
14101412
)
14111413
};
1414+
// The far anchor's handle keeps its direction (and stays collapsed if it has zero length) so the path stays smooth past it
14121415
let Some(start_handle_position) = start_handle_pos else { return };
1413-
let start_handle_direction = start_handle_position - further_other_point;
1416+
let start_direction = start_handle_position - further_other_point;
14141417

1415-
// Get normalized direction vectors, if cubic handle is zero then we consider corresponding tangent
1416-
let d1 = start_handle_direction.try_normalize().unwrap_or({
1417-
if anchor == further_segment.start {
1418-
-pathseg_tangent(further_segment.bezier, 1.)
1419-
} else {
1420-
pathseg_tangent(further_segment.bezier, 0.)
1421-
}
1422-
});
1423-
1424-
let d2 = end_handle_direction.try_normalize().unwrap_or_default();
1425-
1426-
let min_len1 = start_handle_direction.length() * 0.4;
1427-
let min_len2 = end_handle_direction.length() * 0.4;
1428-
1429-
let (relative_pos1, relative_pos2) = find_two_param_best_approximate(further_other_point, new_position, d1, d2, min_len1, min_len2, further_segment.bezier, other_segment);
1418+
// Refit the further segment to cover the part of the closer segment it absorbs
1419+
let (relative_pos1, relative_pos2) = fit_handles_to_segment_pair(further_other_point, new_position, start_direction, end_direction, further_segment.bezier, other_segment);
14301420

1431-
// Now set those handles to these handle lengths keeping the directions d1, d2
14321421
let modification_type = start_handle.set_relative_position(relative_pos1);
14331422
responses.add(GraphOperationMessage::Vector { layer, modification_type });
14341423

0 commit comments

Comments
 (0)