Implement arcs for Bezier math library (#731)

* added arcs impl

Co-authored-by: Hannah Li <hannahli2010@gmail.com>
Co-authored-by: Rob Nadal <RobNadal@users.noreply.github.com>

* fixed arc drawing,  todo - fix linear check

Co-authored-by: Hannah Li <hannahli2010@gmail.com>

* fixed linear bug + added comments and tests

Co-authored-by: Hannah Li <hannahli2010@gmail.com>

* added max iteration guard + made params optional  + added impl todo

* Add functionality to get arcs between extrema

* Add ArcsOptions to manage optional parameters of the arcs function

* added slider to toggle between arcs impl

Co-authored-by: Rob Nadal <RobNadal@users.noreply.github.com>

* Remove unused types

* address some comments

* added rustdoc for CircularArc struct

* Extract duplicate code into helper, remove loop labels, use window function

* Make JsValue handling consistent in WasmBezier and add comments for the underlying type

* Add enum for MaximizeArcs Auto/On/Off functionality

* Change Auto to Automatic

* fix errors from resolving merge conflict

* fixed error from resolving merge conflicts

* fixed formatting

* address comments

* Small fix

* Add some missing comments

* address comments

* rename variable

* Use unit to show maximize_arcs values

* Change i32 to usize and other minor adjustments

* Change computation for middle t values

* Remove tsconfig

* Fix more usize number handling

Co-authored-by: Hannah Li <hannahli2010@gmail.com>
Co-authored-by: Rob Nadal <RobNadal@users.noreply.github.com>
Co-authored-by: Keavon Chambers <keavon@keavon.com>
This commit is contained in:
Thomas Cheng 2022-08-06 01:34:39 -04:00 committed by Keavon Chambers
parent 0f88055573
commit b84e647f40
13 changed files with 582 additions and 103 deletions

View File

@ -25,8 +25,8 @@
<script lang="ts">
import { defineComponent, markRaw } from "vue";
import { drawBezier, drawBezierHelper, drawCircle, drawCurve, drawLine, drawPoint, drawText, getContextFromCanvas, COLORS } from "@/utils/drawing";
import { BezierCurveType, Point, WasmBezierInstance, WasmSubpathInstance } from "@/utils/types";
import { drawBezier, drawBezierHelper, drawCircle, drawCircleSector, drawCurve, drawLine, drawPoint, drawText, getContextFromCanvas, COLORS } from "@/utils/drawing";
import { BezierCurveType, CircleSector, Point, WasmBezierInstance, WasmSubpathInstance } from "@/utils/types";
import ExamplePane from "@/components/ExamplePane.vue";
import SliderExample from "@/components/SliderExample.vue";
@ -115,10 +115,10 @@ export default defineComponent({
{
name: "Lookup Table",
callback: (canvas: HTMLCanvasElement, bezier: WasmBezierInstance, options: Record<string, number>): void => {
const lookupPoints = bezier.compute_lookup_table(options.steps);
lookupPoints.forEach((serializedPoint, index) => {
const lookupPoints: Point[] = JSON.parse(bezier.compute_lookup_table(options.steps));
lookupPoints.forEach((point, index) => {
if (index !== 0 && index !== lookupPoints.length - 1) {
drawPoint(getContextFromCanvas(canvas), JSON.parse(serializedPoint), 3, COLORS.NON_INTERACTIVE.STROKE_1);
drawPoint(getContextFromCanvas(canvas), point, 3, COLORS.NON_INTERACTIVE.STROKE_1);
}
});
},
@ -142,7 +142,7 @@ export default defineComponent({
const derivativeBezier = bezier.derivative();
if (derivativeBezier) {
const points: Point[] = derivativeBezier.get_points().map((p) => JSON.parse(p));
const points: Point[] = JSON.parse(derivativeBezier.get_points());
if (points.length === 2) {
drawLine(context, points[0], points[1], COLORS.NON_INTERACTIVE.STROKE_1);
} else {
@ -356,10 +356,7 @@ export default defineComponent({
name: "Rotate",
callback: (canvas: HTMLCanvasElement, bezier: WasmBezierInstance, options: Record<string, number>): void => {
const context = getContextFromCanvas(canvas);
const rotatedBezier = bezier
.rotate(options.angle * Math.PI)
.get_points()
.map((p) => JSON.parse(p));
const rotatedBezier = JSON.parse(bezier.rotate(options.angle * Math.PI).get_points());
drawBezier(context, rotatedBezier, null, { curveStrokeColor: COLORS.NON_INTERACTIVE.STROKE_1, radius: 3.5 });
},
template: markRaw(SliderExample),
@ -496,6 +493,57 @@ export default defineComponent({
});
},
},
{
name: "Arcs",
callback: (canvas: HTMLCanvasElement, bezier: WasmBezierInstance, options: Record<string, number>): void => {
const context = getContextFromCanvas(canvas);
const arcs: CircleSector[] = JSON.parse(bezier.arcs(options.error, options.max_iterations, options.strategy));
arcs.forEach((circleSector, index) => {
drawCircleSector(context, circleSector, `hsl(${40 * index}, 100%, 50%, 75%)`, `hsl(${40 * index}, 100%, 50%, 37.5%)`);
});
},
template: markRaw(SliderExample),
templateOptions: {
sliders: [
{
variable: "strategy",
min: 0,
max: 2,
step: 1,
default: 0,
unit: [": Automatic", ": FavorLargerArcs", ": FavorCorrectness"],
},
{
variable: "error",
min: 0.05,
max: 1,
step: 0.05,
default: 0.5,
},
{
variable: "max_iterations",
min: 50,
max: 200,
step: 1,
default: 100,
},
],
},
curveDegrees: new Set([BezierCurveType.Quadratic, BezierCurveType.Cubic]),
customPoints: {
[BezierCurveType.Quadratic]: [
[50, 50],
[85, 65],
[100, 100],
],
[BezierCurveType.Cubic]: [
[160, 180],
[170, 10],
[30, 90],
[180, 160],
],
},
},
{
name: "Offset",
callback: (canvas: HTMLCanvasElement, bezier: WasmBezierInstance, options: Record<string, number>): void => {

View File

@ -35,16 +35,14 @@ class BezierDrawing {
this.callback = callback;
this.options = options;
this.createThroughPoints = createThroughPoints;
this.points = bezier
.get_points()
.map((p) => JSON.parse(p))
.map((p, i, points) => ({
x: p.x,
y: p.y,
r: getPointSizeByIndex(i, points.length),
selected: false,
manipulator: MANIPULATOR_KEYS_FROM_BEZIER_TYPE[points.length][i],
}));
const bezierPoints: Point[] = JSON.parse(bezier.get_points());
this.points = bezierPoints.map((p, i, points) => ({
x: p.x,
y: p.y,
r: getPointSizeByIndex(i, points.length),
selected: false,
manipulator: MANIPULATOR_KEYS_FROM_BEZIER_TYPE[points.length][i],
}));
if (this.createThroughPoints && this.points.length === 4) {
// Use the first handler as the middle point
@ -122,7 +120,7 @@ class BezierDrawing {
// For the create through points cases, we store a bezier where the handle is actually the point that the curve should pass through
// This is so that we can re-use the drag and drop logic, while simply drawing the desired bezier instead
const actualBezierPointLength = this.bezier.get_points().length;
const actualBezierPointLength = JSON.parse(this.bezier.get_points()).length;
let pointsToDraw = this.points;
let styleConfig: Partial<BezierStyleConfig> = {
@ -130,14 +128,14 @@ class BezierDrawing {
};
let dragIndex = this.dragIndex;
if (this.createThroughPoints) {
let serializedPoints;
let bezierThroughPoints;
const pointList = this.points.map((p) => [p.x, p.y]);
if (actualBezierPointLength === 3) {
serializedPoints = WasmBezier.quadratic_through_points(pointList, this.options.t);
bezierThroughPoints = WasmBezier.quadratic_through_points(pointList, this.options.t);
} else {
serializedPoints = WasmBezier.cubic_through_points(pointList, this.options.t, this.options["midpoint separation"]);
bezierThroughPoints = WasmBezier.cubic_through_points(pointList, this.options.t, this.options["midpoint separation"]);
}
pointsToDraw = serializedPoints.get_points().map((p) => JSON.parse(p));
pointsToDraw = JSON.parse(bezierThroughPoints.get_points());
if (this.dragIndex === 1) {
// Do not propagate dragIndex when the the non-endpoint is moved
dragIndex = null;

View File

@ -2,7 +2,7 @@
<div>
<Example :title="title" :bezier="bezier" :callback="callback" :options="sliderData" :createThroughPoints="createThroughPoints" />
<div v-for="(slider, index) in templateOptions.sliders" :key="index">
<div class="slider-label">{{ slider.variable }} = {{ sliderData[slider.variable] }}{{ sliderUnits[slider.variable] }}</div>
<div class="slider-label">{{ slider.variable }} = {{ sliderData[slider.variable] }}{{ getSliderValue(sliderData[slider.variable], sliderUnits[slider.variable]) }}</div>
<input class="slider" v-model.number="sliderData[slider.variable]" type="range" :step="slider.step" :min="slider.min" :max="slider.max" />
</div>
</div>
@ -45,6 +45,9 @@ export default defineComponent({
components: {
Example,
},
methods: {
getSliderValue: (sliderValue: number, sliderUnit?: string | string[]) => (Array.isArray(sliderUnit) ? sliderUnit[sliderValue] : sliderUnit),
},
});
</script>

View File

@ -1,4 +1,4 @@
import { BezierStyleConfig, Point, WasmBezierInstance } from "@/utils/types";
import { BezierStyleConfig, CircleSector, Point, WasmBezierInstance } from "@/utils/types";
const HANDLE_RADIUS_FACTOR = 2 / 3;
const DEFAULT_ENDPOINT_RADIUS = 5;
@ -81,8 +81,22 @@ export const drawCircle = (ctx: CanvasRenderingContext2D, point: Point, radius:
ctx.stroke();
};
export const drawCircleSector = (ctx: CanvasRenderingContext2D, circleSector: CircleSector, strokeColor = COLORS.INTERACTIVE.STROKE_1, fillColor = COLORS.NON_INTERACTIVE.STROKE_1): void => {
ctx.strokeStyle = strokeColor;
ctx.fillStyle = fillColor;
ctx.lineWidth = 2;
const { center, radius, startAngle, endAngle } = circleSector;
ctx.beginPath();
ctx.moveTo(center.x, center.y);
ctx.arc(center.x, center.y, radius, startAngle, endAngle);
ctx.lineTo(center.x, center.y);
ctx.closePath();
ctx.fill();
};
export const drawBezierHelper = (ctx: CanvasRenderingContext2D, bezier: WasmBezierInstance, bezierStyleConfig: Partial<BezierStyleConfig> = {}): void => {
const points = bezier.get_points().map((p: string) => JSON.parse(p));
const points = JSON.parse(bezier.get_points());
drawBezier(ctx, points, null, bezierStyleConfig);
};

View File

@ -23,7 +23,7 @@ export type SliderOption = {
step: number;
default: number;
variable: string;
unit?: string;
unit?: string | string[];
};
export type TemplateOption = {
@ -46,3 +46,10 @@ export type BezierStyleConfig = {
radius: number;
drawHandles: boolean;
};
export type CircleSector = {
center: Point;
radius: number;
startAngle: number;
endAngle: number;
};

View File

@ -1,25 +1,42 @@
pub mod subpath;
mod svg_drawing;
use bezier_rs::{Bezier, ProjectionOptions};
use bezier_rs::{ArcStrategy, ArcsOptions, Bezier, ProjectionOptions};
use glam::DVec2;
use serde::{Deserialize, Serialize};
use wasm_bindgen::prelude::*;
#[derive(Serialize, Deserialize)]
struct CircleSector {
center: Point,
radius: f64,
#[serde(rename = "startAngle")]
start_angle: f64,
#[serde(rename = "endAngle")]
end_angle: f64,
}
#[derive(Serialize, Deserialize)]
struct Point {
x: f64,
y: f64,
}
#[wasm_bindgen]
pub enum WasmMaximizeArcs {
Automatic, // 0
On, // 1
Off, // 2
}
/// Wrapper of the `Bezier` struct to be used in JS.
#[wasm_bindgen]
#[derive(Clone)]
pub struct WasmBezier(Bezier);
/// Convert a `DVec2` into a `JsValue`.
fn vec_to_point(p: &DVec2) -> JsValue {
JsValue::from_serde(&serde_json::to_string(&Point { x: p.x, y: p.y }).unwrap()).unwrap()
/// Convert a `DVec2` into a `Point`.
fn vec_to_point(p: &DVec2) -> Point {
Point { x: p.x, y: p.y }
}
/// Convert a bezier to a list of points.
@ -32,6 +49,14 @@ fn to_js_value<T: Serialize>(data: T) -> JsValue {
JsValue::from_serde(&serde_json::to_string(&data).unwrap()).unwrap()
}
fn convert_wasm_maximize_arcs(wasm_enum_value: WasmMaximizeArcs) -> ArcStrategy {
match wasm_enum_value {
WasmMaximizeArcs::Automatic => ArcStrategy::Automatic,
WasmMaximizeArcs::On => ArcStrategy::FavorLargerArcs,
WasmMaximizeArcs::Off => ArcStrategy::FavorCorrectness,
}
}
#[wasm_bindgen]
impl WasmBezier {
/// Expect js_points to be a list of 2 pairs.
@ -78,8 +103,10 @@ impl WasmBezier {
self.0.set_handle_end(DVec2::new(x, y));
}
pub fn get_points(&self) -> Vec<JsValue> {
self.0.get_points().map(|point| vec_to_point(&point)).collect()
/// The wrapped return type is `Vec<Point>`.
pub fn get_points(&self) -> JsValue {
let points: Vec<Point> = self.0.get_points().map(|point| vec_to_point(&point)).collect();
to_js_value(points)
}
pub fn to_svg(&self) -> String {
@ -90,26 +117,39 @@ impl WasmBezier {
self.0.length(None)
}
/// The wrapped return type is `Point`.
pub fn evaluate(&self, t: f64) -> JsValue {
vec_to_point(&self.0.evaluate(t))
let point: Point = vec_to_point(&self.0.evaluate(t));
to_js_value(point)
}
pub fn compute_lookup_table(&self, steps: i32) -> Vec<JsValue> {
self.0.compute_lookup_table(Some(steps)).iter().map(vec_to_point).collect()
/// The wrapped return type is `Vec<Point>`.
pub fn compute_lookup_table(&self, steps: usize) -> JsValue {
let table_values: Vec<Point> = self.0.compute_lookup_table(Some(steps)).iter().map(vec_to_point).collect();
to_js_value(table_values)
}
pub fn derivative(&self) -> Option<WasmBezier> {
self.0.derivative().map(WasmBezier)
}
/// The wrapped return type is `Point`.
pub fn tangent(&self, t: f64) -> JsValue {
vec_to_point(&self.0.tangent(t))
let tangent_point: Point = vec_to_point(&self.0.tangent(t));
to_js_value(tangent_point)
}
/// The wrapped return type is `Point`.
pub fn normal(&self, t: f64) -> JsValue {
vec_to_point(&self.0.normal(t))
let normal_point: Point = vec_to_point(&self.0.normal(t));
to_js_value(normal_point)
}
pub fn curvature(&self, t: f64) -> f64 {
self.0.curvature(t)
}
/// The wrapped return type is `[Vec<Point>; 2]`.
pub fn split(&self, t: f64) -> JsValue {
let bezier_points: [Vec<Point>; 2] = self.0.split(t).map(bezier_to_points);
to_js_value(bezier_points)
@ -123,29 +163,33 @@ impl WasmBezier {
self.0.project(DVec2::new(x, y), ProjectionOptions::default())
}
/// The wrapped return type is `[Vec<f64>; 2]`.
pub fn local_extrema(&self) -> JsValue {
let local_extrema = self.0.local_extrema();
let local_extrema: [Vec<f64>; 2] = self.0.local_extrema();
to_js_value(local_extrema)
}
/// The wrapped return type is `[Point; 2]`.
pub fn bounding_box(&self) -> JsValue {
let bbox_points: [Point; 2] = self.0.bounding_box().map(|p| Point { x: p.x, y: p.y });
to_js_value(bbox_points)
}
/// The wrapped return type is `Vec<f64>`.
pub fn inflections(&self) -> JsValue {
let inflections = self.0.inflections();
let inflections: Vec<f64> = self.0.inflections();
to_js_value(inflections)
}
/// The wrapped return type is `Vec<Vec<Point>>`.
pub fn de_casteljau_points(&self, t: f64) -> JsValue {
let hull = self
let points: Vec<Vec<Point>> = self
.0
.de_casteljau_points(t)
.iter()
.map(|level| level.iter().map(|&point| Point { x: point.x, y: point.y }).collect::<Vec<Point>>())
.collect::<Vec<Vec<Point>>>();
to_js_value(hull)
.collect();
to_js_value(points)
}
pub fn rotate(&self, angle: f64) -> WasmBezier {
@ -185,12 +229,30 @@ impl WasmBezier {
to_js_value(bezier_points)
}
/// The wrapped return type is `Vec<Vec<Point>>`.
pub fn offset(&self, distance: f64) -> JsValue {
let bezier_points: Vec<Vec<Point>> = self.0.offset(distance).into_iter().map(bezier_to_points).collect();
to_js_value(bezier_points)
}
pub fn curvature(&self, t: f64) -> f64 {
self.0.curvature(t)
/// The wrapped return type is `Vec<CircleSector>`.
pub fn arcs(&self, error: f64, max_iterations: usize, maximize_arcs: WasmMaximizeArcs) -> JsValue {
let strategy = convert_wasm_maximize_arcs(maximize_arcs);
let options = ArcsOptions { error, max_iterations, strategy };
let circle_sectors: Vec<CircleSector> = self
.0
.arcs(options)
.iter()
.map(|sector| CircleSector {
center: Point {
x: sector.center.x,
y: sector.center.y,
},
radius: sector.radius,
start_angle: sector.start_angle,
end_angle: sector.end_angle,
})
.collect();
to_js_value(circle_sectors)
}
}

View File

@ -14,9 +14,9 @@ pub const SCALABLE_CURVE_MAX_ENDPOINT_NORMAL_ANGLE: f64 = std::f64::consts::PI /
/// Default `t` value used for the `curve_through_points` functions.
pub const DEFAULT_T_VALUE: f64 = 0.5;
/// Default LUT step size in `compute_lookup_table` function.
pub const DEFAULT_LUT_STEP_SIZE: i32 = 10;
pub const DEFAULT_LUT_STEP_SIZE: usize = 10;
/// Default number of subdivisions used in `length` calculation.
pub const DEFAULT_LENGTH_SUBDIVISIONS: i32 = 1000;
pub const DEFAULT_LENGTH_SUBDIVISIONS: usize = 1000;
/// Default step size for `reduce` function.
pub const DEFAULT_REDUCE_STEP_SIZE: f64 = 0.01;

View File

@ -1,13 +1,16 @@
//! Bezier-rs: A Bezier Math Library for Rust
mod consts;
mod structs;
pub mod subpath;
mod utils;
use consts::*;
pub use structs::*;
pub use subpath::*;
use glam::{DMat2, DVec2};
use std::f64::consts::PI;
use std::fmt::{Debug, Formatter, Result};
use std::ops::Range;
@ -29,30 +32,6 @@ enum BezierHandles {
},
}
/// Struct to represent optional parameters that can be passed to the `project` function.
#[derive(Copy, Clone)]
pub struct ProjectionOptions {
/// Size of the lookup table for the initial passthrough. The default value is 20.
pub lut_size: i32,
/// Difference used between floating point numbers to be considered as equal. The default value is `0.0001`
pub convergence_epsilon: f64,
/// Controls the number of iterations needed to consider that minimum distance to have converged. The default value is 3.
pub convergence_limit: i32,
/// Controls the maximum total number of iterations to be used. The default value is 10.
pub iteration_limit: i32,
}
impl Default for ProjectionOptions {
fn default() -> Self {
ProjectionOptions {
lut_size: 20,
convergence_epsilon: 1e-4,
convergence_limit: 3,
iteration_limit: 10,
}
}
}
/// Representation of a bezier curve with 2D points.
#[derive(Copy, Clone, PartialEq)]
pub struct Bezier {
@ -317,16 +296,16 @@ impl Bezier {
// Basis code based off of pseudocode found here: <https://pomax.github.io/bezierinfo/#explanation>.
let t_squared = t * t;
let one_minus_t = 1.0 - t;
let one_minus_t = 1. - t;
let squared_one_minus_t = one_minus_t * one_minus_t;
match self.handles {
BezierHandles::Linear => self.start.lerp(self.end, t),
BezierHandles::Quadratic { handle } => squared_one_minus_t * self.start + 2.0 * one_minus_t * t * handle + t_squared * self.end,
BezierHandles::Quadratic { handle } => squared_one_minus_t * self.start + 2. * one_minus_t * t * handle + t_squared * self.end,
BezierHandles::Cubic { handle_start, handle_end } => {
let t_cubed = t_squared * t;
let cubed_one_minus_t = squared_one_minus_t * one_minus_t;
cubed_one_minus_t * self.start + 3.0 * squared_one_minus_t * t * handle_start + 3.0 * one_minus_t * t_squared * handle_end + t_cubed * self.end
cubed_one_minus_t * self.start + 3. * squared_one_minus_t * t * handle_start + 3. * one_minus_t * t_squared * handle_end + t_cubed * self.end
}
}
}
@ -334,19 +313,19 @@ impl Bezier {
/// Calculate the point on the curve based on the `t`-value provided.
/// Expects `t` to be within the inclusive range `[0, 1]`.
pub fn evaluate(&self, t: f64) -> DVec2 {
assert!((0.0..=1.0).contains(&t));
assert!((0.0..=1.).contains(&t));
self.unrestricted_evaluate(t)
}
/// 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> {
pub fn compute_lookup_table(&self, steps: Option<usize>) -> Vec<DVec2> {
let steps_unwrapped = steps.unwrap_or(DEFAULT_LUT_STEP_SIZE);
let ratio: f64 = 1.0 / (steps_unwrapped as f64);
let mut steps_array = Vec::with_capacity((steps_unwrapped + 1) as usize);
let ratio: f64 = 1. / (steps_unwrapped as f64);
let mut steps_array = Vec::with_capacity(steps_unwrapped + 1);
for t in 0..steps_unwrapped + 1 {
steps_array.push(self.evaluate(f64::from(t) * ratio))
steps_array.push(self.evaluate(f64::from(t as i32) * ratio))
}
steps_array
@ -354,7 +333,7 @@ impl Bezier {
/// Return an approximation of the length of the bezier curve.
/// - `num_subdivisions` - Number of subdivisions used to approximate the curve. The default value is 1000.
pub fn length(&self, num_subdivisions: Option<i32>) -> f64 {
pub fn length(&self, num_subdivisions: Option<usize>) -> f64 {
match self.handles {
BezierHandles::Linear => self.start.distance(self.end),
_ => {
@ -363,7 +342,7 @@ impl Bezier {
// We will use an approximate approach where we split the curve into many subdivisions
// and calculate the euclidean distance between the two endpoints of the subdivision
let lookup_table = self.compute_lookup_table(Some(num_subdivisions.unwrap_or(DEFAULT_LENGTH_SUBDIVISIONS)));
let mut approx_curve_length = 0.0;
let mut approx_curve_length = 0.;
let mut previous_point = lookup_table[0];
// Calculate approximate distance between subdivision
for current_point in lookup_table.iter().skip(1) {
@ -499,8 +478,10 @@ impl Bezier {
let (minimum_position, minimum_distance) = utils::get_closest_point_in_lut(&lut, point);
// Get the t values to the left and right of the closest result in the lookup table
let mut left_t = (0.max(minimum_position - 1) as f64) / lut_size as f64;
let mut right_t = (lut_size.min(minimum_position + 1)) as f64 / lut_size as f64;
let lut_size_f64 = lut_size as f64;
let minimum_position_f64 = minimum_position as f64;
let mut left_t = (minimum_position_f64 - 1.).max(0.) / lut_size_f64;
let mut right_t = (minimum_position_f64 + 1.).min(lut_size_f64) / lut_size_f64;
// Perform a finer search by finding closest t from 5 points between [left_t, right_t] inclusive
// Choose new left_t and right_t for a smaller range around the closest t and repeat the process
@ -518,16 +499,16 @@ impl Bezier {
// Store calculated distances to minimize unnecessary recomputations
let mut distances: [f64; NUM_DISTANCES] = [
point.distance(lut[0.max(minimum_position - 1) as usize]),
point.distance(lut[(minimum_position as i64 - 1).max(0) as usize]),
0.,
0.,
0.,
point.distance(lut[lut_size.min(minimum_position + 1) as usize]),
point.distance(lut[lut_size.min(minimum_position + 1)]),
];
while left_t <= right_t && convergence_count < convergence_limit && iteration_count < iteration_limit {
previous_distance = new_minimum_distance;
let step = (right_t - left_t) / ((NUM_DISTANCES - 1) as f64);
let step = (right_t - left_t) / (NUM_DISTANCES as f64 - 1.);
let mut iterator_t = left_t;
let mut target_index = 0;
// Iterate through first 4 points and will handle the right most point later
@ -839,6 +820,15 @@ impl Bezier {
endpoint_normal_angle < SCALABLE_CURVE_MAX_ENDPOINT_NORMAL_ANGLE
}
/// Add the bezier endpoints if not already present, and combine and sort the dimensional extrema.
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
}
/// Returns a tuple of the scalable subcurves and the corresponding `t` values that were used to split the curve.
/// This function may introduce gaps if subsections of the curve are not reducible.
/// The function takes the following parameter:
@ -852,10 +842,7 @@ impl Bezier {
let step_size = step_size.unwrap_or(DEFAULT_REDUCE_STEP_SIZE);
let mut extrema: Vec<f64> = 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());
let extrema = self.get_extrema_t_list();
// Split each subcurve such that each resulting segment is scalable.
let mut result_beziers: Vec<Bezier> = Vec::new();
@ -923,6 +910,153 @@ impl Bezier {
self.reduced_curves_and_t_values(step_size).0
}
/// Approximate a bezier curve with circular arcs.
/// The algorithm can be customized using the [ArcsOptions] structure.
pub fn arcs(&self, arcs_options: ArcsOptions) -> Vec<CircleArc> {
let ArcsOptions {
strategy: maximize_arcs,
error,
max_iterations,
} = arcs_options;
match maximize_arcs {
ArcStrategy::Automatic => {
let (auto_arcs, final_low_t) = self.approximate_curve_with_arcs(0., 1., error, max_iterations, true);
let arc_approximations = self.split(final_low_t)[1].arcs(ArcsOptions {
strategy: ArcStrategy::FavorCorrectness,
error,
max_iterations,
});
if final_low_t != 1. {
[auto_arcs, arc_approximations].concat()
} else {
auto_arcs
}
}
ArcStrategy::FavorLargerArcs => self.approximate_curve_with_arcs(0., 1., error, max_iterations, false).0,
ArcStrategy::FavorCorrectness => self
.get_extrema_t_list()
.windows(2)
.flat_map(|t_pair| self.approximate_curve_with_arcs(t_pair[0], t_pair[1], error, max_iterations, false).0)
.collect::<Vec<CircleArc>>(),
}
}
/// Implements an algorithm that approximates a bezier curve with circular arcs.
/// This algorithm uses a method akin to binary search to find an arc that approximates a maximal segment of the curve.
/// Once a maximal arc has been found for a sub-segment of the curve, the algorithm continues by starting again at the end of the previous approximation.
/// More details can be found in the [Approximating a Bezier curve with circular arcs](https://pomax.github.io/bezierinfo/#arcapproximation) section of Pomax's bezier curve primer.
/// A caveat with this algorithm is that it is possible to find erroneous approximations in cases such as in a very narrow `U`.
/// - `stop_when_invalid`: Used to determine whether the algorithm should terminate early if erroneous approximations are encountered.
///
/// Returns a tuple where the first element is the list of circular arcs and the second is the `t` value where the next segment should start from.
/// The second value will be `1.` except for when `stop_when_invalid` is true and an invalid approximation is encountered.
fn approximate_curve_with_arcs(&self, local_low: f64, local_high: f64, error: f64, max_iterations: usize, stop_when_invalid: bool) -> (Vec<CircleArc>, f64) {
let mut low = local_low;
let mut middle = (local_low + local_high) / 2.;
let mut high = local_high;
let mut previous_high = local_high;
let mut iterations = 0;
let mut previous_arc = CircleArc::default();
let mut was_previous_good = false;
let mut arcs = Vec::new();
// Outer loop to iterate over the curve
while low < local_high {
// Inner loop to find the next maximal segment of the curve that can be approximated with a circular arc
while iterations <= max_iterations {
iterations += 1;
let p1 = self.evaluate(low);
let p2 = self.evaluate(middle);
let p3 = self.evaluate(high);
let wrapped_center = utils::compute_circle_center_from_points(p1, p2, p3);
// If the segment is linear, move on to next segment
if wrapped_center.is_none() {
previous_high = high;
low = high;
high = 1.;
middle = (low + high) / 2.;
was_previous_good = false;
break;
}
let center = wrapped_center.unwrap();
let radius = center.distance(p1);
let angle_p1 = DVec2::new(1., 0.).angle_between(p1 - center);
let angle_p2 = DVec2::new(1., 0.).angle_between(p2 - center);
let angle_p3 = DVec2::new(1., 0.).angle_between(p3 - center);
let mut start_angle = angle_p1;
let mut end_angle = angle_p3;
// Adjust start and end angles of the arc to ensure that it travels in the counter-clockwise direction
if angle_p1 < angle_p3 {
if angle_p2 < angle_p1 || angle_p3 < angle_p2 {
std::mem::swap(&mut start_angle, &mut end_angle);
}
} else if angle_p2 < angle_p1 && angle_p3 < angle_p2 {
std::mem::swap(&mut start_angle, &mut end_angle);
}
let new_arc = CircleArc {
center,
radius,
start_angle,
end_angle,
};
// Use points in between low, middle, and high to evaluate how well the arc approximates the curve
let e1 = self.evaluate((low + middle) / 2.);
let e2 = self.evaluate((middle + high) / 2.);
// Iterate until we find the largest good approximation such that the next iteration is not a good approximation with an arc
if utils::f64_compare(radius, e1.distance(center), error) && utils::f64_compare(radius, e2.distance(center), error) {
// Check if the good approximation is actually valid: the sector angle cannot be larger than 180 degrees (PI radians)
let mut sector_angle = end_angle - start_angle;
if sector_angle < 0. {
sector_angle += 2. * PI;
}
if stop_when_invalid && sector_angle > PI {
return (arcs, low);
}
if high == local_high {
// Found the final arc approximation
arcs.push(new_arc);
low = high;
break;
}
// If the approximation is good, expand the segment by half to try finding a larger good approximation
previous_high = high;
high = (high + (high - low) / 2.).min(local_high);
middle = (low + high) / 2.;
previous_arc = new_arc;
was_previous_good = true;
} else if was_previous_good {
// If the previous approximation was good and the current one is bad, then we use the previous good approximation
arcs.push(previous_arc);
// Continue searching for approximations for the rest of the curve
low = previous_high;
high = local_high;
middle = low + (high - low) / 2.;
was_previous_good = false;
break;
} else {
// If no good approximation has been seen yet, try again with half the segment
previous_high = high;
high = middle;
middle = low + (high - low) / 2.;
previous_arc = new_arc;
}
}
}
(arcs, low)
}
/// Return the min and max corners that represent the bounding box of the curve.
pub fn bounding_box(&self) -> [DVec2; 2] {
// Start by taking min/max of endpoints.
@ -1049,6 +1183,14 @@ mod tests {
.all(|(&a, b)| compare_vector_of_points(a.get_points().collect::<Vec<DVec2>>(), b.to_vec()))
}
// Compare circle arcs by allowing some maximum absolute difference between values to account for floating point errors
fn compare_arcs(arc1: CircleArc, arc2: CircleArc) -> bool {
compare_points(arc1.center, arc2.center)
&& utils::f64_compare(arc1.radius, arc1.radius, MAX_ABSOLUTE_DIFFERENCE)
&& utils::f64_compare(arc1.start_angle, arc2.start_angle, MAX_ABSOLUTE_DIFFERENCE)
&& utils::f64_compare(arc1.end_angle, arc2.end_angle, MAX_ABSOLUTE_DIFFERENCE)
}
// Compare vectors of points with some maximum allowed absolute difference between the values
fn compare_vec_of_points(vec1: Vec<DVec2>, vec2: Vec<DVec2>, max_absolute_difference: f64) -> bool {
vec1.into_iter().zip(vec2).all(|(p1, p2)| p1.abs_diff_eq(p2, max_absolute_difference))
@ -1097,6 +1239,7 @@ mod tests {
let bezier2 = Bezier::from_quadratic_coordinates(0., 0., 0., 100., 100., 100.);
assert!(bezier2.evaluate(bezier2.project(DVec2::new(100., 0.), project_options)) == DVec2::new(0., 0.));
}
#[test]
fn test_intersect_line_segment_linear() {
let p1 = DVec2::new(30., 60.);
@ -1232,4 +1375,73 @@ mod tests {
.zip(helper_t_values.windows(2))
.all(|(curve, t_pair)| curve.abs_diff_eq(&bezier.trim(t_pair[0], t_pair[1]), MAX_ABSOLUTE_DIFFERENCE)))
}
#[test]
fn test_arcs_linear() {
let bezier = Bezier::from_linear_coordinates(30., 60., 140., 120.);
let linear_arcs = bezier.arcs(ArcsOptions::default());
assert!(linear_arcs.is_empty());
}
#[test]
fn test_arcs_quadratic() {
let bezier1 = Bezier::from_quadratic_coordinates(30., 30., 50., 50., 100., 100.);
assert!(bezier1.arcs(ArcsOptions::default()).is_empty());
let bezier2 = Bezier::from_quadratic_coordinates(50., 50., 85., 65., 100., 100.);
let actual_arcs = bezier2.arcs(ArcsOptions::default());
let expected_arc = CircleArc {
center: DVec2::new(15., 135.),
radius: 91.92388,
start_angle: -1.18019,
end_angle: -0.39061,
};
assert_eq!(actual_arcs.len(), 1);
assert!(compare_arcs(actual_arcs[0], expected_arc));
}
#[test]
fn test_arcs_cubic() {
let bezier = Bezier::from_cubic_coordinates(30., 30., 30., 80., 60., 80., 60., 140.);
let actual_arcs = bezier.arcs(ArcsOptions::default());
let expected_arcs = vec![
CircleArc {
center: DVec2::new(122.394877, 30.7777189),
radius: 92.39815,
start_angle: 2.5637146,
end_angle: -3.1331755,
},
CircleArc {
center: DVec2::new(-47.54881, 136.169378),
radius: 107.61701,
start_angle: -0.53556,
end_angle: 0.0356025,
},
];
assert_eq!(actual_arcs.len(), 2);
assert!(compare_arcs(actual_arcs[0], expected_arcs[0]));
assert!(compare_arcs(actual_arcs[1], expected_arcs[1]));
// Bezier that contains the erroneous case when maximizing arcs
let bezier2 = Bezier::from_cubic_coordinates(48., 176., 170., 10., 30., 90., 180., 160.);
let auto_arcs = bezier2.arcs(ArcsOptions::default());
let extrema_arcs = bezier2.arcs(ArcsOptions {
strategy: ArcStrategy::FavorCorrectness,
..ArcsOptions::default()
});
let maximal_arcs = bezier2.arcs(ArcsOptions {
strategy: ArcStrategy::FavorLargerArcs,
..ArcsOptions::default()
});
// Resulting automatic arcs match the maximal results until the bad arc (in this case, only index 0 should match)
assert_eq!(auto_arcs[0], maximal_arcs[0]);
// Check that the first result from MaximizeArcs::Automatic should not equal the first results from MaximizeArcs::Off
assert_ne!(auto_arcs[0], extrema_arcs[0]);
// The remaining results (index 2 onwards) should match the results where MaximizeArcs::Off from the next extrema point onwards (after index 2).
assert!(auto_arcs.iter().skip(2).zip(extrema_arcs.iter().skip(2)).all(|(arc1, arc2)| compare_arcs(*arc1, *arc2)));
}
}

View File

@ -0,0 +1,95 @@
use glam::DVec2;
use std::fmt::{Debug, Formatter, Result};
/// Struct to represent optional parameters that can be passed to the `project` function.
#[derive(Copy, Clone)]
pub struct ProjectionOptions {
/// Size of the lookup table for the initial passthrough. The default value is `20`.
pub lut_size: usize,
/// Difference used between floating point numbers to be considered as equal. The default value is `0.0001`
pub convergence_epsilon: f64,
/// Controls the number of iterations needed to consider that minimum distance to have converged. The default value is `3`.
pub convergence_limit: usize,
/// Controls the maximum total number of iterations to be used. The default value is `10`.
pub iteration_limit: usize,
}
impl Default for ProjectionOptions {
fn default() -> Self {
ProjectionOptions {
lut_size: 20,
convergence_epsilon: 1e-4,
convergence_limit: 3,
iteration_limit: 10,
}
}
}
/// Struct used to represent the different strategies for generating arc approximations.
#[derive(Copy, Clone)]
pub enum ArcStrategy {
/// Start with the greedy strategy of maximizing arc approximations and automatically switch to the divide-and-conquer when the greedy approximations no longer fall within the error bound.
Automatic,
/// Use the greedy strategy to maximize approximated arcs, despite potentially erroneous arcs.
FavorLargerArcs,
/// Use the divide-and-conquer strategy that prioritizes correctness over maximal arcs.
FavorCorrectness,
}
/// Struct to represent optional parameters that can be passed to the `arcs` function.
#[derive(Copy, Clone)]
pub struct ArcsOptions {
/// Determines how the approximated arcs are computed.
/// When maximizing the arcs, the algorithm may return incorrect arcs when the curve contains any small loops or segments that look like a very thin "U".
/// The enum options behave as follows:
/// - `Automatic`: Maximize arcs until an erroneous approximation is found. Compute the arcs of the rest of the curve by first splitting on extremas to ensure no more erroneous cases are encountered.
/// - `FavorLargerArcs`: Maximize arcs using the original algorithm from the [Approximating a Bezier curve with circular arcs](https://pomax.github.io/bezierinfo/#arcapproximation) section of Pomax's bezier curve primer. Erroneous arcs are possible.
/// - `FavorCorrectness`: Prioritize correctness by first spliting the curve by its extremas and determine the arc approximation of each segment instead.
///
/// The default value is `Automatic`.
pub strategy: ArcStrategy,
/// The error used for approximating the arc's fit. The default is `0.5`.
pub error: f64,
/// The maximum number of segment iterations used as attempts for arc approximations. The default is `100`.
pub max_iterations: usize,
}
impl Default for ArcsOptions {
fn default() -> Self {
ArcsOptions {
strategy: ArcStrategy::Automatic,
error: 0.5,
max_iterations: 100,
}
}
}
/// Struct to represent the circular arc approximation used in the `arcs` bezier function.
#[derive(Copy, Clone, PartialEq)]
pub struct CircleArc {
/// The center point of the circle.
pub center: DVec2,
/// The radius of the circle.
pub radius: f64,
/// The start angle of the circle sector in rad.
pub start_angle: f64,
/// The end angle of the circle sector in rad.
pub end_angle: f64,
}
impl Debug for CircleArc {
fn fmt(&self, f: &mut Formatter<'_>) -> Result {
write!(f, "Center: {}, radius: {}, start to end angles: {} to {}", self.center, self.radius, self.start_angle, self.end_angle)
}
}
impl Default for CircleArc {
fn default() -> Self {
CircleArc {
center: DVec2::ZERO,
radius: 0.,
start_angle: 0.,
end_angle: 0.,
}
}
}

View File

@ -4,7 +4,7 @@ use super::*;
impl Subpath {
/// Return the sum of the approximation of the length of each `Bezier` curve along the `Subpath`.
/// - `num_subdivisions` - Number of subdivisions used to approximate the curve. The default value is `1000`.
pub fn length(&self, num_subdivisions: Option<i32>) -> f64 {
pub fn length(&self, num_subdivisions: Option<usize>) -> f64 {
self.iter().fold(0., |accumulator, bezier| accumulator + bezier.length(num_subdivisions))
}
}

View File

@ -1,6 +1,6 @@
use crate::consts::{MAX_ABSOLUTE_DIFFERENCE, STRICT_MAX_ABSOLUTE_DIFFERENCE};
use glam::{BVec2, DVec2};
use glam::{BVec2, DMat2, DVec2};
use std::f64::consts::PI;
/// Helper to perform the computation of a and c, where b is the provided point on the curve.
@ -34,10 +34,10 @@ pub fn compute_abc_for_cubic_through_points(start_point: DVec2, point_on_curve:
}
/// Return the index and the value of the closest point in the LUT compared to the provided point.
pub fn get_closest_point_in_lut(lut: &[DVec2], point: DVec2) -> (i32, f64) {
pub fn get_closest_point_in_lut(lut: &[DVec2], point: DVec2) -> (usize, f64) {
lut.iter()
.enumerate()
.map(|(i, p)| (i as i32, point.distance_squared(*p)))
.map(|(i, p)| (i, point.distance_squared(*p)))
.min_by(|x, y| (&(x.1)).partial_cmp(&(y.1)).unwrap())
.unwrap()
}
@ -181,6 +181,33 @@ pub fn line_intersection(point1: DVec2, point1_slope_vector: DVec2, point2: DVec
}
}
/// Check if 3 points are collinear.
pub fn are_points_collinear(p1: DVec2, p2: DVec2, p3: DVec2) -> bool {
let matrix = DMat2::from_cols(p1 - p2, p2 - p3);
f64_compare(matrix.determinant() / 2., 0., MAX_ABSOLUTE_DIFFERENCE)
}
/// Compute the center of the circle that passes through all three provided points. The provided points cannot be collinear.
pub fn compute_circle_center_from_points(p1: DVec2, p2: DVec2, p3: DVec2) -> Option<DVec2> {
if are_points_collinear(p1, p2, p3) {
return None;
}
let midpoint_a = p1.lerp(p2, 0.5);
let midpoint_b = p2.lerp(p3, 0.5);
let midpoint_c = p3.lerp(p1, 0.5);
let tangent_a = (p1 - p2).perp();
let tangent_b = (p2 - p3).perp();
let tangent_c = (p3 - p1).perp();
let intersect_a_b = line_intersection(midpoint_a, tangent_a, midpoint_b, tangent_b);
let intersect_b_c = line_intersection(midpoint_b, tangent_b, midpoint_c, tangent_c);
let intersect_c_a = line_intersection(midpoint_c, tangent_c, midpoint_a, tangent_a);
Some((intersect_a_b + intersect_b_c + intersect_c_a) / 3.)
}
/// Compare two `f64` numbers with a provided max absolute value difference.
pub fn f64_compare(f1: f64, f2: f64, max_abs_diff: f64) -> bool {
(f1 - f2).abs() < max_abs_diff
@ -287,4 +314,20 @@ mod tests {
let end_direction2 = DVec2::new(1., -1.);
assert!(line_intersection(start2, start_direction2, end2, end_direction2) == DVec2::new(4., 4.));
}
#[test]
fn test_are_points_collinear() {
assert!(are_points_collinear(DVec2::new(2., 4.), DVec2::new(6., 8.), DVec2::new(4., 6.)));
assert!(!are_points_collinear(DVec2::new(1., 4.), DVec2::new(6., 8.), DVec2::new(4., 6.)));
}
#[test]
fn test_compute_circle_center_from_points() {
// 3/4 of unit circle
let center1 = compute_circle_center_from_points(DVec2::new(0., 1.), DVec2::new(-1., 0.), DVec2::new(1., 0.));
assert_eq!(center1.unwrap(), DVec2::new(0., 0.));
// 1/4 of unit circle
let center2 = compute_circle_center_from_points(DVec2::new(-1., 0.), DVec2::new(0., 1.), DVec2::new(1., 0.));
assert_eq!(center2.unwrap(), DVec2::new(0., 0.));
}
}

View File

@ -2,7 +2,7 @@ use serde::{Deserialize, Deserializer, Serialize, Serializer};
use std::iter::FromIterator;
/// Necessary because serde can't serialize hashmaps when the keys don't implement display.
pub fn serialize<'a, T, K, V, S>(target: T, ser: S) -> Result<S::Ok, S::Error>
pub fn serialize<'a, T, K, V, S>(target: T, serializer: S) -> Result<S::Ok, S::Error>
where
S: Serializer,
T: IntoIterator<Item = (&'a K, &'a V)>,
@ -10,16 +10,16 @@ where
V: Serialize + 'a,
{
let container: Vec<_> = target.into_iter().collect();
serde::Serialize::serialize(&container, ser)
serde::Serialize::serialize(&container, serializer)
}
pub fn deserialize<'de, T, K, V, D>(des: D) -> Result<T, D::Error>
pub fn deserialize<'de, T, K, V, D>(deserializer: D) -> Result<T, D::Error>
where
D: Deserializer<'de>,
T: FromIterator<(K, V)>,
K: Deserialize<'de>,
V: Deserialize<'de>,
{
let container: Vec<_> = serde::Deserialize::deserialize(des)?;
let container: Vec<_> = serde::Deserialize::deserialize(deserializer)?;
Ok(T::from_iter(container.into_iter()))
}

View File

@ -1,3 +0,0 @@
{
"extends": "./bezier-rs/docs/interactive-docs/tsconfig.json"
}