Implement bezier local_extrema function (#693)

* Implement backend of extrema in bezier-rs

* Added extrema frontend

* Added extrema interface

* Wrapped extrema in filter function to remove points not on the curve

* Saved intermediate results while computing extrema

* Fixed extrema bug when a in cubic formula is 0

* Removed extra prints

* Fixed quadratic extrema regression

* Moved helper functions to utils file

* Fixed bug in solve linear

* Stylistic changes per review

* Sentence comments

Co-authored-by: Linda Zheng <thelindazheng@gmail.com>
Co-authored-by: Keavon Chambers <keavon@keavon.com>
This commit is contained in:
Rob Nadal 2022-07-02 02:00:51 -04:00 committed by Keavon Chambers
parent 3ab47418d2
commit c343aaa3ab
4 changed files with 119 additions and 38 deletions

View File

@ -228,7 +228,7 @@ export default defineComponent({
},
{
name: "Project",
callback: (canvas: HTMLCanvasElement, bezier: WasmBezierInstance, options: Record<string, number>, mouseLocation: Point | null): void => {
callback: (canvas: HTMLCanvasElement, bezier: WasmBezierInstance, options: Record<string, number>, mouseLocation?: Point): void => {
if (mouseLocation != null) {
const context = getContextFromCanvas(canvas);
const closestPoint = JSON.parse(bezier.project(mouseLocation.x, mouseLocation.y));
@ -236,6 +236,20 @@ export default defineComponent({
}
},
},
{
name: "Local Extrema",
callback: (canvas: HTMLCanvasElement, bezier: WasmBezierInstance): void => {
const context = getContextFromCanvas(canvas);
const dimensionColors = [COLORS.NON_INTERACTIVE.STROKE_1, COLORS.NON_INTERACTIVE.STROKE_2];
const extrema: number[][] = JSON.parse(bezier.local_extrema());
extrema.forEach((tValues, index) => {
tValues.forEach((t) => {
const point = JSON.parse(bezier.compute(t));
drawPoint(context, point, 4, dimensionColors[index]);
});
});
},
},
],
};
},

View File

@ -14,20 +14,20 @@ struct Point {
#[derive(Clone)]
pub struct WasmBezier(Bezier);
/// Convert a `DVec2` into a `JsValue`
/// Convert a `DVec2` into a `JsValue`.
pub fn vec_to_point(p: &DVec2) -> JsValue {
JsValue::from_serde(&serde_json::to_string(&Point { x: p.x, y: p.y }).unwrap()).unwrap()
}
#[wasm_bindgen]
impl WasmBezier {
/// Expect js_points to be a list of 3 pairs
/// Expect js_points to be a list of 3 pairs.
pub fn new_quadratic(js_points: &JsValue) -> WasmBezier {
let points: [DVec2; 3] = js_points.into_serde().unwrap();
WasmBezier(Bezier::from_quadratic_dvec2(points[0], points[1], points[2]))
}
/// Expect js_points to be a list of 4 pairs
/// Expect js_points to be a list of 4 pairs.
pub fn new_cubic(js_points: &JsValue) -> WasmBezier {
let points: [DVec2; 4] = js_points.into_serde().unwrap();
WasmBezier(Bezier::from_cubic_dvec2(points[0], points[1], points[2], points[3]))
@ -102,4 +102,9 @@ impl WasmBezier {
pub fn project(&self, x: f64, y: f64) -> JsValue {
vec_to_point(&self.0.project(DVec2::new(x, y), 20, 1e-4, 3, 10))
}
pub fn local_extrema(&self) -> JsValue {
let local_extrema = self.0.local_extrema();
JsValue::from_serde(&serde_json::to_string(&local_extrema).unwrap()).unwrap()
}
}

View File

@ -2,37 +2,37 @@ use glam::DVec2;
mod utils;
/// Representation of the handle point(s) in a bezier segment
/// Representation of the handle point(s) in a bezier segment.
#[derive(Copy, Clone)]
pub enum BezierHandles {
/// Handles for a quadratic segment
/// Handles for a quadratic segment.
Quadratic {
/// Point representing the location of the single handle
/// Point representing the location of the single handle.
handle: DVec2,
},
/// Handles for a cubic segment
/// Handles for a cubic segment.
Cubic {
/// Point representing the location of the handle associated to the start point
/// Point representing the location of the handle associated to the start point.
handle_start: DVec2,
/// Point representing the location of the handle associated to the end point
/// Point representing the location of the handle associated to the end point.
handle_end: DVec2,
},
}
/// Representation of a bezier segment with 2D points
/// Representation of a bezier segment with 2D points.
#[derive(Copy, Clone)]
pub struct Bezier {
/// Start point of the bezier segment
/// Start point of the bezier segment.
start: DVec2,
/// Start point of the bezier segment
/// Start point of the bezier segment.
end: DVec2,
/// Handles of the bezier segment
/// Handles of the bezier segment.
handles: BezierHandles,
}
impl Bezier {
// TODO: Consider removing this function
/// Create a quadratic bezier using the provided coordinates as the start, handle, and end points
/// Create a quadratic bezier using the provided coordinates as the start, handle, and end points.
pub fn from_quadratic_coordinates(x1: f64, y1: f64, x2: f64, y2: f64, x3: f64, y3: f64) -> Self {
Bezier {
start: DVec2::new(x1, y1),
@ -41,7 +41,7 @@ impl Bezier {
}
}
/// Create a quadratc bezier using the provided DVec2s as the start, handle, and end points
/// Create a quadratic bezier using the provided DVec2s as the start, handle, and end points.
pub fn from_quadratic_dvec2(p1: DVec2, p2: DVec2, p3: DVec2) -> Self {
Bezier {
start: p1,
@ -51,7 +51,7 @@ impl Bezier {
}
// TODO: Consider removing this function
/// Create a cubic bezier using the provided coordinates as the start, handles, and end points
/// Create a cubic bezier using the provided coordinates as the start, handles, and end points.
pub fn from_cubic_coordinates(x1: f64, y1: f64, x2: f64, y2: f64, x3: f64, y3: f64, x4: f64, y4: f64) -> Self {
Bezier {
start: DVec2::new(x1, y1),
@ -63,7 +63,7 @@ impl Bezier {
}
}
/// Create a cubic bezier using the provided DVec2s as the start, handles, and end points
/// Create a cubic bezier using the provided DVec2s as the start, handles, and end points.
pub fn from_cubic_dvec2(p1: DVec2, p2: DVec2, p3: DVec2, p4: DVec2) -> Self {
Bezier {
start: p1,
@ -110,7 +110,7 @@ impl Bezier {
Bezier::from_cubic_dvec2(start, handle_start, handle_end, end)
}
/// Convert to SVG
/// Convert to SVG.
// TODO: Allow modifying the viewport, width and height
pub fn to_svg(&self) -> String {
let m_path = format!("M {} {}", self.start.x, self.start.y);
@ -129,12 +129,12 @@ impl Bezier {
)
}
/// Set the coordinates of the start point
/// Set the coordinates of the start point.
pub fn set_start(&mut self, s: DVec2) {
self.start = s;
}
/// Set the coordinates of the end point
/// Set the coordinates of the end point.
pub fn set_end(&mut self, e: DVec2) {
self.end = e;
}
@ -199,8 +199,8 @@ impl Bezier {
}
}
/// Calculate the point on the curve based on the `t`-value provided.
/// Basis code based off of pseudocode found here: <https://pomax.github.io/bezierinfo/#explanation>
/// Calculate the point on the curve based on the `t`-value provided.
/// Basis code based off of pseudocode found here: <https://pomax.github.io/bezierinfo/#explanation>.
pub fn compute(&self, t: f64) -> DVec2 {
assert!((0.0..=1.0).contains(&t));
@ -218,8 +218,8 @@ impl Bezier {
}
}
/// Return a selection of equidistant points on the bezier curve
/// If no value is provided for `steps`, then the function will default `steps` to be 10
/// Return a selection of equidistant points on the bezier curve.
/// If no value is provided for `steps`, then the function will default `steps` to be 10.
pub fn compute_lookup_table(&self, steps: Option<i32>) -> Vec<DVec2> {
let steps_unwrapped = steps.unwrap_or(10);
let ratio: f64 = 1.0 / (steps_unwrapped as f64);
@ -232,8 +232,8 @@ impl Bezier {
steps_array
}
/// Return an approximation of the length of the bezier curve
/// code example taken from: <https://gamedev.stackexchange.com/questions/5373/moving-ships-between-two-planets-along-a-bezier-missing-some-equations-for-acce/5427#5427>
/// Return an approximation of the length of the bezier curve.
/// Code example from <https://gamedev.stackexchange.com/questions/5373/moving-ships-between-two-planets-along-a-bezier-missing-some-equations-for-acce/5427#5427>.
pub fn length(&self) -> f64 {
// We will use an approximate approach where
// we split the curve into many subdivisions
@ -254,7 +254,7 @@ impl Bezier {
approx_curve_length
}
/// Returns a vector representing the derivative at the point designated by `t` on the curve
/// Returns a vector representing the derivative at the point designated by `t` on the curve.
pub fn derivative(&self, t: f64) -> DVec2 {
let one_minus_t = 1. - t;
match self.handles {
@ -272,18 +272,18 @@ impl Bezier {
}
}
/// Returns a normalized unit vector representing the tangent at the point designated by `t` on the curve
/// Returns a normalized unit vector representing the tangent at the point designated by `t` on the curve.
pub fn tangent(&self, t: f64) -> DVec2 {
self.derivative(t).normalize()
}
/// Returns a normalized unit vector representing the direction of the normal at the point designated by `t` on the curve
/// Returns a normalized unit vector representing the direction of the normal at the point designated by `t` on the curve.
pub fn normal(&self, t: f64) -> DVec2 {
let derivative = self.derivative(t);
derivative.normalize().perp()
}
/// Returns the pair of Bezier curves that result from splitting the original curve at the point corresponding to `t`
/// Returns the pair of Bezier curves that result from splitting the original curve at the point corresponding to `t`.
pub fn split(&self, t: f64) -> [Bezier; 2] {
let split_point = self.compute(t);
@ -314,7 +314,7 @@ impl Bezier {
}
}
/// Returns the Bezier curve representing the sub-curve starting at the point corresponding to `t1` and ending at the point corresponding to `t2`
/// 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 {
// Depending on the order of `t1` and `t2`, determine which half of the split we need to keep
let t1_split_side = if t1 <= t2 { 1 } else { 0 };
@ -334,10 +334,10 @@ impl Bezier {
/// Returns the closest point on the curve to the provided point.
/// Uses a searching algorithm akin to binary search that can be customized using the following parameters:
/// - `lut_size` - Size of the lookup table for the initial passthrough
/// - `convergence_epsilon` - Difference used between floating point numbers to be considered as equal
/// - `convergence_limit` - Controls the number of iterations needed to consider that minimum distance to have converged
/// - `iteration_limit` - Controls the maximum total number of iterations to be used
/// - `lut_size` - Size of the lookup table for the initial passthrough.
/// - `convergence_epsilon` - Difference used between floating point numbers to be considered as equal.
/// - `convergence_limit` - Controls the number of iterations needed to consider that minimum distance to have converged.
/// - `iteration_limit` - Controls the maximum total number of iterations to be used.
pub fn project(&self, point: DVec2, lut_size: i32, convergence_epsilon: f64, convergence_limit: i32, iteration_limit: i32) -> DVec2 {
// First find the closest point from the results of a lookup table
let lut = self.compute_lookup_table(Some(lut_size));
@ -418,6 +418,41 @@ impl Bezier {
self.compute(final_t)
}
/// Returns two lists of `t`-values representing the local extrema of the `x` and `y` parametric curves respectively.
/// The local extrema are defined to be points at which the derivative of the curve is equal to zero.
fn unrestricted_local_extrema(&self) -> [Vec<f64>; 2] {
match self.handles {
BezierHandles::Quadratic { handle } => {
let a = handle - self.start;
let b = self.end - handle;
let b_minus_a = b - a;
[utils::solve_linear(b_minus_a.x, a.x), utils::solve_linear(b_minus_a.y, a.y)]
}
BezierHandles::Cubic { handle_start, handle_end } => {
let a = 3. * (-self.start + 3. * handle_start - 3. * handle_end + self.end);
let b = 6. * (self.start - 2. * handle_start + handle_end);
let c = 3. * (handle_start - self.start);
let discriminant = b * b - 4. * a * c;
let two_times_a = 2. * a;
[
utils::solve_quadratic(discriminant.x, two_times_a.x, b.x, c.x),
utils::solve_quadratic(discriminant.y, two_times_a.y, b.y, c.y),
]
}
}
}
/// Returns two lists of `t`-values representing the local extrema of the `x` and `y` parametric curves respectively.
/// The list of `t`-values returned are filtered such that they fall within the range `[0, 1]`.
pub fn local_extrema(&self) -> [Vec<f64>; 2] {
self.unrestricted_local_extrema()
.into_iter()
.map(|t_values| t_values.into_iter().filter(|&t| t > 0. && t < 1.).collect::<Vec<f64>>())
.collect::<Vec<Vec<f64>>>()
.try_into()
.unwrap()
}
}
#[cfg(test)]

View File

@ -20,8 +20,8 @@ pub fn compute_abc_for_quadratic_through_points(start_point: DVec2, point_on_cur
compute_abc_through_points(start_point, point_on_curve, end_point, t_squared, squared_one_minus_t)
}
/// Compute a, b, and c for a cubic curve that fits the start, end and point on curve at `t`.
/// The definition for the a, b, c points are defined in [the projection identity section](https://pomax.github.io/bezierinfo/#abc) of Pomax's bezier curve primer.
/// Compute `a`, `b`, and `c` for a cubic curve that fits the start, end and point on curve at `t`.
/// The definition for the `a`, `b`, `c` points are defined in [the projection identity section](https://pomax.github.io/bezierinfo/#abc) of Pomax's bezier curve primer.
pub fn compute_abc_for_cubic_through_points(start_point: DVec2, point_on_curve: DVec2, end_point: DVec2, t: f64) -> [DVec2; 3] {
let t_cubed = t * t * t;
let one_minus_t = 1. - t;
@ -37,3 +37,30 @@ pub fn get_closest_point_in_lut(lut: &[DVec2], point: DVec2) -> (i32, f64) {
.min_by(|x, y| (&(x.1)).partial_cmp(&(y.1)).unwrap())
.unwrap()
}
/// Find the roots of the linear equation `ax + b`
pub fn solve_linear(a: f64, b: f64) -> Vec<f64> {
let mut roots = Vec::new();
if a != 0. {
roots.push(-b / a);
}
roots
}
/// Find the roots of the linear equation `ax^2 + bx + c`
/// Precompute the `discriminant` (`b^2 - 4ac`) and `two_times_a` arguments prior to calling this function for efficiency purposes
pub fn solve_quadratic(discriminant: f64, two_times_a: f64, b: f64, c: f64) -> Vec<f64> {
let mut roots = Vec::new();
if two_times_a != 0. {
if discriminant > 0. {
let root_discriminant = discriminant.sqrt();
roots.push((-b + root_discriminant) / (two_times_a));
roots.push((-b - root_discriminant) / (two_times_a));
} else if discriminant == 0. {
roots.push(-b / (two_times_a));
}
} else {
roots = solve_linear(b, c);
}
roots
}