From 56060b8e5ffab3b31f16e2e910dcf118d9c10404 Mon Sep 17 00:00:00 2001 From: Rob Nadal Date: Sun, 6 Nov 2022 21:25:00 -0800 Subject: [PATCH] Bezier-rs: Added curve outline functions (#789) * Implement offset and reverse Co-authored-by: Rob Nadal * Initial work on graduated outline Co-authored-by: Rob Nadal * Handle linear case for graduated scale * Added skewed outline, fixed graduated scale hourglass bug * Removed test code * Update comments * Fix linting issue * Improve comments * Comment fixes Co-authored-by: Hannah Li Co-authored-by: Keavon Chambers --- libraries/bezier-rs/src/bezier/core.rs | 2 +- libraries/bezier-rs/src/bezier/transform.rs | 181 +++++++++++++++++- libraries/bezier-rs/src/utils.rs | 11 ++ website/other/bezier-rs-demos/src/App.vue | 80 ++++++++ .../other/bezier-rs-demos/wasm/src/bezier.rs | 36 ++++ .../bezier-rs-demos/wasm/src/svg_drawing.rs | 16 ++ 6 files changed, 321 insertions(+), 5 deletions(-) diff --git a/libraries/bezier-rs/src/bezier/core.rs b/libraries/bezier-rs/src/bezier/core.rs index f528adae..3be7c2c4 100644 --- a/libraries/bezier-rs/src/bezier/core.rs +++ b/libraries/bezier-rs/src/bezier/core.rs @@ -108,7 +108,7 @@ impl Bezier { } /// Return the string argument used to create a curve in an SVG `path`, excluding the start point. - pub(crate) fn svg_curve_argument(&self) -> String { + pub fn svg_curve_argument(&self) -> String { let handle_args = match self.handles { BezierHandles::Linear => SVG_ARG_LINEAR.to_string(), BezierHandles::Quadratic { handle } => { diff --git a/libraries/bezier-rs/src/bezier/transform.rs b/libraries/bezier-rs/src/bezier/transform.rs index b121b433..72fabe55 100644 --- a/libraries/bezier-rs/src/bezier/transform.rs +++ b/libraries/bezier-rs/src/bezier/transform.rs @@ -40,8 +40,18 @@ impl Bezier { } } + /// Returns a reversed version of the Bezier curve. + pub fn reverse(&self) -> Bezier { + match self.handles { + BezierHandles::Linear => Bezier::from_linear_dvec2(self.end, self.start), + BezierHandles::Quadratic { handle } => Bezier::from_quadratic_dvec2(self.end, handle, self.start), + BezierHandles::Cubic { handle_start, handle_end } => Bezier::from_cubic_dvec2(self.end, handle_end, handle_start, self.start), + } + } + /// Returns the Bezier curve representing the sub-curve starting at the point corresponding to `t1` and ending at the point corresponding to `t2`. pub fn trim(&self, t1: f64, t2: f64) -> Bezier { + // If t1 is equal to t2, return a bezier comprised entirely of the same point if f64_compare(t1, t2, MAX_ABSOLUTE_DIFFERENCE) { let point = self.evaluate(t1); return match self.handles { @@ -63,7 +73,11 @@ impl Bezier { // Case where we took the split from the beginning to `t1` t2 / t1 }; - bezier_starting_at_t1.split(adjusted_t2)[t2_split_side] + let result = bezier_starting_at_t1.split(adjusted_t2)[t2_split_side]; + if t2 < t1 { + return result.reverse(); + } + result } /// Returns a Bezier curve that results from applying the transformation function to each point in the Bezier. @@ -243,11 +257,71 @@ impl Bezier { }) } + /// Version of the `scale` function which scales the curve such that the start of the scaled curve is `start_distance` from the original curve, while the end of + /// of the scaled curve is `end_distance` from the original curve. The curve transitions from `start_distance` to `end_distance` gradually, proportional to the + /// distance along the equation (`t`-value) of the curve. + pub fn graduated_scale(&self, start_distance: f64, end_distance: f64) -> Bezier { + assert!(self.is_scalable(), "The curve provided to scale is not scalable. Reduce the curve first."); + + let normal_start = self.normal(0.); + let normal_end = self.normal(1.); + + // If normal unit vectors are equal, then the lines are parallel + if normal_start.abs_diff_eq(normal_end, MAX_ABSOLUTE_DIFFERENCE) { + let transformed_start = utils::scale_point_from_direction_vector(self.start, self.normal(0.), false, start_distance); + let transformed_end = utils::scale_point_from_direction_vector(self.end, self.normal(1.), false, end_distance); + + return match self.handles { + BezierHandles::Linear => Bezier::from_linear_dvec2(transformed_start, transformed_end), + BezierHandles::Quadratic { handle } => { + let handle_closest_t = self.project(handle, ProjectionOptions::default()); + let handle_scale_distance = (1. - handle_closest_t) * start_distance + handle_closest_t * end_distance; + let transformed_handle = utils::scale_point_from_direction_vector(handle, self.normal(handle_closest_t), false, handle_scale_distance); + Bezier::from_quadratic_dvec2(transformed_start, transformed_handle, transformed_end) + } + BezierHandles::Cubic { handle_start, handle_end } => { + let handle_start_closest_t = self.project(handle_start, ProjectionOptions::default()); + let handle_start_scale_distance = (1. - handle_start_closest_t) * start_distance + handle_start_closest_t * end_distance; + let transformed_handle_start = utils::scale_point_from_direction_vector(handle_start, self.normal(handle_start_closest_t), false, handle_start_scale_distance); + + let handle_end_closest_t = self.project(handle_start, ProjectionOptions::default()); + let handle_end_scale_distance = (1. - handle_end_closest_t) * start_distance + handle_end_closest_t * end_distance; + let transformed_handle_end = utils::scale_point_from_direction_vector(handle_end, self.normal(handle_end_closest_t), false, handle_end_scale_distance); + Bezier::from_cubic_dvec2(transformed_start, transformed_handle_start, transformed_handle_end, transformed_end) + } + }; + } + + // Find the intersection point of the endpoint normals + let intersection = utils::line_intersection(self.start, normal_start, self.end, normal_end); + let should_flip_direction = (self.start - intersection).normalize().abs_diff_eq(normal_start, MAX_ABSOLUTE_DIFFERENCE); + + let transformed_start = utils::scale_point_from_origin(self.start, intersection, should_flip_direction, start_distance); + let transformed_end = utils::scale_point_from_origin(self.end, intersection, should_flip_direction, end_distance); + + match self.handles { + BezierHandles::Linear => Bezier::from_linear_dvec2(transformed_start, transformed_end), + BezierHandles::Quadratic { handle } => { + let handle_scale_distance = (start_distance + end_distance) / 2.; + let transformed_handle = utils::scale_point_from_origin(handle, intersection, should_flip_direction, handle_scale_distance); + Bezier::from_quadratic_dvec2(transformed_start, transformed_handle, transformed_end) + } + BezierHandles::Cubic { handle_start, handle_end } => { + let handle_start_scale_distance = (start_distance * 2. + end_distance) / 3.; + let transformed_handle_start = utils::scale_point_from_origin(handle_start, intersection, should_flip_direction, handle_start_scale_distance); + + let handle_end_scale_distance = (start_distance + end_distance * 2.) / 3.; + let transformed_handle_end = utils::scale_point_from_origin(handle_end, intersection, should_flip_direction, handle_end_scale_distance); + Bezier::from_cubic_dvec2(transformed_start, transformed_handle_start, transformed_handle_end, transformed_end) + } + } + } + /// Offset will get all the reduceable subcurves, and for each subcurve, it will scale the subcurve a set distance away from the original curve. /// Note that not all bezier curves are possible to offset, so this function first reduces the curve to scalable segments and then offsets those segments. /// A proof for why this is true can be found in the [Curve offsetting section](https://pomax.github.io/bezierinfo/#offsetting) of Pomax's bezier curve primer. /// Offset takes the following parameter: - /// - `distance` - The distance away from the curve that the new one will be offset to. Positive values will offset the curve in the same direction as the endpoint normals, + /// - `distance` - The offset's distance from the curve. Positive values will offset the curve in the same direction as the endpoint normals, /// while negative values will offset in the opposite direction. pub fn offset(&self, distance: f64) -> Vec { let mut reduced = self.reduce(None); @@ -255,6 +329,64 @@ impl Bezier { reduced } + /// Version of the `offset` function which scales the offset such that the start of the offset is `start_distance` from the original curve, while the end of + /// of the offset is `end_distance` from the original curve. The curve transitions from `start_distance` to `end_distance` gradually, proportional to the + /// distance along the equation (`t`-value) of the curve. Similarily to the `offset` function, the returned result is an approximation. + pub fn graduated_offset(&self, start_distance: f64, end_distance: f64) -> Vec { + let reduced = self.reduce(None); + let mut next_start_distance = start_distance; + let distance_difference = end_distance - start_distance; + let total_length = self.length(None); + + let mut result = vec![]; + reduced.iter().for_each(|bezier| { + let current_length = bezier.length(None); + let next_end_distance = next_start_distance + (current_length / total_length) * distance_difference; + result.push(bezier.graduated_scale(next_start_distance, next_end_distance)); + next_start_distance = next_end_distance; + }); + result + } + + /// Outline will return a vector of Beziers that creates an outline around the curve at the designated distance away from the curve. + /// It makes use of the `offset` function, thus restrictions applicable to `offset` are relevant to this function as well. + /// 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) -> Vec { + let first_segment = self.offset(distance); + let third_segment = self.reverse().offset(distance); + + if first_segment.is_empty() || third_segment.is_empty() { + return vec![]; + } + + let second_segment = Bezier::from_linear_dvec2(first_segment.last().unwrap().end, third_segment.first().unwrap().start); + let fourth_segment = Bezier::from_linear_dvec2(third_segment.last().unwrap().end, first_segment.first().unwrap().start); + [first_segment, vec![second_segment], third_segment, vec![fourth_segment]].concat() + } + + /// 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) -> Vec { + self.skewed_outline(start_distance, end_distance, end_distance, start_distance) + } + + /// 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) -> Vec { + let first_segment = self.graduated_offset(distance1, distance2); + let third_segment = self.reverse().graduated_offset(distance3, distance4); + + if first_segment.is_empty() || third_segment.is_empty() { + return vec![]; + } + + let second_segment = Bezier::from_linear_dvec2(first_segment.last().unwrap().end, third_segment.first().unwrap().start); + let fourth_segment = Bezier::from_linear_dvec2(third_segment.last().unwrap().end, first_segment.first().unwrap().start); + [first_segment, vec![second_segment], third_segment, vec![fourth_segment]].concat() + } + /// Approximate a bezier curve with circular arcs. /// The algorithm can be customized using the [ArcsOptions] structure. pub fn arcs(&self, arcs_options: ArcsOptions) -> Vec { @@ -503,13 +635,13 @@ mod tests { // Test trimming quadratic curve when t2 > t1 let bezier_quadratic = Bezier::from_quadratic_coordinates(30., 50., 140., 30., 160., 170.); let trim1 = bezier_quadratic.trim(0.25, 0.75); - let trim2 = bezier_quadratic.trim(0.75, 0.25); + let trim2 = bezier_quadratic.trim(0.75, 0.25).reverse(); assert!(trim1.abs_diff_eq(&trim2, MAX_ABSOLUTE_DIFFERENCE)); // Test trimming cubic curve when t2 > t1 let bezier_cubic = Bezier::from_cubic_coordinates(30., 30., 60., 140., 150., 30., 160., 160.); let trim3 = bezier_cubic.trim(0.25, 0.75); - let trim4 = bezier_cubic.trim(0.75, 0.25); + let trim4 = bezier_cubic.trim(0.75, 0.25).reverse(); assert!(trim3.abs_diff_eq(&trim4, MAX_ABSOLUTE_DIFFERENCE)); } @@ -599,6 +731,47 @@ mod tests { assert!(compare_vector_of_beziers(&bezier2.offset(30.), expected_bezier_points2)); } + #[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.); + + assert_eq!(outline.len(), 4); + + // Assert the first length-wise piece of the outline is 10 units from the line + assert!(f64_compare(outline[0].evaluate(0.25).distance(line.evaluate(0.25)), 10., MAX_ABSOLUTE_DIFFERENCE)); // f64 + + // Assert the first cap touches the line end point at the halfway point + assert!(outline[1].evaluate(0.5).abs_diff_eq(line.end(), MAX_ABSOLUTE_DIFFERENCE)); + + // Assert the second length-wise piece of the outline is 10 units from the line + assert!(f64_compare(outline[2].evaluate(0.25).distance(line.evaluate(0.75)), 10., MAX_ABSOLUTE_DIFFERENCE)); // f64 + + // Assert the second cap touches the line start point at the halfway point + assert!(outline[3].evaluate(0.5).abs_diff_eq(line.start(), MAX_ABSOLUTE_DIFFERENCE)); + } + + #[test] + fn test_graduated_scale() { + let bezier = Bezier::from_linear_coordinates(30., 60., 140., 120.); + bezier.graduated_scale(10., 20.); + } + + #[test] + fn test_graduated_scale_quadratic() { + let bezier = Bezier::from_quadratic_coordinates(30., 50., 82., 98., 160., 170.); + let scaled_bezier = bezier.graduated_scale(30., 30.); + + dbg!(scaled_bezier); + + // Assert the scaled bezier is 30 units from the line + assert!(f64_compare(scaled_bezier.evaluate(0.).distance(bezier.evaluate(0.)), 30., MAX_ABSOLUTE_DIFFERENCE)); + assert!(f64_compare(scaled_bezier.evaluate(1.).distance(bezier.evaluate(1.)), 30., MAX_ABSOLUTE_DIFFERENCE)); + assert!(f64_compare(scaled_bezier.evaluate(0.5).distance(bezier.evaluate(0.5)), 30., MAX_ABSOLUTE_DIFFERENCE)); + } + #[test] fn test_arcs_linear() { let bezier = Bezier::from_linear_coordinates(30., 60., 140., 120.); diff --git a/libraries/bezier-rs/src/utils.rs b/libraries/bezier-rs/src/utils.rs index 9b975c4d..694d5588 100644 --- a/libraries/bezier-rs/src/utils.rs +++ b/libraries/bezier-rs/src/utils.rs @@ -226,6 +226,17 @@ pub fn dvec2_approximately_in_range(point: DVec2, min: DVec2, max: DVec2, max_ab (point.cmpge(min) & point.cmple(max)) | dvec2_compare(point, min, max_abs_diff) | dvec2_compare(point, max, max_abs_diff) } +/// Calculate a new position for a point given its original position, a unit vector in the desired direction, and a distance to move it by. +pub fn scale_point_from_direction_vector(point: DVec2, direction_unit_vector: DVec2, should_flip_direction: bool, distance: f64) -> DVec2 { + let should_reverse_factor = if should_flip_direction { -1. } else { 1. }; + point + distance * direction_unit_vector * should_reverse_factor +} + +/// Scale a point by a given distance with respect to the provided origin. +pub fn scale_point_from_origin(point: DVec2, origin: DVec2, should_flip_direction: bool, distance: f64) -> DVec2 { + scale_point_from_direction_vector(point, (origin - point).normalize(), should_flip_direction, distance) +} + #[cfg(test)] mod tests { use super::*; diff --git a/website/other/bezier-rs-demos/src/App.vue b/website/other/bezier-rs-demos/src/App.vue index 655b5cc2..d7baa7c2 100644 --- a/website/other/bezier-rs-demos/src/App.vue +++ b/website/other/bezier-rs-demos/src/App.vue @@ -295,6 +295,86 @@ export default defineComponent({ }, }, }, + { + name: "Outline", + callback: (bezier: WasmBezierInstance, options: Record): string => bezier.outline(options.distance), + exampleOptions: { + [BezierCurveType.Quadratic]: { + sliderOptions: [ + { + variable: "distance", + min: 0, + max: 50, + step: 1, + default: 20, + }, + ], + }, + }, + }, + { + name: "Graduated Outline", + callback: (bezier: WasmBezierInstance, options: Record): string => bezier.graduated_outline(options.start_distance, options.end_distance), + exampleOptions: { + [BezierCurveType.Quadratic]: { + sliderOptions: [ + { + variable: "start_distance", + min: 0, + max: 50, + step: 1, + default: 30, + }, + { + variable: "end_distance", + min: 0, + max: 50, + step: 1, + default: 30, + }, + ], + }, + }, + }, + { + name: "Skewed Outline", + callback: (bezier: WasmBezierInstance, options: Record): string => + bezier.skewed_outline(options.distance1, options.distance2, options.distance3, options.distance4), + exampleOptions: { + [BezierCurveType.Quadratic]: { + sliderOptions: [ + { + variable: "distance1", + min: 0, + max: 50, + step: 1, + default: 20, + }, + { + variable: "distance2", + min: 0, + max: 50, + step: 1, + default: 10, + }, + { + variable: "distance3", + min: 0, + max: 50, + step: 1, + default: 30, + }, + { + variable: "distance4", + min: 0, + max: 50, + step: 1, + default: 5, + }, + ], + }, + }, + }, { name: "Arcs", callback: (bezier: WasmBezierInstance, options: Record): string => bezier.arcs(options.error, options.max_iterations, options.strategy), diff --git a/website/other/bezier-rs-demos/wasm/src/bezier.rs b/website/other/bezier-rs-demos/wasm/src/bezier.rs index 387dfd6a..e0c76c79 100644 --- a/website/other/bezier-rs-demos/wasm/src/bezier.rs +++ b/website/other/bezier-rs-demos/wasm/src/bezier.rs @@ -571,6 +571,42 @@ impl WasmBezier { wrap_svg_tag(bezier_curves_svg) } + pub fn outline(&self, distance: f64) -> String { + let outline_beziers = self.0.outline(distance); + if outline_beziers.is_empty() { + return String::new(); + } + + let outline_svg = draw_beziers(outline_beziers, CURVE_ATTRIBUTES.to_string().replace(BLACK, RED)); + let bezier_svg = self.get_bezier_path(); + + wrap_svg_tag(format!("{bezier_svg}{outline_svg}")) + } + + pub fn graduated_outline(&self, start_distance: f64, end_distance: f64) -> String { + let outline_beziers = self.0.graduated_outline(start_distance, end_distance); + if outline_beziers.is_empty() { + return String::new(); + } + + let outline_svg = draw_beziers(outline_beziers, CURVE_ATTRIBUTES.to_string().replace(BLACK, RED)); + let bezier_svg = self.get_bezier_path(); + + wrap_svg_tag(format!("{bezier_svg}{outline_svg}")) + } + + pub fn skewed_outline(&self, distance1: f64, distance2: f64, distance3: f64, distance4: f64) -> String { + let outline_beziers = self.0.skewed_outline(distance1, distance2, distance3, distance4); + if outline_beziers.is_empty() { + return String::new(); + } + + let outline_svg = draw_beziers(outline_beziers, CURVE_ATTRIBUTES.to_string().replace(BLACK, RED)); + let bezier_svg = self.get_bezier_path(); + + wrap_svg_tag(format!("{bezier_svg}{outline_svg}")) + } + /// The wrapped return type is `Vec`. pub fn arcs(&self, error: f64, max_iterations: usize, maximize_arcs: WasmMaximizeArcs) -> String { let original_curve_svg = self.get_bezier_path(); diff --git a/website/other/bezier-rs-demos/wasm/src/svg_drawing.rs b/website/other/bezier-rs-demos/wasm/src/svg_drawing.rs index cb758b61..bf7a5992 100644 --- a/website/other/bezier-rs-demos/wasm/src/svg_drawing.rs +++ b/website/other/bezier-rs-demos/wasm/src/svg_drawing.rs @@ -1,3 +1,6 @@ +use bezier_rs::Bezier; +use std::fmt::Write; + // SVG drawing constants pub const SVG_OPEN_TAG: &str = r#""#; pub const SVG_CLOSE_TAG: &str = ""; @@ -37,6 +40,19 @@ pub fn draw_line(start_x: f64, start_y: f64, end_x: f64, end_y: f64, stroke: &st format!(r#""#) } +/// Helper function to draw a list of beziers. +pub fn draw_beziers(beziers: Vec, options: String) -> String { + let start_point = beziers.first().unwrap().start(); + let mut svg = format!("", options); + svg +} + // Helper function to convert polar to cartesian coordinates fn polar_to_cartesian(center_x: f64, center_y: f64, radius: f64, angle_in_rad: f64) -> [f64; 2] { let x = center_x + radius * angle_in_rad.cos();