From c0576ab4e0def95c1854abf44d1656a2772adafd Mon Sep 17 00:00:00 2001 From: Hannah Li Date: Mon, 27 Mar 2023 16:25:08 -0400 Subject: [PATCH] Bezier-rs: Add joins and caps to offsets and outlines (#1083) * Intial work * Improve miter and add round join * Get arcs to go opposite direction * Add cap and other refactors * Rename joint to join, fix some bugs * Fix single point issue * Clean up * Fix iframe sizes and update UI * Address comments and handle single point outline * Rename variables, fix branches in outline * Address comments --- libraries/bezier-rs/src/bezier/core.rs | 7 + libraries/bezier-rs/src/bezier/solvers.rs | 13 +- libraries/bezier-rs/src/bezier/transform.rs | 190 +++++++++----- libraries/bezier-rs/src/lib.rs | 2 +- libraries/bezier-rs/src/subpath/core.rs | 13 +- libraries/bezier-rs/src/subpath/solvers.rs | 199 +++++++++++++-- libraries/bezier-rs/src/subpath/transform.rs | 240 +++++++++++++++--- libraries/bezier-rs/src/utils.rs | 41 ++- .../src/components/SubpathDemoPane.ts | 11 +- .../src/features/bezier-features.ts | 12 +- .../src/features/subpath-features.ts | 13 +- .../bezier-rs-demos/src/utils/options.ts | 14 + .../other/bezier-rs-demos/src/utils/render.ts | 4 + .../other/bezier-rs-demos/src/utils/types.ts | 5 + .../other/bezier-rs-demos/wasm/src/bezier.rs | 61 +++-- website/other/bezier-rs-demos/wasm/src/lib.rs | 1 + .../other/bezier-rs-demos/wasm/src/subpath.rs | 12 +- .../other/bezier-rs-demos/wasm/src/utils.rs | 19 ++ 18 files changed, 684 insertions(+), 173 deletions(-) create mode 100644 website/other/bezier-rs-demos/wasm/src/utils.rs diff --git a/libraries/bezier-rs/src/bezier/core.rs b/libraries/bezier-rs/src/bezier/core.rs index bc3912598..7f3886c27 100644 --- a/libraries/bezier-rs/src/bezier/core.rs +++ b/libraries/bezier-rs/src/bezier/core.rs @@ -211,6 +211,13 @@ impl Bezier { self_points.len() == other_points.len() && self_points.into_iter().zip(other_points.into_iter()).all(|(a, b)| a.abs_diff_eq(b, max_abs_diff)) } + + /// Returns true if the start, end and handles of the Bezier are all at the same location + pub fn is_point(&self) -> bool { + let start = self.start(); + + self.get_points().all(|point| point.abs_diff_eq(start, MAX_ABSOLUTE_DIFFERENCE)) + } } #[cfg(test)] diff --git a/libraries/bezier-rs/src/bezier/solvers.rs b/libraries/bezier-rs/src/bezier/solvers.rs index b0b771f9d..18be83ec6 100644 --- a/libraries/bezier-rs/src/bezier/solvers.rs +++ b/libraries/bezier-rs/src/bezier/solvers.rs @@ -64,7 +64,12 @@ impl Bezier { /// pub fn tangent(&self, t: TValue) -> DVec2 { let t = self.t_value_to_parametric(t); - self.non_normalized_tangent(t).normalize() + let tangent = self.non_normalized_tangent(t); + if tangent.length() > 0. { + tangent.normalize() + } else { + tangent + } } /// Returns a normalized unit vector representing the direction of the normal at the point `t` along the curve. @@ -88,7 +93,7 @@ impl Bezier { let numerator = d.x * dd.y - d.y * dd.x; let denominator = (d.x.powf(2.) + d.y.powf(2.)).powf(1.5); - if denominator == 0. { + if denominator.abs() < MAX_ABSOLUTE_DIFFERENCE { 0. } else { numerator / denominator @@ -369,9 +374,9 @@ impl Bezier { } // Create iterators that combine a subcurve with the `t` value pair that it was trimmed with - let combined_iterator1 = self1.into_iter().zip(self1_t_values.windows(2).map(|t_pair| Range { start: t_pair[0], end: t_pair[1] })); + let combined_iterator1 = self1.into_iter().zip(self1_t_values.iter().map(|t_pair| Range { start: t_pair[0], end: t_pair[1] })); // Second one needs to be a list because Iterator does not implement copy - let combined_list2: Vec<(Bezier, Range)> = self2.into_iter().zip(self2_t_values.windows(2).map(|t_pair| Range { start: t_pair[0], end: t_pair[1] })).collect(); + let combined_list2: Vec<(Bezier, Range)> = self2.into_iter().zip(self2_t_values.iter().map(|t_pair| Range { start: t_pair[0], end: t_pair[1] })).collect(); // For each curve, look for intersections with every curve that is at least 2 indices away combined_iterator1 diff --git a/libraries/bezier-rs/src/bezier/transform.rs b/libraries/bezier-rs/src/bezier/transform.rs index 5e2555879..4df23d0d1 100644 --- a/libraries/bezier-rs/src/bezier/transform.rs +++ b/libraries/bezier-rs/src/bezier/transform.rs @@ -1,7 +1,7 @@ use super::*; use crate::compare::compare_points; -use crate::utils::{f64_compare, TValue}; +use crate::utils::{f64_compare, Cap, TValue}; use crate::{AppendType, ManipulatorGroup, Subpath}; use glam::DMat2; @@ -158,7 +158,7 @@ impl Bezier { // Verify the angle formed by the endpoint normals is sufficiently small, ensuring the on-curve point for `t = 0.5` occurs roughly in the center of the polygon. let normal_0 = self.normal(TValue::Parametric(0.)); let normal_1 = self.normal(TValue::Parametric(1.)); - let endpoint_normal_angle = (normal_0.x * normal_1.x + normal_0.y * normal_1.y).acos(); + let endpoint_normal_angle = (normal_0.x * normal_1.x + normal_0.y * normal_1.y).min(1.).acos(); endpoint_normal_angle < SCALABLE_CURVE_MAX_ENDPOINT_NORMAL_ANGLE } @@ -166,8 +166,8 @@ impl Bezier { pub(crate) fn get_extrema_t_list(&self) -> Vec { let mut extrema = self.local_extrema().into_iter().flatten().collect::>(); extrema.append(&mut vec![0., 1.]); - extrema.dedup(); extrema.sort_by(|ex1, ex2| ex1.partial_cmp(ex2).unwrap()); + extrema.dedup(); extrema } @@ -176,10 +176,10 @@ impl Bezier { /// The function takes the following parameter: /// - `step_size` - Dictates the granularity at which the function searches for reducible subcurves. The default value is `0.01`. /// A small granularity may increase the chance the function does not introduce gaps, but will increase computation time. - pub(crate) fn reduced_curves_and_t_values(&self, step_size: Option) -> (Vec, Vec) { + pub(crate) fn reduced_curves_and_t_values(&self, step_size: Option) -> (Vec, Vec<[f64; 2]>) { // A linear segment is scalable, so return itself if let BezierHandles::Linear = self.handles { - return (vec![*self], vec![0., 1.]); + return (vec![*self], vec![[0., 1.]]); } let step_size = step_size.unwrap_or(DEFAULT_REDUCE_STEP_SIZE); @@ -192,7 +192,7 @@ impl Bezier { // Split each subcurve such that each resulting segment is scalable. let mut result_beziers: Vec = Vec::new(); - let mut result_t_values: Vec = vec![extrema[0]]; + let mut result_t_values: Vec<[f64; 2]> = vec![]; extrema.windows(2).for_each(|t_pair| { let t_subcurve_start = t_pair[0]; @@ -201,7 +201,7 @@ impl Bezier { // Perform no processing on the subcurve if it's already scalable. if subcurve.is_scalable() { result_beziers.push(subcurve); - result_t_values.push(t_subcurve_end); + result_t_values.push([t_subcurve_start, t_subcurve_end]); return; } @@ -209,6 +209,7 @@ impl Bezier { let mut segment: Bezier; let mut t1 = 0.; let mut t2 = step_size; + let mut is_prev_valid = false; while t2 <= 1. + step_size { segment = subcurve.trim(TValue::Parametric(t1), TValue::Parametric(f64::min(t2, 1.))); if !segment.is_scalable() { @@ -216,14 +217,21 @@ impl Bezier { // If the previous step does not exist, the start of the subcurve is irreducible. // Otherwise, add the valid segment from the previous step to the result. - if f64::abs(t1 - t2) >= step_size { + if is_prev_valid { segment = subcurve.trim(TValue::Parametric(t1), TValue::Parametric(t2)); - result_beziers.push(segment); - result_t_values.push(t_subcurve_start + t2 * (t_subcurve_end - t_subcurve_start)); + if segment.is_scalable() { + result_beziers.push(segment); + result_t_values.push([t_subcurve_start + t1 * (t_subcurve_end - t_subcurve_start), t_subcurve_start + t2 * (t_subcurve_end - t_subcurve_start)]); + } else { + t2 = t1 + step_size; + } } else { - return; + t2 = t1 + step_size; } t1 = t2; + is_prev_valid = false; + } else { + is_prev_valid = true; } t2 += step_size; } @@ -232,7 +240,7 @@ impl Bezier { segment = subcurve.trim(TValue::Parametric(t1), TValue::Parametric(1.)); if segment.is_scalable() { result_beziers.push(segment); - result_t_values.push(t_subcurve_end); + result_t_values.push([t_subcurve_start + t1 * (t_subcurve_end - t_subcurve_start), t_subcurve_end]); } } }); @@ -349,14 +357,19 @@ impl Bezier { /// while negative values will offset in the opposite direction. /// pub fn offset(&self, distance: f64) -> Subpath { + if self.is_point() { + return Subpath::from_bezier(self); + } let reduced = self.reduce(None); let mut scaled = Subpath::new(vec![], false); reduced.iter().enumerate().for_each(|(index, bezier)| { let scaled_bezier = bezier.scale(distance); - if index > 0 && !compare_points(bezier.start(), reduced[index - 1].end()) { - scaled.append_bezier(&scaled_bezier, AppendType::SmoothJoin(MAX_ABSOLUTE_DIFFERENCE)); - } else { - scaled.append_bezier(&scaled_bezier, AppendType::IgnoreStart); + if !bezier.is_point() { + if index > 0 && !compare_points(bezier.start(), reduced[index - 1].end()) { + scaled.append_bezier(&scaled_bezier, AppendType::SmoothJoin(MAX_ABSOLUTE_DIFFERENCE)); + } else { + scaled.append_bezier(&scaled_bezier, AppendType::IgnoreStart); + } } }); @@ -376,19 +389,24 @@ impl Bezier { let mut next_start_distance = start_distance; let distance_difference = end_distance - start_distance; let total_length = self.length(None); + if total_length < MAX_ABSOLUTE_DIFFERENCE { + return Subpath::new(vec![], false); + } let mut result = Subpath::new(vec![], false); reduced.iter().enumerate().for_each(|(index, bezier)| { - let current_length = bezier.length(None); - let next_end_distance = next_start_distance + (current_length / total_length) * distance_difference; - let scaled_bezier = bezier.graduated_scale(next_start_distance, next_end_distance); + if !bezier.is_point() { + let current_length = bezier.length(None); + let next_end_distance = next_start_distance + (current_length / total_length) * distance_difference; + let scaled_bezier = bezier.graduated_scale(next_start_distance, next_end_distance); - if index > 0 && !compare_points(bezier.start(), reduced[index - 1].end()) { - result.append_bezier(&scaled_bezier, AppendType::SmoothJoin(MAX_ABSOLUTE_DIFFERENCE)); - } else { - result.append_bezier(&scaled_bezier, AppendType::IgnoreStart); + if index > 0 && !compare_points(bezier.start(), reduced[index - 1].end()) { + result.append_bezier(&scaled_bezier, AppendType::SmoothJoin(MAX_ABSOLUTE_DIFFERENCE)); + } else { + result.append_bezier(&scaled_bezier, AppendType::IgnoreStart); + } + next_start_distance = next_end_distance; } - next_start_distance = next_end_distance; }); // If the curve is not linear, smooth the handles. All segments produced by bezier::scale will be cubic. @@ -404,44 +422,48 @@ impl Bezier { /// The 'caps', the linear segments at opposite ends of the outline, intersect the original curve at the midpoint of the cap. /// Outline takes the following parameter: /// - `distance` - The outline's distance from the curve. - /// - pub fn outline(&self, distance: f64) -> Subpath { - let first_segment = self.offset(distance); - let third_segment = self.reverse().offset(distance); + /// + pub fn outline(&self, distance: f64, cap: Cap) -> Subpath { + let (pos_offset, neg_offset) = if self.is_point() { + ( + Subpath::new(vec![ManipulatorGroup::new_anchor(self.start() + DVec2::NEG_Y * distance)], false), + Subpath::new(vec![ManipulatorGroup::new_anchor(self.start() + DVec2::Y * distance)], false), + ) + } else { + (self.offset(distance), self.reverse().offset(distance)) + }; - if first_segment.is_empty() || third_segment.is_empty() { + if pos_offset.is_empty() || neg_offset.is_empty() { return Subpath::new(vec![], false); } - let mut result_manipulator_groups: Vec> = vec![]; - result_manipulator_groups.extend_from_slice(first_segment.manipulator_groups()); - // TODO: Handle other caps here - result_manipulator_groups.extend_from_slice(third_segment.manipulator_groups()); - Subpath::new(result_manipulator_groups, true) + pos_offset.combine_outline(&neg_offset, cap) } /// Version of the `outline` function which draws the outline at the specified distances away from the curve. /// The outline begins `start_distance` away, and gradually move to being `end_distance` away. - /// - pub fn graduated_outline(&self, start_distance: f64, end_distance: f64) -> Subpath { - self.skewed_outline(start_distance, end_distance, end_distance, start_distance) + /// + pub fn graduated_outline(&self, start_distance: f64, end_distance: f64, cap: Cap) -> Subpath { + self.skewed_outline(start_distance, end_distance, end_distance, start_distance, cap) } /// Version of the `graduated_outline` function that allows for the 4 corners of the outline to be different distances away from the curve. - /// - pub fn skewed_outline(&self, distance1: f64, distance2: f64, distance3: f64, distance4: f64) -> Subpath { - let first_segment = self.graduated_offset(distance1, distance2); - let third_segment = self.reverse().graduated_offset(distance3, distance4); + /// + pub fn skewed_outline(&self, distance1: f64, distance2: f64, distance3: f64, distance4: f64, cap: Cap) -> Subpath { + let (pos_offset, neg_offset) = if self.is_point() { + ( + Subpath::new(vec![ManipulatorGroup::new_anchor(self.start() + DVec2::NEG_Y * distance1)], false), + Subpath::new(vec![ManipulatorGroup::new_anchor(self.start() + DVec2::Y * distance1)], false), + ) + } else { + (self.graduated_offset(distance1, distance2), self.reverse().graduated_offset(distance3, distance4)) + }; - if first_segment.is_empty() || third_segment.is_empty() { + if pos_offset.is_empty() || neg_offset.is_empty() { return Subpath::new(vec![], false); } - let mut result_manipulator_groups: Vec> = vec![]; - result_manipulator_groups.extend_from_slice(first_segment.manipulator_groups()); - // TODO: Handle other caps here - result_manipulator_groups.extend_from_slice(third_segment.manipulator_groups()); - Subpath::new(result_manipulator_groups, true) + pos_offset.combine_outline(&neg_offset, cap) } /// Approximate a bezier curve with circular arcs. @@ -596,8 +618,8 @@ impl Bezier { #[cfg(test)] mod tests { use super::*; - use crate::compare::{compare_arcs, compare_points, compare_vec_of_points}; - use crate::utils::TValue; + use crate::compare::{compare_arcs, compare_points}; + use crate::utils::{Cap, TValue}; use crate::EmptyId; #[test] @@ -748,17 +770,8 @@ mod tests { let p3 = DVec2::new(0., 0.); let bezier = Bezier::from_quadratic_dvec2(p1, p2, p3); - let expected_bezier_points = vec![ - vec![DVec2::new(0., 0.), DVec2::new(0.5, 0.5), DVec2::new(0.989, 0.989)], - vec![DVec2::new(0.989, 0.989), DVec2::new(2.705, 2.705), DVec2::new(4.2975, 4.2975)], - vec![DVec2::new(4.2975, 4.2975), DVec2::new(5.6625, 5.6625), DVec2::new(6.9375, 6.9375)], - ]; let reduced_curves = bezier.reduce(None); - assert!(reduced_curves.iter().zip(expected_bezier_points.into_iter()).all(|(bezier, points)| compare_vec_of_points( - bezier.get_points().collect::>(), - points, - MAX_ABSOLUTE_DIFFERENCE - ))); + assert!(reduced_curves.iter().all(|bezier| bezier.is_scalable())); // Check that the reduce helper is correct let (helper_curves, helper_t_values) = bezier.reduced_curves_and_t_values(None); @@ -768,7 +781,7 @@ mod tests { .all(|(bezier1, bezier2)| bezier1.abs_diff_eq(bezier2, MAX_ABSOLUTE_DIFFERENCE))); assert!(reduced_curves .iter() - .zip(helper_t_values.windows(2)) + .zip(helper_t_values.iter()) .all(|(curve, t_pair)| curve.abs_diff_eq(&bezier.trim(TValue::Parametric(t_pair[0]), TValue::Parametric(t_pair[1])), MAX_ABSOLUTE_DIFFERENCE))) } @@ -853,12 +866,29 @@ mod tests { } } + #[test] + fn test_offset_curve_that_has_a_single_point_after_reduce() { + let p1 = DVec2::new(30., 30.); + let p2 = DVec2::new(150., 29.); + let p3 = DVec2::new(150., 30.); + let p4 = DVec2::new(160., 160.); + + let bezier = Bezier::from_cubic_dvec2(p1, p2, p3, p4); + + let reduce = bezier.reduce(None); + let offset = bezier.offset::(15.); + assert!(reduce.last().is_some()); + assert!(reduce.last().unwrap().is_point()); + // Expect the single point bezier to be dropped in the offset + assert_eq!(reduce.len(), offset.len_segments() + 1); + } + #[test] fn test_outline() { let p1 = DVec2::new(30., 50.); let p2 = DVec2::new(140., 30.); let line = Bezier::from_linear_dvec2(p1, p2); - let outline = line.outline::(10.); + let outline = line.outline::(10., Cap::Butt); assert_eq!(outline.len(), 4); @@ -883,6 +913,44 @@ mod tests { assert!(outline.iter().nth(3).unwrap().evaluate(TValue::Parametric(0.5)).abs_diff_eq(line.start(), MAX_ABSOLUTE_DIFFERENCE)); } + #[test] + fn test_outline_single_point_circle() { + let ellipse: Subpath = Subpath::new_ellipse(DVec2::new(0., 0.), DVec2::new(50., 50.)).reverse(); + let p = DVec2::new(25., 25.); + + let line = Bezier::from_linear_dvec2(p, p); + let outline = line.outline::(25., Cap::Round); + assert_eq!(outline, ellipse); + + let cubic = Bezier::from_cubic_dvec2(p, p, p, p); + let outline_cubic = cubic.outline::(25., Cap::Round); + assert_eq!(outline_cubic, ellipse); + } + + #[test] + fn test_outline_single_point_square() { + let square: Subpath = Subpath::from_anchors( + [ + DVec2::new(25., 0.), + DVec2::new(0., 0.), + DVec2::new(0., 50.), + DVec2::new(25., 50.), + DVec2::new(50., 50.), + DVec2::new(50., 0.), + ], + true, + ); + let p = DVec2::new(25., 25.); + + let line = Bezier::from_linear_dvec2(p, p); + let outline = line.outline::(25., Cap::Square); + assert_eq!(outline, square); + + let cubic = Bezier::from_cubic_dvec2(p, p, p, p); + let outline_cubic = cubic.outline::(25., Cap::Square); + assert_eq!(outline_cubic, square); + } + #[test] fn test_graduated_scale() { let bezier = Bezier::from_linear_coordinates(30., 60., 140., 120.); diff --git a/libraries/bezier-rs/src/lib.rs b/libraries/bezier-rs/src/lib.rs index 61a7fd7bf..cc74ff03a 100644 --- a/libraries/bezier-rs/src/lib.rs +++ b/libraries/bezier-rs/src/lib.rs @@ -8,4 +8,4 @@ mod utils; pub use bezier::*; pub use subpath::*; -pub use utils::{Joint, SubpathTValue, TValue}; +pub use utils::{Cap, Join, SubpathTValue, TValue}; diff --git a/libraries/bezier-rs/src/subpath/core.rs b/libraries/bezier-rs/src/subpath/core.rs index de6768aec..1b7b67f7a 100644 --- a/libraries/bezier-rs/src/subpath/core.rs +++ b/libraries/bezier-rs/src/subpath/core.rs @@ -88,7 +88,7 @@ impl Subpath { /// Returns the number of segments contained within the `Subpath`. pub fn len_segments(&self) -> usize { let mut number_of_curves = self.len(); - if !self.closed { + if !self.closed && number_of_curves > 0 { number_of_curves -= 1 } number_of_curves @@ -112,6 +112,17 @@ impl Subpath { &self.manipulator_groups } + /// Returns if the Subpath is equivalent to a single point. + pub fn is_point(&self) -> bool { + if self.is_empty() { + return false; + } + let point = self.manipulator_groups[0].anchor; + self.manipulator_groups + .iter() + .all(|manipulator_group| manipulator_group.anchor.abs_diff_eq(point, MAX_ABSOLUTE_DIFFERENCE)) + } + /// Appends to the `svg` mutable string with an SVG shape representation of the curve. pub fn curve_to_svg(&self, svg: &mut String, attributes: String) { let curve_start_argument = format!("{SVG_ARG_MOVE}{} {}", self[0].anchor.x, self[0].anchor.y); diff --git a/libraries/bezier-rs/src/subpath/solvers.rs b/libraries/bezier-rs/src/subpath/solvers.rs index 7b93fd0b1..166a5e1ff 100644 --- a/libraries/bezier-rs/src/subpath/solvers.rs +++ b/libraries/bezier-rs/src/subpath/solvers.rs @@ -1,9 +1,10 @@ use super::*; use crate::consts::MAX_ABSOLUTE_DIFFERENCE; -use crate::utils::SubpathTValue; +use crate::utils::{compute_circular_subpath_details, line_intersection, SubpathTValue}; use crate::TValue; -use glam::DVec2; +use glam::{DMat2, DVec2}; +use std::f64::consts::PI; impl Subpath { /// Calculate the point on the subpath based on the parametric `t`-value provided. @@ -22,7 +23,7 @@ impl Subpath { /// - `error`: an optional f64 value to provide an error bound /// - `minimum_separation`: the minimum difference two adjacent `t`-values must have when comparing adjacent `t`-values in sorted order. /// If the comparison condition is not satisfied, the function takes the larger `t`-value of the two. - /// + /// pub fn intersections(&self, other: &Bezier, error: Option, minimum_separation: Option) -> Vec<(usize, f64)> { self.iter() .enumerate() @@ -34,27 +35,20 @@ impl Subpath { /// This function expects the following: /// - other: a [Bezier] curve to check intersections against /// - error: an optional f64 value to provide an error bound - /// + /// pub fn subpath_intersections(&self, other: &Subpath, error: Option, minimum_separation: Option) -> Vec<(usize, f64)> { let mut intersection_t_values: Vec<(usize, f64)> = other.iter().flat_map(|bezier| self.intersections(&bezier, error, minimum_separation)).collect(); intersection_t_values.sort_by(|a, b| a.partial_cmp(b).unwrap()); intersection_t_values } - /// Returns a normalized unit vector representing the tangent on the subpath based on the parametric `t`-value provided. - /// - pub fn tangent(&self, t: SubpathTValue) -> DVec2 { - let (segment_index, t) = self.t_value_to_parametric(t); - self.get_segment(segment_index).unwrap().tangent(TValue::Parametric(t)) - } - /// Returns a list of `t` values that correspond to the self intersection points of the subpath. For each intersection point, the returned `t` value is the smaller of the two that correspond to the point. /// - `error` - For intersections with non-linear beziers, `error` defines the threshold for bounding boxes to be considered an intersection point. /// - `minimum_separation`: the minimum difference two adjacent `t`-values must have when comparing adjacent `t`-values in sorted order. /// If the comparison condition is not satisfied, the function takes the larger `t`-value of the two /// /// **NOTE**: if an intersection were to occur within an `error` distance away from an anchor point, the algorithm will filter that intersection out. - /// + /// pub fn self_intersections(&self, error: Option, minimum_separation: Option) -> Vec<(usize, f64)> { let mut intersections_vec = Vec::new(); let err = error.unwrap_or(MAX_ABSOLUTE_DIFFERENCE); @@ -74,6 +68,13 @@ impl Subpath { intersections_vec } + /// Returns a normalized unit vector representing the tangent on the subpath based on the parametric `t`-value provided. + /// + pub fn tangent(&self, t: SubpathTValue) -> DVec2 { + let (segment_index, t) = self.t_value_to_parametric(t); + self.get_segment(segment_index).unwrap().tangent(TValue::Parametric(t)) + } + /// Returns a normalized unit vector representing the direction of the normal on the subpath based on the parametric `t`-value provided. /// pub fn normal(&self, t: SubpathTValue) -> DVec2 { @@ -83,7 +84,7 @@ impl Subpath { /// Returns two lists of `t`-values representing the local extrema of the `x` and `y` parametric subpaths respectively. /// The list of `t`-values returned are filtered such that they fall within the range `[0, 1]`. - /// + /// pub fn local_extrema(&self) -> [Vec; 2] { let number_of_curves = self.len_segments() as f64; @@ -98,7 +99,7 @@ impl Subpath { } /// Return the min and max corners that represent the bounding box of the subpath. - /// + /// pub fn bounding_box(&self) -> Option<[DVec2; 2]> { self.iter().map(|bezier| bezier.bounding_box()).reduce(|bbox1, bbox2| [bbox1[0].min(bbox2[0]), bbox1[1].max(bbox2[1])]) } @@ -112,7 +113,7 @@ impl Subpath { /// Returns list of `t`-values representing the inflection points of the subpath. /// The list of `t`-values returned are filtered such that they fall within the range `[0, 1]`. - /// + /// pub fn inflections(&self) -> Vec { let number_of_curves = self.len_segments() as f64; let inflection_t_values: Vec = self @@ -135,6 +136,92 @@ impl Subpath { pub fn contains_point(&self, target_point: DVec2) -> bool { self.iter().map(|bezier| bezier.winding(target_point)).sum::() != 0 } + + /// Returns the manipulator point that is needed for a miter join if it is possible. + pub(crate) fn miter_line_join(&self, other: &Subpath) -> Option> { + let in_segment = self.get_segment(self.len_segments() - 1).unwrap(); + let out_segment = other.get_segment(0).unwrap(); + let in_tangent = in_segment.tangent(TValue::Parametric(1.)); + let out_tangent = out_segment.tangent(TValue::Parametric(0.)); + + let normalized_in_tangent = in_tangent.normalize(); + let normalized_out_tangent = out_tangent.normalize(); + + // The tangents must not be parallel for the miter join + if !normalized_in_tangent.abs_diff_eq(normalized_out_tangent, MAX_ABSOLUTE_DIFFERENCE) && !normalized_in_tangent.abs_diff_eq(-normalized_out_tangent, MAX_ABSOLUTE_DIFFERENCE) { + let intersection = line_intersection(in_segment.end(), in_tangent, out_segment.start(), out_tangent); + + // Draw the miter join if the intersection occurs in the correct direction with respect to the path + if (intersection - in_segment.end()).normalize().abs_diff_eq(in_tangent, MAX_ABSOLUTE_DIFFERENCE) + && (out_segment.start() - intersection).normalize().abs_diff_eq(out_tangent, MAX_ABSOLUTE_DIFFERENCE) + { + return Some(ManipulatorGroup { + anchor: intersection, + in_handle: None, + out_handle: None, + id: ManipulatorGroupId::new(), + }); + } + } + // If we can't draw the miter join, default to a bevel join + None + } + + /// Returns the necessary information to create a round join with the provided center. + /// The returned items correspond to: + /// - The `out_handle` for the last manipulator group of `self` + /// - The new manipulator group to be added + /// - The `in_handle` for the first manipulator group of `other` + pub(crate) fn round_line_join(&self, other: &Subpath, center: DVec2) -> (DVec2, ManipulatorGroup, DVec2) { + let left = self.manipulator_groups[self.len() - 1].anchor; + let right = other.manipulator_groups[0].anchor; + + let center_to_right = right - center; + let center_to_left = left - center; + + let in_segment = self.get_segment(self.len_segments() - 1).unwrap(); + let in_tangent = in_segment.tangent(TValue::Parametric(1.)); + + let mut angle = center_to_right.angle_between(center_to_left) / 2.; + let mut arc_point = center + DMat2::from_angle(angle).mul_vec2(center_to_right); + + if (arc_point - left).angle_between(in_tangent).abs() > PI / 2. { + angle = angle - PI * (if angle < 0. { -1. } else { 1. }); + arc_point = center + DMat2::from_angle(angle).mul_vec2(center_to_right); + } + + compute_circular_subpath_details(left, arc_point, right, center, Some(angle)) + } + + /// Returns the necessary information to create a round cap between the end of `self` and the beginning of `other`. + /// The returned items correspond to: + /// - The `out_handle` for the last manipulator group of `self` + /// - The new manipulator group to be added + /// - The `in_handle` for the first manipulator group of `other` + pub(crate) fn round_cap(&self, other: &Subpath) -> (DVec2, ManipulatorGroup, DVec2) { + let left = self.manipulator_groups[self.len() - 1].anchor; + let right = other.manipulator_groups[0].anchor; + + let center = (right + left) / 2.; + let center_to_right = right - center; + + let arc_point = center + center_to_right.perp(); + + compute_circular_subpath_details(left, arc_point, right, center, None) + } + + /// Returns the two manipulator groups that create a sqaure cap between the end of `self` and the beginning of `other`. + pub(crate) fn square_cap(&self, other: &Subpath) -> [ManipulatorGroup; 2] { + let left = self.manipulator_groups[self.len() - 1].anchor; + let right = other.manipulator_groups[0].anchor; + + let center = (right + left) / 2.; + let center_to_right = right - center; + + let translation = center_to_right.perp(); + + [ManipulatorGroup::new_anchor(left + translation), ManipulatorGroup::new_anchor(right + translation)] + } } #[cfg(test)] @@ -517,4 +604,86 @@ mod tests { } // TODO: add more intersection tests + + #[test] + fn round_join_counter_clockwise_rotation() { + // Test case where the round join is drawn in the counter clockwise direction between two consecutive offsets + let subpath = Subpath::new( + vec![ + ManipulatorGroup { + anchor: DVec2::new(20., 20.), + out_handle: Some(DVec2::new(10., 90.)), + in_handle: None, + id: EmptyId, + }, + ManipulatorGroup { + anchor: DVec2::new(114., 159.), + out_handle: None, + in_handle: Some(DVec2::new(60., 40.)), + id: EmptyId, + }, + ManipulatorGroup { + anchor: DVec2::new(148., 155.), + out_handle: None, + in_handle: None, + id: EmptyId, + }, + ], + false, + ); + + let offset = subpath.offset(10., utils::Join::Round); + let offset_len = offset.len(); + + let manipulator_groups = offset.manipulator_groups(); + let round_start = manipulator_groups[offset_len - 4].anchor; + let round_point = manipulator_groups[offset_len - 3].anchor; + let round_end = manipulator_groups[offset_len - 2].anchor; + + let middle = (round_start + round_end) / 2.; + + assert!((round_point - middle).angle_between(round_start - middle) > 0.); + assert!((round_end - middle).angle_between(round_point - middle) > 0.); + } + + #[test] + fn round_join_clockwise_rotation() { + // Test case where the round join is drawn in the clockwise direction between two consecutive offsets + let subpath = Subpath::new( + vec![ + ManipulatorGroup { + anchor: DVec2::new(20., 20.), + out_handle: Some(DVec2::new(10., 90.)), + in_handle: None, + id: EmptyId, + }, + ManipulatorGroup { + anchor: DVec2::new(150., 40.), + out_handle: None, + in_handle: Some(DVec2::new(60., 40.)), + id: EmptyId, + }, + ManipulatorGroup { + anchor: DVec2::new(78., 36.), + out_handle: None, + in_handle: None, + id: EmptyId, + }, + ], + false, + ); + + let offset = subpath.offset(-15., utils::Join::Round); + let offset_len = offset.len(); + + let manipulator_groups = offset.manipulator_groups(); + let round_start = manipulator_groups[offset_len - 4].anchor; + let round_point = manipulator_groups[offset_len - 3].anchor; + let round_end = manipulator_groups[offset_len - 2].anchor; + + let middle = (round_start + round_end) / 2.; + + assert!((round_point - middle).angle_between(round_start - middle) < 0.); + assert!((round_end - middle).angle_between(round_point - middle) < 0.); + } } diff --git a/libraries/bezier-rs/src/subpath/transform.rs b/libraries/bezier-rs/src/subpath/transform.rs index 2233b7568..0087bce05 100644 --- a/libraries/bezier-rs/src/subpath/transform.rs +++ b/libraries/bezier-rs/src/subpath/transform.rs @@ -2,9 +2,9 @@ use std::vec; use super::*; use crate::consts::MAX_ABSOLUTE_DIFFERENCE; -use crate::utils::{Joint, SubpathTValue, TValue}; +use crate::utils::{Cap, Join, SubpathTValue, TValue}; -use glam::DAffine2; +use glam::{DAffine2, DVec2}; /// Helper function to ensure the index and t value pair is mapped within a maximum index value. /// Allows for the point to be fetched without needing to handle an additional edge case. @@ -109,9 +109,14 @@ impl Subpath { } /// Returns a [Subpath] with a reversed winding order. + /// Note that a reversed closed subpath will start on the same manipulator group and simply wind the other direction pub fn reverse(&self) -> Subpath { + let mut reversed = Subpath::reverse_manipulator_groups(self.manipulator_groups()); + if self.closed { + reversed.rotate_right(1); + }; Subpath { - manipulator_groups: Subpath::reverse_manipulator_groups(&self.manipulator_groups), + manipulator_groups: reversed, closed: self.closed, } } @@ -121,7 +126,7 @@ impl Subpath { /// The resulting Subpath will wind from the given `t1` to `t2`. /// That means, if the value of `t1` > `t2`, it will cross the break between endpoints from `t1` to `t = 1 = 0` to `t2`. /// If a path winding in the reverse direction is desired, call `trim` on the `Subpath` returned from `Subpath::reverse`. - /// + /// pub fn trim(&self, t1: SubpathTValue, t2: SubpathTValue) -> Subpath { // Return a clone of the Subpath if it is not long enough to be a valid Bezier if self.manipulator_groups.is_empty() { @@ -278,6 +283,9 @@ impl Subpath { /// Smooths a Subpath up to the first derivative, using a weighted averaged based on segment length. /// The Subpath must be open, and contain no quadratic segments. pub(crate) fn smooth_open_subpath(&mut self) { + if self.len() < 2 { + return; + } for i in 1..self.len() - 1 { let first_bezier = self.manipulator_groups[i - 1].to_bezier(&self.manipulator_groups[i]); let second_bezier = self.manipulator_groups[i].to_bezier(&self.manipulator_groups[i + 1]); @@ -326,17 +334,22 @@ impl Subpath { } /// Reduces the segments of the subpath into simple subcurves, then scales each subcurve a set `distance` away. - /// The intersections of segments of the subpath are joined using the method specified by the `joint` argument. - /// - pub fn offset(&self, distance: f64, joint: Joint) -> Subpath { + /// The intersections of segments of the subpath are joined using the method specified by the `join` argument. + /// + pub fn offset(&self, distance: f64, join: Join) -> Subpath { assert!(self.len_segments() > 1, "Cannot offset an empty Subpath."); // An offset at a distance 0 from the curve is simply the same curve - if distance == 0. { + // An offset of a single point is not defined + if distance == 0. || self.len() == 1 { return self.clone(); } - let mut subpaths = self.iter().map(|bezier| bezier.offset(distance)).collect::>>(); + let mut subpaths = self + .iter() + .filter(|bezier| !bezier.is_point()) + .map(|bezier| bezier.offset(distance)) + .collect::>>(); let mut drop_common_point = vec![true; self.len()]; // Clip or join consecutive Subpaths @@ -359,7 +372,7 @@ impl Subpath { let angle = out_tangent.angle_between(in_tangent); // The angle is concave. The Subpath overlap and must be clipped - let mut apply_joint = true; + let mut apply_join = true; if (angle > 0. && distance > 0.) || (angle < 0. && distance < 0.) { // If the distance is large enough, there may still be no intersections. Also, if the angle is close enough to zero, // subpath intersections may find no intersections. In this case, the points are likely close enough that we can approximate @@ -367,16 +380,27 @@ impl Subpath { if let Some((clipped_subpath1, clipped_subpath2)) = Subpath::clip_simple_subpaths(subpath1, subpath2) { subpaths[i] = clipped_subpath1; subpaths[j] = clipped_subpath2; - apply_joint = false; + apply_join = false; } } - // The angle is convex. The Subpath must be joined using the specified Joint type - if apply_joint { - match joint { - Joint::Bevel => { - drop_common_point[j] = false; + // The angle is convex. The Subpath must be joined using the specified join type + if apply_join { + drop_common_point[j] = false; + match join { + Join::Bevel => {} + Join::Miter => { + let miter_manipulator_group = subpaths[i].miter_line_join(&subpaths[j]); + if let Some(miter_manipulator_group) = miter_manipulator_group { + subpaths[i].manipulator_groups.push(miter_manipulator_group); + } + } + Join::Round => { + let (out_handle, round_point, in_handle) = subpaths[i].round_line_join(&subpaths[j], self.manipulator_groups[j].anchor); + let last_index = subpaths[i].manipulator_groups.len() - 1; + subpaths[i].manipulator_groups[last_index].out_handle = Some(out_handle); + subpaths[i].manipulator_groups.push(round_point.clone()); + subpaths[j].manipulator_groups[0].in_handle = Some(in_handle); } - _ => unimplemented!(), } } } @@ -387,22 +411,35 @@ impl Subpath { let in_tangent = self.get_segment(0).unwrap().tangent(TValue::Parametric(0.)); let angle = out_tangent.angle_between(in_tangent); - let mut apply_joint = true; + let mut apply_join = true; if (angle > 0. && distance > 0.) || (angle < 0. && distance < 0.) { if let Some((clipped_subpath1, clipped_subpath2)) = Subpath::clip_simple_subpaths(&subpaths[subpaths.len() - 1], &subpaths[0]) { // Merge the clipped subpaths let last_index = subpaths.len() - 1; subpaths[last_index] = clipped_subpath1; subpaths[0] = clipped_subpath2; - apply_joint = false; + apply_join = false; } } - if apply_joint { - match joint { - Joint::Bevel => { - drop_common_point[0] = false; + if apply_join { + drop_common_point[0] = false; + match join { + Join::Bevel => {} + Join::Miter => { + let last_subpath_index = subpaths.len() - 1; + let miter_manipulator_group = subpaths[last_subpath_index].miter_line_join(&subpaths[0]); + if let Some(miter_manipulator_group) = miter_manipulator_group { + subpaths[last_subpath_index].manipulator_groups.push(miter_manipulator_group); + } + } + Join::Round => { + let last_subpath_index = subpaths.len() - 1; + let (out_handle, round_point, in_handle) = subpaths[last_subpath_index].round_line_join(&subpaths[0], self.manipulator_groups[0].anchor); + let last_index = subpaths[last_subpath_index].manipulator_groups.len() - 1; + subpaths[last_subpath_index].manipulator_groups[last_index].out_handle = Some(out_handle); + subpaths[last_subpath_index].manipulator_groups.push(round_point); + subpaths[0].manipulator_groups[0].in_handle = Some(in_handle); } - _ => unimplemented!(), } } } @@ -428,34 +465,68 @@ impl Subpath { Subpath::new(manipulator_groups, self.closed) } + /// Helper function to combine the two offsets that make up an outline. + pub(crate) fn combine_outline(&self, other: &Subpath, cap: Cap) -> Subpath { + let mut result_manipulator_groups: Vec> = vec![]; + result_manipulator_groups.extend_from_slice(self.manipulator_groups()); + match cap { + Cap::Butt => { + result_manipulator_groups.extend_from_slice(other.manipulator_groups()); + } + Cap::Round => { + let last_index = result_manipulator_groups.len() - 1; + let (out_handle, round_point, in_handle) = self.round_cap(other); + result_manipulator_groups[last_index].out_handle = Some(out_handle); + result_manipulator_groups.push(round_point); + result_manipulator_groups.extend_from_slice(&other.manipulator_groups); + result_manipulator_groups[last_index + 2].in_handle = Some(in_handle); + + let last_index = result_manipulator_groups.len() - 1; + let (out_handle, round_point, in_handle) = other.round_cap(self); + result_manipulator_groups[last_index].out_handle = Some(out_handle); + result_manipulator_groups.push(round_point); + result_manipulator_groups[0].in_handle = Some(in_handle); + } + Cap::Square => { + let square_points = self.square_cap(other); + result_manipulator_groups.extend_from_slice(&square_points); + result_manipulator_groups.extend_from_slice(other.manipulator_groups()); + let square_points = other.square_cap(self); + result_manipulator_groups.extend_from_slice(&square_points); + } + } + Subpath::new(result_manipulator_groups, true) + } + // TODO: Replace this return type with `Path`, once the `Path` data type has been created. /// Outline returns a single closed subpath (if the original subpath was open) or two closed subpaths (if the original subpath was closed) that forms /// an approximate outline around the subpath at a specified distance from the curve. Outline takes the following parameters: /// - `distance` - The outline's distance from the curve. - /// - `joint` - The joint type used to cap the endpoints of open bezier curves, and join successive subpath segments. - /// - pub fn outline(&self, distance: f64, joint: Joint) -> (Subpath, Option>) { - let mut pos_offset = self.offset(distance, joint); - let mut neg_offset = self.reverse().offset(distance, joint); + /// - `join` - The join type used to cap the endpoints of open bezier curves, and join successive subpath segments. + /// + pub fn outline(&self, distance: f64, join: Join, cap: Cap) -> (Subpath, Option>) { + let is_point = self.is_point(); + let (pos_offset, neg_offset) = if is_point { + let point = self.manipulator_groups[0].anchor; + ( + Subpath::new(vec![ManipulatorGroup::new_anchor(point + DVec2::NEG_Y * distance)], false), + Subpath::new(vec![ManipulatorGroup::new_anchor(point + DVec2::Y * distance)], false), + ) + } else { + (self.offset(distance, join), self.reverse().offset(distance, join)) + }; - if self.closed { + if self.closed && !is_point { return (pos_offset, Some(neg_offset)); } - match joint { - Joint::Bevel => { - pos_offset.manipulator_groups.append(&mut neg_offset.manipulator_groups); - pos_offset.closed = true; - (pos_offset, None) - } - _ => unimplemented!(), - } + (pos_offset.combine_outline(&neg_offset, cap), None) } } #[cfg(test)] mod tests { - use super::{ManipulatorGroup, Subpath}; + use super::{Cap, Join, ManipulatorGroup, Subpath}; use crate::compare::{compare_points, compare_subpaths, compare_vec_of_points}; use crate::consts::MAX_ABSOLUTE_DIFFERENCE; use crate::utils::{SubpathTValue, TValue}; @@ -509,6 +580,43 @@ mod tests { subpath } + #[test] + fn outline_with_single_point_segment() { + let subpath = Subpath::new( + vec![ + ManipulatorGroup { + anchor: DVec2::new(20., 20.), + out_handle: Some(DVec2::new(10., 90.)), + in_handle: None, + id: EmptyId, + }, + ManipulatorGroup { + anchor: DVec2::new(150., 40.), + out_handle: None, + in_handle: Some(DVec2::new(60., 40.)), + id: EmptyId, + }, + ManipulatorGroup { + anchor: DVec2::new(150., 40.), + out_handle: Some(DVec2::new(40., 120.)), + in_handle: None, + id: EmptyId, + }, + ManipulatorGroup { + anchor: DVec2::new(100., 100.), + out_handle: None, + in_handle: None, + id: EmptyId, + }, + ], + false, + ); + + let outline = subpath.outline(10., crate::Join::Round, crate::Cap::Round).0; + assert!(outline.manipulator_groups.windows(2).all(|pair| !pair[0].anchor.abs_diff_eq(pair[1].anchor, MAX_ABSOLUTE_DIFFERENCE))); + assert_eq!(outline.closed(), true); + } + #[test] fn split_an_open_subpath() { let subpath = set_up_open_subpath(); @@ -628,9 +736,15 @@ mod tests { let result = temporary.reverse(); let end = result.len(); - assert_eq!(temporary.manipulator_groups[0].anchor, result.manipulator_groups[end - 1].anchor); - assert_eq!(temporary.manipulator_groups[0].in_handle, result.manipulator_groups[end - 1].out_handle); - assert_eq!(temporary.manipulator_groups[0].out_handle, result.manipulator_groups[end - 1].in_handle); + // Second manipulator group on the temporary subpath should be the reflected version of the last in the result + assert_eq!(temporary.manipulator_groups[1].anchor, result.manipulator_groups[end - 1].anchor); + assert_eq!(temporary.manipulator_groups[1].in_handle, result.manipulator_groups[end - 1].out_handle); + assert_eq!(temporary.manipulator_groups[1].out_handle, result.manipulator_groups[end - 1].in_handle); + + // The first manipulator group in both should be the reflected versions of each other + assert_eq!(temporary.manipulator_groups[0].anchor, result.manipulator_groups[0].anchor); + assert_eq!(temporary.manipulator_groups[0].in_handle, result.manipulator_groups[0].out_handle); + assert_eq!(temporary.manipulator_groups[0].out_handle, result.manipulator_groups[0].in_handle); assert_eq!(subpath, result); } @@ -907,4 +1021,46 @@ mod tests { assert!(result.manipulator_groups[0].out_handle.is_none()); assert_eq!(result.manipulator_groups.len(), 1); } + + #[test] + fn outline_single_point_circle() { + let ellipse: Subpath = Subpath::new_ellipse(DVec2::new(0., 0.), DVec2::new(50., 50.)).reverse(); + let p = DVec2::new(25., 25.); + + let subpath: Subpath = Subpath::from_anchors([p, p, p], false); + let outline_open = subpath.outline(25., Join::Bevel, Cap::Round); + assert_eq!(outline_open.0, ellipse); + assert_eq!(outline_open.1, None); + + let subpath_closed: Subpath = Subpath::from_anchors([p, p, p], true); + let outline_closed = subpath_closed.outline(25., Join::Bevel, Cap::Round); + assert_eq!(outline_closed.0, ellipse); + assert_eq!(outline_closed.1, None); + } + + #[test] + fn outline_single_point_square() { + let square: Subpath = Subpath::from_anchors( + [ + DVec2::new(25., 0.), + DVec2::new(0., 0.), + DVec2::new(0., 50.), + DVec2::new(25., 50.), + DVec2::new(50., 50.), + DVec2::new(50., 0.), + ], + true, + ); + let p = DVec2::new(25., 25.); + + let subpath: Subpath = Subpath::from_anchors([p, p, p], false); + let outline_open = subpath.outline(25., Join::Bevel, Cap::Square); + assert_eq!(outline_open.0, square); + assert_eq!(outline_open.1, None); + + let subpath_closed: Subpath = Subpath::from_anchors([p, p, p], true); + let outline_closed = subpath_closed.outline(25., Join::Bevel, Cap::Square); + assert_eq!(outline_closed.0, square); + assert_eq!(outline_closed.1, None); + } } diff --git a/libraries/bezier-rs/src/utils.rs b/libraries/bezier-rs/src/utils.rs index 9a0975756..8567de741 100644 --- a/libraries/bezier-rs/src/utils.rs +++ b/libraries/bezier-rs/src/utils.rs @@ -1,4 +1,5 @@ use crate::consts::{MAX_ABSOLUTE_DIFFERENCE, MIN_SEPARATION_VALUE, STRICT_MAX_ABSOLUTE_DIFFERENCE}; +use crate::ManipulatorGroup; use glam::{BVec2, DMat2, DVec2}; use std::f64::consts::PI; @@ -29,12 +30,23 @@ pub enum SubpathTValue { } #[derive(Copy, Clone)] -pub enum Joint { - Miter, +/// Enum to represent the join type between subpaths. +/// As defined in SVG: https://www.w3.org/TR/SVG2/painting.html#LineJoin. +pub enum Join { Bevel, + Miter, Round, } +#[derive(Copy, Clone)] +/// Enum to represent the cap type at the ends of an outline +/// As defined in SVG: https://www.w3.org/TR/SVG2/painting.html#LineCaps. +pub enum Cap { + Butt, + Round, + Square, +} + /// Helper to perform the computation of a and c, where b is the provided point on the curve. /// Given the correct power of `t` and `(1-t)`, the computation is the same for quadratic and cubic cases. /// Relevant derivation and the definitions of a, b, and c can be found in [the projection identity section](https://pomax.github.io/bezierinfo/#abc) of Pomax's bezier curve primer. @@ -266,6 +278,31 @@ pub fn scale_point_from_origin(point: DVec2, origin: DVec2, should_flip_directio scale_point_from_direction_vector(point, (origin - point).normalize(), should_flip_direction, distance) } +/// Computes the necessary details to form a circular join from `left` to `right`, along a circle around `center`. +/// By default, the angle is assumed to be 180 degrees. +pub fn compute_circular_subpath_details( + left: DVec2, + arc_point: DVec2, + right: DVec2, + center: DVec2, + angle: Option, +) -> (DVec2, ManipulatorGroup, DVec2) { + let center_to_arc_point = arc_point - center; + + // Based on https://pomax.github.io/bezierinfo/#circles_cubic + let handle_offset_factor = if let Some(angle) = angle { 4. / 3. * (angle / 4.).tan() } else { 0.551784777779014 }; + + ( + left - (left - center).perp() * handle_offset_factor, + ManipulatorGroup::new( + arc_point, + Some(arc_point + center_to_arc_point.perp() * handle_offset_factor), + Some(arc_point - center_to_arc_point.perp() * handle_offset_factor), + ), + right + (right - center).perp() * handle_offset_factor, + ) +} + #[cfg(test)] mod tests { use super::*; diff --git a/website/other/bezier-rs-demos/src/components/SubpathDemoPane.ts b/website/other/bezier-rs-demos/src/components/SubpathDemoPane.ts index 505926550..7348ee511 100644 --- a/website/other/bezier-rs-demos/src/components/SubpathDemoPane.ts +++ b/website/other/bezier-rs-demos/src/components/SubpathDemoPane.ts @@ -1,6 +1,6 @@ import subpathFeatures, { SubpathFeatureKey } from "@graphite/features/subpath-features"; import { renderDemoPane } from "@graphite/utils/render"; -import { Demo, DemoPane, InputOption, SubpathDemoArgs } from "@graphite/utils/types"; +import { Demo, DemoPane, SubpathDemoArgs, SubpathInputOption } from "@graphite/utils/types"; class SubpathDemoPane extends HTMLElement implements DemoPane { // Props @@ -8,7 +8,7 @@ class SubpathDemoPane extends HTMLElement implements DemoPane { name!: string; - inputOptions!: InputOption[]; + inputOptions!: SubpathInputOption[]; triggerOnMouseMove!: boolean; @@ -62,7 +62,12 @@ class SubpathDemoPane extends HTMLElement implements DemoPane { subpathDemo.setAttribute("triples", JSON.stringify(demo.triples)); subpathDemo.setAttribute("closed", String(demo.closed)); subpathDemo.setAttribute("key", this.key); - subpathDemo.setAttribute("inputOptions", JSON.stringify(this.inputOptions)); + + const inputOptions = this.inputOptions.map((option) => ({ + ...option, + disabled: option.isDisabledForClosed && demo.closed, + })); + subpathDemo.setAttribute("inputOptions", JSON.stringify(inputOptions)); subpathDemo.setAttribute("triggerOnMouseMove", String(this.triggerOnMouseMove)); return subpathDemo; } diff --git a/website/other/bezier-rs-demos/src/features/bezier-features.ts b/website/other/bezier-rs-demos/src/features/bezier-features.ts index 2a7983514..cac89df63 100644 --- a/website/other/bezier-rs-demos/src/features/bezier-features.ts +++ b/website/other/bezier-rs-demos/src/features/bezier-features.ts @@ -1,5 +1,5 @@ import { WasmBezier } from "@graphite/../wasm/pkg"; -import { tSliderOptions, bezierTValueVariantOptions, errorOptions, minimumSeparationOptions } from "@graphite/utils/options"; +import { capOptions, tSliderOptions, bezierTValueVariantOptions, errorOptions, minimumSeparationOptions } from "@graphite/utils/options"; import { BezierDemoOptions, WasmBezierInstance, BezierCallback, InputOption, BEZIER_T_VALUE_VARIANTS } from "@graphite/utils/types"; const bezierFeatures = { @@ -251,7 +251,7 @@ const bezierFeatures = { }, outline: { name: "Outline", - callback: (bezier: WasmBezierInstance, options: Record): string => bezier.outline(options.distance), + callback: (bezier: WasmBezierInstance, options: Record): string => bezier.outline(options.distance, options.cap), demoOptions: { Quadratic: { inputOptions: [ @@ -262,13 +262,14 @@ const bezierFeatures = { step: 1, default: 15, }, + capOptions, ], }, }, }, "graduated-outline": { name: "Graduated Outline", - callback: (bezier: WasmBezierInstance, options: Record): string => bezier.graduated_outline(options.start_distance, options.end_distance), + callback: (bezier: WasmBezierInstance, options: Record): string => bezier.graduated_outline(options.start_distance, options.end_distance, options.cap), demoOptions: { Quadratic: { inputOptions: [ @@ -286,6 +287,7 @@ const bezierFeatures = { step: 1, default: 15, }, + capOptions, ], }, }, @@ -300,7 +302,8 @@ const bezierFeatures = { }, "skewed-outline": { name: "Skewed Outline", - callback: (bezier: WasmBezierInstance, options: Record): string => bezier.skewed_outline(options.distance1, options.distance2, options.distance3, options.distance4), + callback: (bezier: WasmBezierInstance, options: Record): string => + bezier.skewed_outline(options.distance1, options.distance2, options.distance3, options.distance4, options.cap), demoOptions: { Quadratic: { inputOptions: [ @@ -332,6 +335,7 @@ const bezierFeatures = { step: 1, default: 5, }, + capOptions, ], }, }, diff --git a/website/other/bezier-rs-demos/src/features/subpath-features.ts b/website/other/bezier-rs-demos/src/features/subpath-features.ts index 570cda07c..9f9e7a399 100644 --- a/website/other/bezier-rs-demos/src/features/subpath-features.ts +++ b/website/other/bezier-rs-demos/src/features/subpath-features.ts @@ -1,5 +1,5 @@ -import { tSliderOptions, subpathTValueVariantOptions, intersectionErrorOptions, minimumSeparationOptions } from "@graphite/utils/options"; -import { InputOption, SubpathCallback, WasmSubpathInstance, SUBPATH_T_VALUE_VARIANTS } from "@graphite/utils/types"; +import { capOptions, joinOptions, tSliderOptions, subpathTValueVariantOptions, intersectionErrorOptions, minimumSeparationOptions } from "@graphite/utils/options"; +import { SubpathCallback, SubpathInputOption, WasmSubpathInstance, SUBPATH_T_VALUE_VARIANTS } from "@graphite/utils/types"; const subpathFeatures = { constructor: { @@ -107,7 +107,7 @@ const subpathFeatures = { }, offset: { name: "Offset", - callback: (subpath: WasmSubpathInstance, options: Record): string => subpath.offset(options.distance), + callback: (subpath: WasmSubpathInstance, options: Record): string => subpath.offset(options.distance, options.join), inputOptions: [ { variable: "distance", @@ -116,11 +116,12 @@ const subpathFeatures = { step: 1, default: 10, }, + joinOptions, ], }, outline: { name: "Outline", - callback: (subpath: WasmSubpathInstance, options: Record): string => subpath.outline(options.distance), + callback: (subpath: WasmSubpathInstance, options: Record): string => subpath.outline(options.distance, options.join, options.cap), inputOptions: [ { variable: "distance", @@ -129,6 +130,8 @@ const subpathFeatures = { step: 1, default: 10, }, + joinOptions, + { ...capOptions, isDisabledForClosed: true }, ], }, }; @@ -137,7 +140,7 @@ export type SubpathFeatureKey = keyof typeof subpathFeatures; export type SubpathFeatureOptions = { name: string; callback: SubpathCallback; - inputOptions?: InputOption[]; + inputOptions?: SubpathInputOption[]; triggerOnMouseMove?: boolean; }; export default subpathFeatures as Record; diff --git a/website/other/bezier-rs-demos/src/utils/options.ts b/website/other/bezier-rs-demos/src/utils/options.ts index 2503d7402..83a6e8dff 100644 --- a/website/other/bezier-rs-demos/src/utils/options.ts +++ b/website/other/bezier-rs-demos/src/utils/options.ts @@ -45,3 +45,17 @@ export const subpathTValueVariantOptions = { inputType: "dropdown", options: SUBPATH_T_VALUE_VARIANTS, }; + +export const joinOptions = { + variable: "join", + default: 0, + inputType: "dropdown", + options: ["Bevel", "Miter", "Round"], +}; + +export const capOptions = { + variable: "cap", + default: 0, + inputType: "dropdown", + options: ["Butt", "Round", "Square"], +}; diff --git a/website/other/bezier-rs-demos/src/utils/render.ts b/website/other/bezier-rs-demos/src/utils/render.ts index 17fdc6a98..a4f486619 100644 --- a/website/other/bezier-rs-demos/src/utils/render.ts +++ b/website/other/bezier-rs-demos/src/utils/render.ts @@ -43,6 +43,10 @@ export function renderDemo(demo: Demo): void { selectInput.append(option); }); + if (inputOption.disabled) { + selectInput.disabled = true; + } + selectInput.addEventListener("change", (event: Event): void => { demo.sliderData[inputOption.variable] = Number((event.target as HTMLInputElement).value); demo.drawDemo(figure); diff --git a/website/other/bezier-rs-demos/src/utils/types.ts b/website/other/bezier-rs-demos/src/utils/types.ts index ed795753d..76253e27c 100644 --- a/website/other/bezier-rs-demos/src/utils/types.ts +++ b/website/other/bezier-rs-demos/src/utils/types.ts @@ -22,6 +22,10 @@ export type BezierDemoOptions = { }; }; +export type SubpathInputOption = InputOption & { + isDisabledForClosed?: boolean; +}; + export type InputOption = { variable: string; min?: number; @@ -31,6 +35,7 @@ export type InputOption = { unit?: string | string[]; inputType?: "slider" | "dropdown"; options?: string[]; + disabled?: boolean; }; export function getCurveType(numPoints: number): BezierCurveType { diff --git a/website/other/bezier-rs-demos/wasm/src/bezier.rs b/website/other/bezier-rs-demos/wasm/src/bezier.rs index 6eb945331..e57a5cc61 100644 --- a/website/other/bezier-rs-demos/wasm/src/bezier.rs +++ b/website/other/bezier-rs-demos/wasm/src/bezier.rs @@ -1,4 +1,6 @@ use crate::svg_drawing::*; +use crate::utils::parse_cap; + use bezier_rs::{ArcStrategy, ArcsOptions, Bezier, Identifier, ProjectionOptions, TValue}; use glam::DVec2; use serde::{Deserialize, Serialize}; @@ -219,22 +221,25 @@ impl WasmBezier { } pub fn curvature(&self, raw_t: f64, t_variant: String) -> String { - let bezier = self.get_bezier_path(); + let mut content = self.get_bezier_path(); let t = parse_t_variant(&t_variant, raw_t); - let radius = 1. / self.0.curvature(t); - let normal_point = self.0.normal(t); - let intersection_point = self.0.evaluate(t); + let curvature = self.0.curvature(t); + if curvature > 0. { + let radius = 1. / self.0.curvature(t); + let normal_point = self.0.normal(t); + let intersection_point = self.0.evaluate(t); - let curvature_center = intersection_point + normal_point * radius; + let curvature_center = intersection_point + normal_point * radius; - let content = format!( - "{bezier}{}{}{}{}", - draw_circle(curvature_center, radius.abs(), RED, 1., NONE), - draw_line(intersection_point.x, intersection_point.y, curvature_center.x, curvature_center.y, RED, 1.), - draw_circle(intersection_point, 3., RED, 1., WHITE), - draw_circle(curvature_center, 3., RED, 1., WHITE), - ); + content = format!( + "{content}{}{}{}{}", + draw_circle(curvature_center, radius.abs(), RED, 1., NONE), + draw_line(intersection_point.x, intersection_point.y, curvature_center.x, curvature_center.y, RED, 1.), + draw_circle(intersection_point, 3., RED, 1., WHITE), + draw_circle(curvature_center, 3., RED, 1., WHITE), + ); + } wrap_svg_tag(content) } @@ -242,19 +247,10 @@ impl WasmBezier { let t = parse_t_variant(&t_variant, raw_t); let beziers: [Bezier; 2] = self.0.split(t); - let mut original_bezier_svg = String::new(); - self.0.to_svg( - &mut original_bezier_svg, - CURVE_ATTRIBUTES.to_string().replace(BLACK, WHITE), - ANCHOR_ATTRIBUTES.to_string().replace(BLACK, WHITE), - HANDLE_ATTRIBUTES.to_string(), - HANDLE_LINE_ATTRIBUTES.to_string(), - ); - let mut bezier_svg_1 = String::new(); beziers[0].to_svg( &mut bezier_svg_1, - CURVE_ATTRIBUTES.to_string().replace(BLACK, ORANGE), + CURVE_ATTRIBUTES.to_string().replace(BLACK, ORANGE).replace("stroke-width=\"2\"", "stroke-width=\"8\"") + " opacity=\"0.5\"", ANCHOR_ATTRIBUTES.to_string().replace(BLACK, ORANGE), HANDLE_ATTRIBUTES.to_string().replace(GRAY, ORANGE), HANDLE_LINE_ATTRIBUTES.to_string().replace(GRAY, ORANGE), @@ -263,13 +259,13 @@ impl WasmBezier { let mut bezier_svg_2 = String::new(); beziers[1].to_svg( &mut bezier_svg_2, - CURVE_ATTRIBUTES.to_string().replace(BLACK, RED), + CURVE_ATTRIBUTES.to_string().replace(BLACK, RED).replace("stroke-width=\"2\"", "stroke-width=\"8\"") + " opacity=\"0.5\"", ANCHOR_ATTRIBUTES.to_string().replace(BLACK, RED), HANDLE_ATTRIBUTES.to_string().replace(GRAY, RED), HANDLE_LINE_ATTRIBUTES.to_string().replace(GRAY, RED), ); - wrap_svg_tag(format!("{original_bezier_svg}{bezier_svg_1}{bezier_svg_2}")) + wrap_svg_tag(format!("{}{bezier_svg_1}{bezier_svg_2}", self.get_bezier_path())) } pub fn trim(&self, raw_t1: f64, raw_t2: f64, t_variant: String) -> String { @@ -279,7 +275,7 @@ impl WasmBezier { let mut trimmed_bezier_svg = String::new(); trimmed_bezier.to_svg( &mut trimmed_bezier_svg, - CURVE_ATTRIBUTES.to_string().replace(BLACK, RED), + CURVE_ATTRIBUTES.to_string().replace(BLACK, RED).replace("stroke-width=\"2\"", "stroke-width=\"8\"") + " opacity=\"0.5\"", ANCHOR_ATTRIBUTES.to_string().replace(BLACK, RED), HANDLE_ATTRIBUTES.to_string().replace(GRAY, RED), HANDLE_LINE_ATTRIBUTES.to_string().replace(GRAY, RED), @@ -570,8 +566,9 @@ impl WasmBezier { wrap_svg_tag(bezier_curves_svg) } - pub fn outline(&self, distance: f64) -> String { - let outline_subpath = self.0.outline::(distance); + pub fn outline(&self, distance: f64, cap: i32) -> String { + let cap = parse_cap(cap); + let outline_subpath = self.0.outline::(distance, cap); if outline_subpath.is_empty() { return String::new(); } @@ -583,8 +580,9 @@ impl WasmBezier { wrap_svg_tag(format!("{bezier_svg}{outline_svg}")) } - pub fn graduated_outline(&self, start_distance: f64, end_distance: f64) -> String { - let outline_subpath = self.0.graduated_outline::(start_distance, end_distance); + pub fn graduated_outline(&self, start_distance: f64, end_distance: f64, cap: i32) -> String { + let cap = parse_cap(cap); + let outline_subpath = self.0.graduated_outline::(start_distance, end_distance, cap); if outline_subpath.is_empty() { return String::new(); } @@ -596,8 +594,9 @@ impl WasmBezier { wrap_svg_tag(format!("{bezier_svg}{outline_svg}")) } - pub fn skewed_outline(&self, distance1: f64, distance2: f64, distance3: f64, distance4: f64) -> String { - let outline_subpath = self.0.skewed_outline::(distance1, distance2, distance3, distance4); + pub fn skewed_outline(&self, distance1: f64, distance2: f64, distance3: f64, distance4: f64, cap: i32) -> String { + let cap = parse_cap(cap); + let outline_subpath = self.0.skewed_outline::(distance1, distance2, distance3, distance4, cap); if outline_subpath.is_empty() { return String::new(); } diff --git a/website/other/bezier-rs-demos/wasm/src/lib.rs b/website/other/bezier-rs-demos/wasm/src/lib.rs index 0ac7850c9..7bb48e208 100644 --- a/website/other/bezier-rs-demos/wasm/src/lib.rs +++ b/website/other/bezier-rs-demos/wasm/src/lib.rs @@ -1,3 +1,4 @@ pub mod bezier; pub mod subpath; mod svg_drawing; +mod utils; diff --git a/website/other/bezier-rs-demos/wasm/src/subpath.rs b/website/other/bezier-rs-demos/wasm/src/subpath.rs index 4c176914a..d8d713a3e 100644 --- a/website/other/bezier-rs-demos/wasm/src/subpath.rs +++ b/website/other/bezier-rs-demos/wasm/src/subpath.rs @@ -1,4 +1,5 @@ use crate::svg_drawing::*; +use crate::utils::{parse_cap, parse_join}; use bezier_rs::{Bezier, ManipulatorGroup, ProjectionOptions, Subpath, SubpathTValue}; @@ -377,8 +378,9 @@ impl WasmSubpath { wrap_svg_tag(format!("{}{}", self.to_default_svg(), trimmed_subpath_svg)) } - pub fn offset(&self, distance: f64) -> String { - let offset_subpath = self.0.offset(distance, bezier_rs::Joint::Bevel); + pub fn offset(&self, distance: f64, join: i32) -> String { + let join = parse_join(join); + let offset_subpath = self.0.offset(distance, join); let mut offset_svg = String::new(); offset_subpath.to_svg(&mut offset_svg, CURVE_ATTRIBUTES.to_string().replace(BLACK, RED), String::new(), String::new(), String::new()); @@ -386,8 +388,10 @@ impl WasmSubpath { wrap_svg_tag(format!("{}{offset_svg}", self.to_default_svg())) } - pub fn outline(&self, distance: f64) -> String { - let (outline_piece1, outline_piece2) = self.0.outline(distance, bezier_rs::Joint::Bevel); + pub fn outline(&self, distance: f64, join: i32, cap: i32) -> String { + let join = parse_join(join); + let cap = parse_cap(cap); + let (outline_piece1, outline_piece2) = self.0.outline(distance, join, cap); let mut outline_piece1_svg = String::new(); outline_piece1.to_svg(&mut outline_piece1_svg, CURVE_ATTRIBUTES.to_string().replace(BLACK, RED), String::new(), String::new(), String::new()); diff --git a/website/other/bezier-rs-demos/wasm/src/utils.rs b/website/other/bezier-rs-demos/wasm/src/utils.rs new file mode 100644 index 000000000..58dc13290 --- /dev/null +++ b/website/other/bezier-rs-demos/wasm/src/utils.rs @@ -0,0 +1,19 @@ +use bezier_rs::{Cap, Join}; + +pub fn parse_join(join: i32) -> Join { + match join { + 0 => Join::Bevel, + 1 => Join::Miter, + 2 => Join::Round, + _ => panic!("Unexpected Join value: '{}'", join), + } +} + +pub fn parse_cap(cap: i32) -> Cap { + match cap { + 0 => Cap::Butt, + 1 => Cap::Round, + 2 => Cap::Square, + _ => panic!("Unexpected Cap value: '{}'", cap), + } +}