From b01f76f097d3a873b85e039c1a01e88dffe3212f Mon Sep 17 00:00:00 2001 From: Rob Nadal Date: Wed, 6 Jul 2022 14:20:28 -0400 Subject: [PATCH] Implement Bezier curve reduce function (#711) * fixed extrema bug and added reduce impl Co-authored-by: Rob Nadal Co-authored-by: Linda Zheng Co-authored-by: Hannah Li * Stylistic changes related to reduce * Fixed reduce splitting bug causing panic * Added shortcuts and simplified reduce * Stylistic changes per review * address comments * Removed color gradient function and added consts * Tweaks * Change colors faster * Don't drop on mouseout Co-authored-by: Thomas Cheng Co-authored-by: Rob Nadal Co-authored-by: Linda Zheng Co-authored-by: Hannah Li Co-authored-by: Keavon Chambers --- bezier-rs/docs/interactive-docs/src/App.vue | 10 +++ .../src/components/BezierDrawing.ts | 12 ++- .../interactive-docs/src/utils/drawing.ts | 15 ++-- .../docs/interactive-docs/src/utils/types.ts | 1 + .../docs/interactive-docs/wasm/src/lib.rs | 26 ++++-- bezier-rs/lib/src/consts.rs | 6 ++ bezier-rs/lib/src/lib.rs | 90 ++++++++++++++++++- 7 files changed, 142 insertions(+), 18 deletions(-) diff --git a/bezier-rs/docs/interactive-docs/src/App.vue b/bezier-rs/docs/interactive-docs/src/App.vue index 1fc5f836..a5f738ad 100644 --- a/bezier-rs/docs/interactive-docs/src/App.vue +++ b/bezier-rs/docs/interactive-docs/src/App.vue @@ -266,6 +266,16 @@ export default defineComponent({ }); }, }, + { + name: "Reduce", + callback: (canvas: HTMLCanvasElement, bezier: WasmBezierInstance): void => { + const context = getContextFromCanvas(canvas); + const curves: Point[][] = JSON.parse(bezier.reduce()); + curves.forEach((points, index) => { + drawBezier(context, points, null, { curveStrokeColor: `hsl(${40 * index}, 100%, 50%)`, radius: 3.5, drawHandles: false }); + }); + }, + }, ], }; }, diff --git a/bezier-rs/docs/interactive-docs/src/components/BezierDrawing.ts b/bezier-rs/docs/interactive-docs/src/components/BezierDrawing.ts index 6427b77e..8e17fdcb 100644 --- a/bezier-rs/docs/interactive-docs/src/components/BezierDrawing.ts +++ b/bezier-rs/docs/interactive-docs/src/components/BezierDrawing.ts @@ -59,10 +59,9 @@ class BezierDrawing { this.dragIndex = null; // Index of the point being moved - this.canvas.addEventListener("mousedown", this.mouseDownHandler.bind(this)); - this.canvas.addEventListener("mousemove", this.mouseMoveHandler.bind(this)); - this.canvas.addEventListener("mouseup", this.deselectPointHandler.bind(this)); - this.canvas.addEventListener("mouseout", this.deselectPointHandler.bind(this)); + this.canvas.addEventListener("mousedown", (e) => this.mouseDownHandler(e)); + this.canvas.addEventListener("mousemove", (e) => this.mouseMoveHandler(e)); + this.canvas.addEventListener("mouseup", () => this.deselectPointHandler()); this.updateBezier(); } @@ -71,6 +70,11 @@ class BezierDrawing { } mouseMoveHandler(evt: MouseEvent): void { + if (evt.buttons === 0) { + this.deselectPointHandler(); + return; + } + const mx = evt.offsetX; const my = evt.offsetY; diff --git a/bezier-rs/docs/interactive-docs/src/utils/drawing.ts b/bezier-rs/docs/interactive-docs/src/utils/drawing.ts index 3237a4d0..40a9e414 100644 --- a/bezier-rs/docs/interactive-docs/src/utils/drawing.ts +++ b/bezier-rs/docs/interactive-docs/src/utils/drawing.ts @@ -70,9 +70,10 @@ export const drawBezier = (ctx: CanvasRenderingContext2D, points: Point[], dragI handleStrokeColor: COLORS.INTERACTIVE.STROKE_1, handleLineStrokeColor: COLORS.INTERACTIVE.STROKE_1, radius: DEFAULT_ENDPOINT_RADIUS, + drawHandles: true, ...bezierStyleConfig, }; - // if the handle or handle line colors are not specified, use the same colour as the rest of the curve + // If the handle or handle line colors are not specified, use the same color as the rest of the curve if (bezierStyleConfig.curveStrokeColor) { if (!bezierStyleConfig.handleStrokeColor) { styleConfig.handleStrokeColor = bezierStyleConfig.curveStrokeColor; @@ -112,11 +113,15 @@ export const drawBezier = (ctx: CanvasRenderingContext2D, points: Point[], dragI } ctx.stroke(); - drawLine(ctx, start, handleStart, styleConfig.handleLineStrokeColor); - drawLine(ctx, end, handleEnd, styleConfig.handleLineStrokeColor); + if (styleConfig.drawHandles) { + drawLine(ctx, start, handleStart, styleConfig.handleLineStrokeColor); + drawLine(ctx, end, handleEnd, styleConfig.handleLineStrokeColor); + } points.forEach((point, index) => { - const strokeColor = isIndexFirstOrLast(index, points.length) ? styleConfig.curveStrokeColor : styleConfig.handleStrokeColor; - drawPoint(ctx, point, getPointSizeByIndex(index, points.length, styleConfig.radius), index === dragIndex ? COLORS.INTERACTIVE.SELECTED : strokeColor); + if (styleConfig.drawHandles || isIndexFirstOrLast(index, points.length)) { + const strokeColor = isIndexFirstOrLast(index, points.length) ? styleConfig.curveStrokeColor : styleConfig.handleStrokeColor; + drawPoint(ctx, point, getPointSizeByIndex(index, points.length, styleConfig.radius), index === dragIndex ? COLORS.INTERACTIVE.SELECTED : strokeColor); + } }); }; diff --git a/bezier-rs/docs/interactive-docs/src/utils/types.ts b/bezier-rs/docs/interactive-docs/src/utils/types.ts index def7320e..780d9f6f 100644 --- a/bezier-rs/docs/interactive-docs/src/utils/types.ts +++ b/bezier-rs/docs/interactive-docs/src/utils/types.ts @@ -33,4 +33,5 @@ export type BezierStyleConfig = { handleStrokeColor: string; handleLineStrokeColor: string; radius: number; + drawHandles: boolean; }; diff --git a/bezier-rs/docs/interactive-docs/wasm/src/lib.rs b/bezier-rs/docs/interactive-docs/wasm/src/lib.rs index 8f492f55..5fa56d9f 100644 --- a/bezier-rs/docs/interactive-docs/wasm/src/lib.rs +++ b/bezier-rs/docs/interactive-docs/wasm/src/lib.rs @@ -15,10 +15,20 @@ struct Point { pub struct WasmBezier(Bezier); /// Convert a `DVec2` into a `JsValue`. -pub fn vec_to_point(p: &DVec2) -> 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 bezier to a list of points. +fn bezier_to_points(bezier: Bezier) -> Vec { + bezier.get_points().iter().flatten().map(|point| Point { x: point.x, y: point.y }).collect() +} + +/// Serialize some data and then convert it to a JsValue. +fn to_js_value(data: T) -> JsValue { + JsValue::from_serde(&serde_json::to_string(&data).unwrap()).unwrap() +} + #[wasm_bindgen] impl WasmBezier { /// Expect js_points to be a list of 3 pairs. @@ -88,11 +98,8 @@ impl WasmBezier { } pub fn split(&self, t: f64) -> JsValue { - let bezier_points: [Vec; 2] = self - .0 - .split(t) - .map(|bezier| bezier.get_points().iter().flatten().map(|point| Point { x: point.x, y: point.y }).collect()); - JsValue::from_serde(&serde_json::to_string(&bezier_points).unwrap()).unwrap() + let bezier_points: [Vec; 2] = self.0.split(t).map(bezier_to_points); + to_js_value(bezier_points) } pub fn trim(&self, t1: f64, t2: f64) -> WasmBezier { @@ -105,7 +112,7 @@ impl WasmBezier { pub fn local_extrema(&self) -> JsValue { let local_extrema = self.0.local_extrema(); - JsValue::from_serde(&serde_json::to_string(&local_extrema).unwrap()).unwrap() + to_js_value(local_extrema) } pub fn rotate(&self, angle: f64) -> WasmBezier { @@ -116,4 +123,9 @@ impl WasmBezier { let line: [DVec2; 2] = js_points.into_serde().unwrap(); self.0.intersect_line_segment(line).iter().map(|&p| vec_to_point(&p)).collect::>() } + + pub fn reduce(&self) -> JsValue { + let bezier_points: Vec> = self.0.reduce(None).into_iter().map(bezier_to_points).collect(); + to_js_value(bezier_points) + } } diff --git a/bezier-rs/lib/src/consts.rs b/bezier-rs/lib/src/consts.rs index 51dd92c3..f424949e 100644 --- a/bezier-rs/lib/src/consts.rs +++ b/bezier-rs/lib/src/consts.rs @@ -1,3 +1,9 @@ +/// Implementation constants +pub const SCALABLE_CURVE_MAX_ENDPOINT_NORMAL_ANGLE: f64 = std::f64::consts::PI / 3.; + +/// Method argument defaults +pub const REDUCE_STEP_SIZE_DEFAULT: f64 = 0.01; + /// Default `t` value used for the `curve_through_points` functions pub const DEFAULT_T_VALUE: f64 = 0.5; diff --git a/bezier-rs/lib/src/lib.rs b/bezier-rs/lib/src/lib.rs index 7faa0f82..c32c6836 100644 --- a/bezier-rs/lib/src/lib.rs +++ b/bezier-rs/lib/src/lib.rs @@ -1,12 +1,12 @@ //! Bezier-rs: A Bezier Math Library for Rust mod consts; -use consts::*; mod utils; +use consts::*; use glam::{DMat2, DVec2}; -/// Representation of the handle point(s) in a bezier curve. +/// Representation of the handle point(s) in a bezier segment. #[derive(Copy, Clone)] enum BezierHandles { /// Handles for a quadratic curve. @@ -568,6 +568,92 @@ impl Bezier { .filter(|&point| utils::dvec2_approximately_in_range(point, min, max, MAX_ABSOLUTE_DIFFERENCE).all()) .collect::>() } + + /// Determine if it is possible to scale the given curve, using the following conditions: + /// 1. All the control points are located on a single side of the curve. + /// 2. The on-curve point for `t = 0.5` must occur roughly in the center of the polygon defined by the curve's endpoint normals. + /// See [the offset section](https://pomax.github.io/bezierinfo/#offsetting) of Pomax's bezier curve primer for more details. + fn is_scalable(&self) -> bool { + // Verify all the control points are located on a single side of the curve. + if let BezierHandles::Cubic { handle_start, handle_end } = self.handles { + let angle_1 = (self.end - self.start).angle_between(handle_start - self.start); + let angle_2 = (self.end - self.start).angle_between(handle_end - self.start); + if (angle_1 > 0. && angle_2 < 0.) || (angle_1 < 0. && angle_2 > 0.) { + return false; + } + } + // 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(0.); + let normal_1 = self.normal(1.); + let endpoint_normal_angle = (normal_0.x * normal_1.x + normal_0.y * normal_1.y).acos(); + endpoint_normal_angle < SCALABLE_CURVE_MAX_ENDPOINT_NORMAL_ANGLE + } + + /// Split the curve into a number of scalable subcurves. This function may introduce gaps if subsections of the curve are not reducible. + /// 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 fn reduce(&self, step_size: Option) -> Vec { + let step_size = step_size.unwrap_or(REDUCE_STEP_SIZE_DEFAULT); + + let mut extrema: Vec = self.local_extrema().into_iter().flatten().collect::>(); + extrema.append(&mut vec![0., 1.]); + extrema.dedup(); + extrema.sort_by(|ex1, ex2| ex1.partial_cmp(ex2).unwrap()); + + // Split the curve on the extremas. Simplifies procedure for ensuring each curve can be scaled. + let mut subcurves = Vec::new(); + let mut t1: f64 = extrema[0]; + for t2 in extrema.iter().skip(1) { + subcurves.push(self.trim(t1, *t2)); + t1 = *t2; + } + + // Split each subcurve such that each resulting segment is scalable. + let mut result: Vec = Vec::new(); + subcurves.iter().for_each(|&subcurve| { + // Perform no processing on the subcurve if it's already scalable. + if subcurve.is_scalable() { + result.push(subcurve); + return; + } + // According to , it is generally sufficient to split subcurves with no local extrema at `t = 0.5` to generate two scalable segments. + let [first_half, second_half] = subcurve.split(0.5); + if first_half.is_scalable() && second_half.is_scalable() { + result.push(first_half); + result.push(second_half); + return; + } + + // Greedily iterate across the subcurve at intervals of size `step_size` to break up the curve into maximally large segments + let mut segment: Bezier; + let mut t1 = 0.; + let mut t2 = step_size; + while t2 <= 1. + step_size { + segment = subcurve.trim(t1, f64::min(t2, 1.)); + if !segment.is_scalable() { + t2 -= step_size; + + // 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 { + segment = subcurve.trim(t1, t2); + result.push(segment); + } + t1 = t2; + } + t2 += step_size; + } + // Collect final remainder of the curve. + if t1 < 1. { + segment = subcurve.trim(t1, 1.); + if segment.is_scalable() { + result.push(segment); + } + } + }); + result + } } #[cfg(test)]