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
This commit is contained in:
Hannah Li 2023-03-27 16:25:08 -04:00 committed by Keavon Chambers
parent 7e124c8035
commit c0576ab4e0
18 changed files with 684 additions and 173 deletions

View file

@ -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)]

View file

@ -64,7 +64,12 @@ impl Bezier {
/// <iframe frameBorder="0" width="100%" height="400px" src="https://graphite.rs/bezier-rs-demos#bezier/tangent/solo" title="Tangent Demo"></iframe>
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<f64>)> = 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<f64>)> = 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

View file

@ -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<f64> {
let mut extrema = self.local_extrema().into_iter().flatten().collect::<Vec<f64>>();
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<f64>) -> (Vec<Bezier>, Vec<f64>) {
pub(crate) fn reduced_curves_and_t_values(&self, step_size: Option<f64>) -> (Vec<Bezier>, 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<Bezier> = Vec::new();
let mut result_t_values: Vec<f64> = 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.
/// <iframe frameBorder="0" width="100%" height="375px" src="https://graphite.rs/bezier-rs-demos#bezier/offset/solo" title="Offset Demo"></iframe>
pub fn offset<ManipulatorGroupId: crate::Identifier>(&self, distance: f64) -> Subpath<ManipulatorGroupId> {
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.
/// <iframe frameBorder="0" width="100%" height="375px" src="https://graphite.rs/bezier-rs-demos#bezier/outline/solo" title="Outline Demo"></iframe>
pub fn outline<ManipulatorGroupId: crate::Identifier>(&self, distance: f64) -> Subpath<ManipulatorGroupId> {
let first_segment = self.offset(distance);
let third_segment = self.reverse().offset(distance);
/// <iframe frameBorder="0" width="100%" height="400px" src="https://graphite.rs/bezier-rs-demos#bezier/outline/solo" title="Outline Demo"></iframe>
pub fn outline<ManipulatorGroupId: crate::Identifier>(&self, distance: f64, cap: Cap) -> Subpath<ManipulatorGroupId> {
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<ManipulatorGroup<ManipulatorGroupId>> = 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.
/// <iframe frameBorder="0" width="100%" height="400px" src="https://graphite.rs/bezier-rs-demos#bezier/graduated-outline/solo" title="Graduated Outline Demo"></iframe>
pub fn graduated_outline<ManipulatorGroupId: crate::Identifier>(&self, start_distance: f64, end_distance: f64) -> Subpath<ManipulatorGroupId> {
self.skewed_outline(start_distance, end_distance, end_distance, start_distance)
/// <iframe frameBorder="0" width="100%" height="450px" src="https://graphite.rs/bezier-rs-demos#bezier/graduated-outline/solo" title="Graduated Outline Demo"></iframe>
pub fn graduated_outline<ManipulatorGroupId: crate::Identifier>(&self, start_distance: f64, end_distance: f64, cap: Cap) -> Subpath<ManipulatorGroupId> {
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.
/// <iframe frameBorder="0" width="100%" height="475px" src="https://graphite.rs/bezier-rs-demos#bezier/skewed-outline/solo" title="Skewed Outline Demo"></iframe>
pub fn skewed_outline<ManipulatorGroupId: crate::Identifier>(&self, distance1: f64, distance2: f64, distance3: f64, distance4: f64) -> Subpath<ManipulatorGroupId> {
let first_segment = self.graduated_offset(distance1, distance2);
let third_segment = self.reverse().graduated_offset(distance3, distance4);
/// <iframe frameBorder="0" width="100%" height="550px" src="https://graphite.rs/bezier-rs-demos#bezier/skewed-outline/solo" title="Skewed Outline Demo"></iframe>
pub fn skewed_outline<ManipulatorGroupId: crate::Identifier>(&self, distance1: f64, distance2: f64, distance3: f64, distance4: f64, cap: Cap) -> Subpath<ManipulatorGroupId> {
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<ManipulatorGroup<ManipulatorGroupId>> = 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::<Vec<DVec2>>(),
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::<EmptyId>(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::<EmptyId>(10.);
let outline = line.outline::<EmptyId>(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<EmptyId> = 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::<EmptyId>(25., Cap::Round);
assert_eq!(outline, ellipse);
let cubic = Bezier::from_cubic_dvec2(p, p, p, p);
let outline_cubic = cubic.outline::<EmptyId>(25., Cap::Round);
assert_eq!(outline_cubic, ellipse);
}
#[test]
fn test_outline_single_point_square() {
let square: Subpath<EmptyId> = 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::<EmptyId>(25., Cap::Square);
assert_eq!(outline, square);
let cubic = Bezier::from_cubic_dvec2(p, p, p, p);
let outline_cubic = cubic.outline::<EmptyId>(25., Cap::Square);
assert_eq!(outline_cubic, square);
}
#[test]
fn test_graduated_scale() {
let bezier = Bezier::from_linear_coordinates(30., 60., 140., 120.);

View file

@ -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};

View file

@ -88,7 +88,7 @@ impl<ManipulatorGroupId: crate::Identifier> Subpath<ManipulatorGroupId> {
/// 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<ManipulatorGroupId: crate::Identifier> Subpath<ManipulatorGroupId> {
&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);

View file

@ -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<ManipulatorGroupId: crate::Identifier> Subpath<ManipulatorGroupId> {
/// Calculate the point on the subpath based on the parametric `t`-value provided.
@ -22,7 +23,7 @@ impl<ManipulatorGroupId: crate::Identifier> Subpath<ManipulatorGroupId> {
/// - `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.
/// <iframe frameBorder="0" width="100%" height="325px" src="https://graphite.rs/bezier-rs-demos#subpath/intersect-cubic/solo" title="Intersection Demo"></iframe>
/// <iframe frameBorder="0" width="100%" height="400px" src="https://graphite.rs/bezier-rs-demos#subpath/intersect-cubic/solo" title="Intersection Demo"></iframe>
pub fn intersections(&self, other: &Bezier, error: Option<f64>, minimum_separation: Option<f64>) -> Vec<(usize, f64)> {
self.iter()
.enumerate()
@ -34,27 +35,20 @@ impl<ManipulatorGroupId: crate::Identifier> Subpath<ManipulatorGroupId> {
/// This function expects the following:
/// - other: a [Bezier] curve to check intersections against
/// - error: an optional f64 value to provide an error bound
/// <iframe frameBorder="0" width="100%" height="325px" src="https://graphite.rs/bezier-rs-demos#subpath/intersect-cubic/solo" title="Intersection Demo"></iframe>
/// <iframe frameBorder="0" width="100%" height="400px" src="https://graphite.rs/bezier-rs-demos#subpath/intersect-cubic/solo" title="Intersection Demo"></iframe>
pub fn subpath_intersections(&self, other: &Subpath<ManipulatorGroupId>, error: Option<f64>, minimum_separation: Option<f64>) -> 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.
/// <iframe frameBorder="0" width="100%" height="400px" src="https://graphite.rs/bezier-rs-demos#subpath/tangent/solo" title="Tangent Demo"></iframe>
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.
/// <iframe frameBorder="0" width="100%" height="325px" src="https://graphite.rs/bezier-rs-demos#subpath/self-intersect/solo" title="Self-Intersection Demo"></iframe>
/// <iframe frameBorder="0" width="100%" height="350px" src="https://graphite.rs/bezier-rs-demos#subpath/self-intersect/solo" title="Self-Intersection Demo"></iframe>
pub fn self_intersections(&self, error: Option<f64>, minimum_separation: Option<f64>) -> Vec<(usize, f64)> {
let mut intersections_vec = Vec::new();
let err = error.unwrap_or(MAX_ABSOLUTE_DIFFERENCE);
@ -74,6 +68,13 @@ impl<ManipulatorGroupId: crate::Identifier> Subpath<ManipulatorGroupId> {
intersections_vec
}
/// Returns a normalized unit vector representing the tangent on the subpath based on the parametric `t`-value provided.
/// <iframe frameBorder="0" width="100%" height="400px" src="https://graphite.rs/bezier-rs-demos#subpath/tangent/solo" title="Tangent Demo"></iframe>
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.
/// <iframe frameBorder="0" width="100%" height="400px" src="https://graphite.rs/bezier-rs-demos#subpath/normal/solo" title="Normal Demo"></iframe>
pub fn normal(&self, t: SubpathTValue) -> DVec2 {
@ -83,7 +84,7 @@ impl<ManipulatorGroupId: crate::Identifier> Subpath<ManipulatorGroupId> {
/// 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]`.
/// <iframe frameBorder="0" width="100%" height="400px" src="https://graphite.rs/bezier-rs-demos#subpath/local-extrema/solo" title="Local Extrema Demo"></iframe>
/// <iframe frameBorder="0" width="100%" height="325px" src="https://graphite.rs/bezier-rs-demos#subpath/local-extrema/solo" title="Local Extrema Demo"></iframe>
pub fn local_extrema(&self) -> [Vec<f64>; 2] {
let number_of_curves = self.len_segments() as f64;
@ -98,7 +99,7 @@ impl<ManipulatorGroupId: crate::Identifier> Subpath<ManipulatorGroupId> {
}
/// Return the min and max corners that represent the bounding box of the subpath.
/// <iframe frameBorder="0" width="100%" height="400px" src="https://graphite.rs/bezier-rs-demos#subpath/bounding-box/solo" title="Bounding Box Demo"></iframe>
/// <iframe frameBorder="0" width="100%" height="325px" src="https://graphite.rs/bezier-rs-demos#subpath/bounding-box/solo" title="Bounding Box Demo"></iframe>
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<ManipulatorGroupId: crate::Identifier> Subpath<ManipulatorGroupId> {
/// 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]`.
/// <iframe frameBorder="0" width="100%" height="400px" src="https://graphite.rs/bezier-rs-demos#subpath/inflections/solo" title="Inflections Demo"></iframe>
/// <iframe frameBorder="0" width="100%" height="300px" src="https://graphite.rs/bezier-rs-demos#subpath/inflections/solo" title="Inflections Demo"></iframe>
pub fn inflections(&self) -> Vec<f64> {
let number_of_curves = self.len_segments() as f64;
let inflection_t_values: Vec<f64> = self
@ -135,6 +136,92 @@ impl<ManipulatorGroupId: crate::Identifier> Subpath<ManipulatorGroupId> {
pub fn contains_point(&self, target_point: DVec2) -> bool {
self.iter().map(|bezier| bezier.winding(target_point)).sum::<i32>() != 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<ManipulatorGroupId>) -> Option<ManipulatorGroup<ManipulatorGroupId>> {
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<ManipulatorGroupId>, center: DVec2) -> (DVec2, ManipulatorGroup<ManipulatorGroupId>, 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<ManipulatorGroupId>) -> (DVec2, ManipulatorGroup<ManipulatorGroupId>, 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<ManipulatorGroupId>) -> [ManipulatorGroup<ManipulatorGroupId>; 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.);
}
}

View file

@ -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<ManipulatorGroupId: crate::Identifier> Subpath<ManipulatorGroupId> {
}
/// 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<ManipulatorGroupId> {
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<ManipulatorGroupId: crate::Identifier> Subpath<ManipulatorGroupId> {
/// 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`.
/// <iframe frameBorder="0" width="100%" height="400px" src="https://graphite.rs/bezier-rs-demos#subpath/trim/solo" title="Trim Demo"></iframe>
/// <iframe frameBorder="0" width="100%" height="450px" src="https://graphite.rs/bezier-rs-demos#subpath/trim/solo" title="Trim Demo"></iframe>
pub fn trim(&self, t1: SubpathTValue, t2: SubpathTValue) -> Subpath<ManipulatorGroupId> {
// 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<ManipulatorGroupId: crate::Identifier> Subpath<ManipulatorGroupId> {
/// 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<ManipulatorGroupId: crate::Identifier> Subpath<ManipulatorGroupId> {
}
/// 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.
/// <iframe frameBorder="0" width="100%" height="375px" src="https://graphite.rs/bezier-rs-demos#subpath/offset/solo" title="Offset Demo"></iframe>
pub fn offset(&self, distance: f64, joint: Joint) -> Subpath<ManipulatorGroupId> {
/// The intersections of segments of the subpath are joined using the method specified by the `join` argument.
/// <iframe frameBorder="0" width="100%" height="400px" src="https://graphite.rs/bezier-rs-demos#subpath/offset/solo" title="Offset Demo"></iframe>
pub fn offset(&self, distance: f64, join: Join) -> Subpath<ManipulatorGroupId> {
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::<Vec<Subpath<ManipulatorGroupId>>>();
let mut subpaths = self
.iter()
.filter(|bezier| !bezier.is_point())
.map(|bezier| bezier.offset(distance))
.collect::<Vec<Subpath<ManipulatorGroupId>>>();
let mut drop_common_point = vec![true; self.len()];
// Clip or join consecutive Subpaths
@ -359,7 +372,7 @@ impl<ManipulatorGroupId: crate::Identifier> Subpath<ManipulatorGroupId> {
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<ManipulatorGroupId: crate::Identifier> Subpath<ManipulatorGroupId> {
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<ManipulatorGroupId: crate::Identifier> Subpath<ManipulatorGroupId> {
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<ManipulatorGroupId: crate::Identifier> Subpath<ManipulatorGroupId> {
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<ManipulatorGroupId>, cap: Cap) -> Subpath<ManipulatorGroupId> {
let mut result_manipulator_groups: Vec<ManipulatorGroup<ManipulatorGroupId>> = 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.
/// <iframe frameBorder="0" width="100%" height="375px" src="https://graphite.rs/bezier-rs-demos#subpath/outline/solo" title="Outline Demo"></iframe>
pub fn outline(&self, distance: f64, joint: Joint) -> (Subpath<ManipulatorGroupId>, Option<Subpath<ManipulatorGroupId>>) {
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.
/// <iframe frameBorder="0" width="100%" height="450px" src="https://graphite.rs/bezier-rs-demos#subpath/outline/solo" title="Outline Demo"></iframe>
pub fn outline(&self, distance: f64, join: Join, cap: Cap) -> (Subpath<ManipulatorGroupId>, Option<Subpath<ManipulatorGroupId>>) {
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<EmptyId> = Subpath::new_ellipse(DVec2::new(0., 0.), DVec2::new(50., 50.)).reverse();
let p = DVec2::new(25., 25.);
let subpath: Subpath<EmptyId> = 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<EmptyId> = 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<EmptyId> = 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<EmptyId> = 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<EmptyId> = 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);
}
}

View file

@ -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<ManipulatorGroupId: crate::Identifier>(
left: DVec2,
arc_point: DVec2,
right: DVec2,
center: DVec2,
angle: Option<f64>,
) -> (DVec2, ManipulatorGroup<ManipulatorGroupId>, 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::*;

View file

@ -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;
}

View file

@ -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, number>): string => bezier.outline(options.distance),
callback: (bezier: WasmBezierInstance, options: Record<string, number>): 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, number>): string => bezier.graduated_outline(options.start_distance, options.end_distance),
callback: (bezier: WasmBezierInstance, options: Record<string, number>): 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, number>): string => bezier.skewed_outline(options.distance1, options.distance2, options.distance3, options.distance4),
callback: (bezier: WasmBezierInstance, options: Record<string, number>): 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,
],
},
},

View file

@ -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, number>): string => subpath.offset(options.distance),
callback: (subpath: WasmSubpathInstance, options: Record<string, number>): 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, number>): string => subpath.outline(options.distance),
callback: (subpath: WasmSubpathInstance, options: Record<string, number>): 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<SubpathFeatureKey, SubpathFeatureOptions>;

View file

@ -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"],
};

View file

@ -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);

View file

@ -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 {

View file

@ -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::<EmptyId>(distance);
pub fn outline(&self, distance: f64, cap: i32) -> String {
let cap = parse_cap(cap);
let outline_subpath = self.0.outline::<EmptyId>(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::<EmptyId>(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::<EmptyId>(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::<EmptyId>(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::<EmptyId>(distance1, distance2, distance3, distance4, cap);
if outline_subpath.is_empty() {
return String::new();
}

View file

@ -1,3 +1,4 @@
pub mod bezier;
pub mod subpath;
mod svg_drawing;
mod utils;

View file

@ -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());

View file

@ -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),
}
}