Bezier split and trim implementation (#680)

* Added split implementation and UI

Co-authored-by: Thomas Cheng <Androxium@users.noreply.github.com>

* Added bezier split impl

* Adjust struct traits

* Implement trim and adjust FE code to handle multiple sliders per feature

Co-authored-by: Thomas Cheng <contact.chengthomas@gmail.com>
Co-authored-by: Rob Nadal <robnadal44@gmail.com>

* Fix edge case in trim and add rust doc comments

* Stylistic changes per review

* More stylistic changes per review

* replaced last explicit color usages

Co-authored-by: Robert Nadal <Robnadal44@gmail.com>
Co-authored-by: Thomas Cheng <Androxium@users.noreply.github.com>
Co-authored-by: Thomas Cheng <contact.chengthomas@gmail.com>
This commit is contained in:
Hannah Li 2022-06-27 19:33:56 -04:00 committed by Keavon Chambers
parent 7f15cac5e2
commit 4eaffd0e5a
8 changed files with 224 additions and 102 deletions

View file

@ -2,7 +2,7 @@
<div class="App">
<h1>Bezier-rs Interactive Documentation</h1>
<p>This is the interactive documentation for the <b>bezier-rs</b> library. Click and drag on the endpoints of the example curves to visualize the various Bezier utilities and functions.</p>
<div v-for="feature in features" :key="feature.id">
<div v-for="(feature, index) in features" :key="index">
<ExamplePane :template="feature.template" :templateOptions="feature.templateOptions" :name="feature.name" :callback="feature.callback" />
</div>
<br />
@ -13,7 +13,7 @@
<script lang="ts">
import { defineComponent, markRaw } from "vue";
import { drawText, drawPoint, drawLine, getContextFromCanvas } from "@/utils/drawing";
import { drawText, drawPoint, drawBezier, drawLine, getContextFromCanvas, drawBezierHelper, COLORS } from "@/utils/drawing";
import { WasmBezierInstance } from "@/utils/types";
import ExamplePane from "@/components/ExamplePane.vue";
@ -51,57 +51,55 @@ export default defineComponent({
return {
features: [
{
id: 0,
name: "Constructor",
// eslint-disable-next-line
callback: (): void => {},
},
{
id: 2,
name: "Length",
callback: (canvas: HTMLCanvasElement, bezier: WasmBezierInstance): void => {
drawText(getContextFromCanvas(canvas), `Length: ${bezier.length().toFixed(2)}`, 5, canvas.height - 7);
},
},
{
id: 3,
name: "Compute",
callback: (canvas: HTMLCanvasElement, bezier: WasmBezierInstance, options: string): void => {
const point = JSON.parse(bezier.compute(parseFloat(options)));
drawPoint(getContextFromCanvas(canvas), point, 4, "Red");
callback: (canvas: HTMLCanvasElement, bezier: WasmBezierInstance, options: Record<string, number>): void => {
const point = JSON.parse(bezier.compute(options.t));
drawPoint(getContextFromCanvas(canvas), point, 4, COLORS.NON_INTERACTIVE.STROKE_1);
},
template: markRaw(SliderExample),
templateOptions: tSliderOptions,
templateOptions: { sliders: [tSliderOptions] },
},
{
id: 4,
name: "Lookup Table",
callback: (canvas: HTMLCanvasElement, bezier: WasmBezierInstance, options: string): void => {
const lookupPoints = bezier.compute_lookup_table(Number(options));
callback: (canvas: HTMLCanvasElement, bezier: WasmBezierInstance, options: Record<string, number>): void => {
const lookupPoints = bezier.compute_lookup_table(options.steps);
lookupPoints.forEach((serialisedPoint, index) => {
if (index !== 0 && index !== lookupPoints.length - 1) {
drawPoint(getContextFromCanvas(canvas), JSON.parse(serialisedPoint), 3, "Red");
drawPoint(getContextFromCanvas(canvas), JSON.parse(serialisedPoint), 3, COLORS.NON_INTERACTIVE.STROKE_1);
}
});
},
template: markRaw(SliderExample),
templateOptions: {
min: 2,
max: 15,
step: 1,
default: 5,
variable: "Steps",
sliders: [
{
min: 2,
max: 15,
step: 1,
default: 5,
variable: "steps",
},
],
},
},
{
id: 5,
name: "Derivative",
callback: (canvas: HTMLCanvasElement, bezier: WasmBezierInstance, options: string): void => {
const t = parseFloat(options);
callback: (canvas: HTMLCanvasElement, bezier: WasmBezierInstance, options: Record<string, number>): void => {
const context = getContextFromCanvas(canvas);
const intersection = JSON.parse(bezier.compute(t));
const derivative = JSON.parse(bezier.derivative(t));
const intersection = JSON.parse(bezier.compute(options.t));
const derivative = JSON.parse(bezier.derivative(options.t));
const curveFactor = bezier.get_points().length - 1;
const tangentStart = {
@ -113,23 +111,21 @@ export default defineComponent({
y: intersection.y + derivative.y / curveFactor,
};
drawLine(context, tangentStart, tangentEnd, "Red");
drawPoint(context, tangentStart, 3, "Red");
drawPoint(context, intersection, 3, "Red");
drawPoint(context, tangentEnd, 3, "Red");
drawLine(context, tangentStart, tangentEnd, COLORS.NON_INTERACTIVE.STROKE_1);
drawPoint(context, tangentStart, 3, COLORS.NON_INTERACTIVE.STROKE_1);
drawPoint(context, intersection, 3, COLORS.NON_INTERACTIVE.STROKE_1);
drawPoint(context, tangentEnd, 3, COLORS.NON_INTERACTIVE.STROKE_1);
},
template: markRaw(SliderExample),
templateOptions: tSliderOptions,
templateOptions: { sliders: [tSliderOptions] },
},
{
id: 6,
name: "Normal",
callback: (canvas: HTMLCanvasElement, bezier: WasmBezierInstance, options: string): void => {
const t = parseFloat(options);
callback: (canvas: HTMLCanvasElement, bezier: WasmBezierInstance, options: Record<string, number>): void => {
const context = getContextFromCanvas(canvas);
const intersection = JSON.parse(bezier.compute(t));
const normal = JSON.parse(bezier.normal(t));
const intersection = JSON.parse(bezier.compute(options.t));
const normal = JSON.parse(bezier.normal(options.t));
const normalStart = {
x: intersection.x - normal.x * 20,
@ -140,13 +136,52 @@ export default defineComponent({
y: intersection.y + normal.y * 20,
};
drawLine(context, normalStart, normalEnd, "Red");
drawPoint(context, normalStart, 3, "Red");
drawPoint(context, intersection, 3, "Red");
drawPoint(context, normalEnd, 3, "Red");
drawLine(context, normalStart, normalEnd, COLORS.NON_INTERACTIVE.STROKE_1);
drawPoint(context, normalStart, 3, COLORS.NON_INTERACTIVE.STROKE_1);
drawPoint(context, intersection, 3, COLORS.NON_INTERACTIVE.STROKE_1);
drawPoint(context, normalEnd, 3, COLORS.NON_INTERACTIVE.STROKE_1);
},
template: markRaw(SliderExample),
templateOptions: tSliderOptions,
templateOptions: { sliders: [tSliderOptions] },
},
{
name: "Split",
callback: (canvas: HTMLCanvasElement, bezier: WasmBezierInstance, options: Record<string, number>): void => {
const context = getContextFromCanvas(canvas);
const bezierPairPoints = JSON.parse(bezier.split(options.t));
drawBezier(context, bezierPairPoints[0], null, COLORS.NON_INTERACTIVE.STROKE_2, 3.5);
drawBezier(context, bezierPairPoints[1], null, COLORS.NON_INTERACTIVE.STROKE_1, 3.5);
},
template: markRaw(SliderExample),
templateOptions: { sliders: [tSliderOptions] },
},
{
name: "Trim",
callback: (canvas: HTMLCanvasElement, bezier: WasmBezierInstance, options: Record<string, number>): void => {
const context = getContextFromCanvas(canvas);
const trimmedBezier = bezier.trim(options.t1, options.t2);
drawBezierHelper(context, trimmedBezier, COLORS.NON_INTERACTIVE.STROKE_1, 3.5);
},
template: markRaw(SliderExample),
templateOptions: {
sliders: [
{
variable: "t1",
min: 0,
max: 1,
step: 0.01,
default: 0.25,
},
{
variable: "t2",
min: 0,
max: 1,
step: 0.01,
default: 0.75,
},
],
},
},
],
};

View file

@ -20,9 +20,9 @@ class BezierDrawing {
callback: BezierCallback;
options: string;
options: Record<string, number>;
constructor(bezier: WasmBezierInstance, callback: BezierCallback, options: string) {
constructor(bezier: WasmBezierInstance, callback: BezierCallback, options: Record<string, number>) {
this.bezier = bezier;
this.callback = callback;
this.options = options;
@ -100,8 +100,8 @@ class BezierDrawing {
}
}
updateBezier(options = ""): void {
if (options !== "") {
updateBezier(options: Record<string, number> = {}): void {
if (Object.values(options).length !== 0) {
this.options = options;
}
this.clearFigure();

View file

@ -30,8 +30,8 @@ export default defineComponent({
required: true,
},
options: {
type: String,
default: "",
type: Object as PropType<Record<string, number>>,
default: () => ({}),
},
},
mounted() {
@ -40,8 +40,11 @@ export default defineComponent({
this.bezierDrawing.updateBezier();
},
watch: {
options() {
this.bezierDrawing.updateBezier(this.options);
options: {
deep: true,
handler() {
this.bezierDrawing.updateBezier(this.options);
},
},
},
});

View file

@ -1,15 +1,17 @@
<template>
<div>
<Example :title="title" :bezier="bezier" :callback="callback" :options="value.toString()" />
<div class="slider_label">{{ templateOptions.variable }} = {{ value }}</div>
<input class="slider" v-model="value" type="range" :step="templateOptions.step" :min="templateOptions.min" :max="templateOptions.max" />
<Example :title="title" :bezier="bezier" :callback="callback" :options="sliderData" />
<div v-for="(slider, index) in templateOptions.sliders" :key="index">
<div class="slider_label">{{ slider.variable }} = {{ sliderData[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>
</template>
<script lang="ts">
import { defineComponent, PropType } from "vue";
import { BezierCallback } from "@/utils/types";
import { BezierCallback, SliderOption } from "@/utils/types";
import { WasmBezierInstance } from "@/utils/wasm-comm";
import Example from "@/components/Example.vue";
@ -35,8 +37,9 @@ export default defineComponent({
},
},
data() {
const sliders: SliderOption[] = this.templateOptions.sliders;
return {
value: this.templateOptions.default,
sliderData: Object.assign({}, ...sliders.map((s) => ({ [s.variable]: s.default }))),
};
},
});

View file

@ -1,11 +1,22 @@
import { Point } from "@/utils/types";
import { Point, WasmBezierInstance } from "@/utils/types";
const RADIUS_SIZE = {
large: 5,
small: 3,
const HANDLE_RADIUS_FACTOR = 2 / 3;
const DEFAULT_ENDPOINT_RADIUS = 5;
export const COLORS = {
CANVAS: "white",
INTERACTIVE: {
STROKE_1: "black",
STROKE_2: "grey",
SELECTED: "blue",
},
NON_INTERACTIVE: {
STROKE_1: "red",
STROKE_2: "orange",
},
};
export const getPointSizeByIndex = (index: number, numPoints: number): number => RADIUS_SIZE[index === 0 || index === numPoints - 1 ? "large" : "small"];
export const getPointSizeByIndex = (index: number, numPoints: number, radius = DEFAULT_ENDPOINT_RADIUS): number => (index === 0 || index === numPoints - 1 ? radius : radius * HANDLE_RADIUS_FACTOR);
export const getContextFromCanvas = (canvas: HTMLCanvasElement): CanvasRenderingContext2D => {
const ctx = canvas.getContext("2d");
@ -15,7 +26,7 @@ export const getContextFromCanvas = (canvas: HTMLCanvasElement): CanvasRendering
return ctx;
};
export const drawLine = (ctx: CanvasRenderingContext2D, point1: Point, point2: Point, strokeColor = "gray"): void => {
export const drawLine = (ctx: CanvasRenderingContext2D, point1: Point, point2: Point, strokeColor = COLORS.INTERACTIVE.STROKE_2): void => {
ctx.strokeStyle = strokeColor;
ctx.lineWidth = 1;
@ -25,7 +36,7 @@ export const drawLine = (ctx: CanvasRenderingContext2D, point1: Point, point2: P
ctx.stroke();
};
export const drawPoint = (ctx: CanvasRenderingContext2D, point: Point, radius: number, strokeColor = "black"): void => {
export const drawPoint = (ctx: CanvasRenderingContext2D, point: Point, radius: number, strokeColor = COLORS.INTERACTIVE.STROKE_1): void => {
// Outline the point
ctx.strokeStyle = strokeColor;
ctx.lineWidth = radius / 3;
@ -34,25 +45,29 @@ export const drawPoint = (ctx: CanvasRenderingContext2D, point: Point, radius: n
ctx.stroke();
// Fill the point (hiding any overlapping lines)
ctx.fillStyle = "white";
ctx.fillStyle = COLORS.CANVAS;
ctx.beginPath();
ctx.arc(point.x, point.y, radius * (2 / 3), 0, 2 * Math.PI, false);
ctx.arc(point.x, point.y, radius * HANDLE_RADIUS_FACTOR, 0, 2 * Math.PI, false);
ctx.fill();
};
export const drawText = (ctx: CanvasRenderingContext2D, text: string, x: number, y: number): void => {
ctx.fillStyle = "black";
export const drawText = (ctx: CanvasRenderingContext2D, text: string, x: number, y: number, textColor = COLORS.INTERACTIVE.STROKE_1): void => {
ctx.fillStyle = textColor;
ctx.font = "16px Arial";
ctx.fillText(text, x, y);
};
export const drawBezier = (ctx: CanvasRenderingContext2D, points: Point[], dragIndex: number | null = null): void => {
/* Until a bezier representation is finalized, treat the points as follows
points[0] = start point
points[1] = handle start
points[2] = (optional) handle end
points[3] = end point
*/
export const drawBezierHelper = (ctx: CanvasRenderingContext2D, bezier: WasmBezierInstance, strokeColor = COLORS.INTERACTIVE.STROKE_1, radius = DEFAULT_ENDPOINT_RADIUS): void => {
const points = bezier.get_points().map((p: string) => JSON.parse(p));
drawBezier(ctx, points, null, strokeColor, radius);
};
export const drawBezier = (ctx: CanvasRenderingContext2D, points: Point[], dragIndex: number | null = null, strokeColor = COLORS.INTERACTIVE.STROKE_1, radius = DEFAULT_ENDPOINT_RADIUS): void => {
// Points passed to drawBezier are interpreted as follows
// points[0] = start point
// points[1] = handle start
// points[2] = (optional) handle end
// points[3] = end point
const start = points[0];
let end = null;
let handleStart = null;
@ -67,7 +82,7 @@ export const drawBezier = (ctx: CanvasRenderingContext2D, points: Point[], dragI
end = points[2];
}
ctx.strokeStyle = "black";
ctx.strokeStyle = strokeColor;
ctx.lineWidth = 2;
ctx.beginPath();
@ -79,10 +94,10 @@ export const drawBezier = (ctx: CanvasRenderingContext2D, points: Point[], dragI
}
ctx.stroke();
drawLine(ctx, start, handleStart);
drawLine(ctx, end, handleEnd);
drawLine(ctx, start, handleStart, strokeColor);
drawLine(ctx, end, handleEnd, strokeColor);
points.forEach((point, index) => {
drawPoint(ctx, point, getPointSizeByIndex(index, points.length), index === dragIndex ? "Blue" : "Black");
drawPoint(ctx, point, getPointSizeByIndex(index, points.length, radius), index === dragIndex ? COLORS.INTERACTIVE.SELECTED : strokeColor);
});
};

View file

@ -4,7 +4,15 @@ export type WasmBezierInstance = InstanceType<WasmRawInstance["WasmBezier"]>;
export type WasmBezierKey = keyof WasmBezierInstance;
export type WasmBezierMutatorKey = "set_start" | "set_handle_start" | "set_handle_end" | "set_end";
export type BezierCallback = (canvas: HTMLCanvasElement, bezier: WasmBezierInstance, options: string) => void;
export type BezierCallback = (canvas: HTMLCanvasElement, bezier: WasmBezierInstance, options: Record<string, number>) => void;
export type SliderOption = {
min: number;
max: number;
step: number;
default: number;
variable: string;
};
export type Point = {
x: number;

View file

@ -9,11 +9,10 @@ struct Point {
y: f64,
}
/// Wrapper of the `Bezier` struct to be used in JS.
#[wasm_bindgen]
/// Wrapper of the `Bezier` struct to be used in JS
pub struct WasmBezier {
internal: Bezier,
}
#[derive(Clone)]
pub struct WasmBezier(Bezier);
/// Convert a `DVec2` into a `JsValue`
pub fn vec_to_point(p: &DVec2) -> JsValue {
@ -25,60 +24,68 @@ impl WasmBezier {
/// Expect js_points to be a list of 3 pairs
pub fn new_quad(js_points: &JsValue) -> WasmBezier {
let points: [DVec2; 3] = js_points.into_serde().unwrap();
WasmBezier {
internal: Bezier::from_quadratic_dvec2(points[0], points[1], points[2]),
}
WasmBezier(Bezier::from_quadratic_dvec2(points[0], points[1], points[2]))
}
/// 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 {
internal: Bezier::from_cubic_dvec2(points[0], points[1], points[2], points[3]),
}
WasmBezier(Bezier::from_cubic_dvec2(points[0], points[1], points[2], points[3]))
}
pub fn set_start(&mut self, x: f64, y: f64) {
self.internal.set_start(DVec2::from((x, y)));
self.0.set_start(DVec2::new(x, y));
}
pub fn set_end(&mut self, x: f64, y: f64) {
self.internal.set_end(DVec2::from((x, y)));
self.0.set_end(DVec2::new(x, y));
}
pub fn set_handle_start(&mut self, x: f64, y: f64) {
self.internal.set_handle_start(DVec2::from((x, y)));
self.0.set_handle_start(DVec2::new(x, y));
}
pub fn set_handle_end(&mut self, x: f64, y: f64) {
self.internal.set_handle_end(DVec2::from((x, y)));
self.0.set_handle_end(DVec2::new(x, y));
}
pub fn get_points(&self) -> Vec<JsValue> {
self.internal.get_points().iter().flatten().map(vec_to_point).collect()
self.0.get_points().iter().flatten().map(vec_to_point).collect()
}
pub fn to_svg(&self) -> String {
self.internal.to_svg()
self.0.to_svg()
}
pub fn length(&self) -> f64 {
self.internal.length()
self.0.length()
}
pub fn compute(&self, t: f64) -> JsValue {
vec_to_point(&self.internal.compute(t))
vec_to_point(&self.0.compute(t))
}
pub fn compute_lookup_table(&self, steps: i32) -> Vec<JsValue> {
self.internal.compute_lookup_table(Some(steps)).iter().map(vec_to_point).collect()
self.0.compute_lookup_table(Some(steps)).iter().map(vec_to_point).collect()
}
pub fn derivative(&self, t: f64) -> JsValue {
vec_to_point(&self.internal.derivative(t))
vec_to_point(&self.0.derivative(t))
}
pub fn normal(&self, t: f64) -> JsValue {
vec_to_point(&self.internal.normal(t))
vec_to_point(&self.0.normal(t))
}
pub fn split(&self, t: f64) -> JsValue {
let bezier_points: [Vec<Point>; 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()
}
pub fn trim(&self, t1: f64, t2: f64) -> WasmBezier {
WasmBezier(self.0.trim(t1, t2))
}
}

View file

@ -1,6 +1,7 @@
use glam::DVec2;
/// Representation of the handle point(s) in a bezier segment
#[derive(Copy, Clone)]
pub enum BezierHandles {
/// Handles for a quadratic segment
Quadratic {
@ -17,6 +18,7 @@ pub enum BezierHandles {
}
/// Representation of a bezier segment with 2D points
#[derive(Copy, Clone)]
pub struct Bezier {
/// Start point of the bezier segment
start: DVec2,
@ -31,9 +33,9 @@ impl Bezier {
/// 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::from((x1, y1)),
handles: BezierHandles::Quadratic { handle: DVec2::from((x2, y2)) },
end: DVec2::from((x3, y3)),
start: DVec2::new(x1, y1),
handles: BezierHandles::Quadratic { handle: DVec2::new(x2, y2) },
end: DVec2::new(x3, y3),
}
}
@ -50,12 +52,12 @@ impl Bezier {
/// 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::from((x1, y1)),
start: DVec2::new(x1, y1),
handles: BezierHandles::Cubic {
handle_start: DVec2::from((x2, y2)),
handle_end: DVec2::from((x3, y3)),
handle_start: DVec2::new(x2, y2),
handle_end: DVec2::new(x3, y3),
},
end: DVec2::from((x4, y4)),
end: DVec2::new(x4, y4),
}
}
@ -254,4 +256,53 @@ impl Bezier {
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`
pub fn split(&self, t: f64) -> [Bezier; 2] {
let split_point = self.compute(t);
let t_squared = t * t;
let t_minus_one = t - 1.;
let squared_t_minus_one = t_minus_one * t_minus_one;
match self.handles {
// TODO: Actually calculate the correct handle locations
BezierHandles::Quadratic { handle } => [
Bezier::from_quadratic_dvec2(self.start, t * handle - t_minus_one * self.start, split_point),
Bezier::from_quadratic_dvec2(split_point, t * self.end - t_minus_one * handle, self.end),
],
BezierHandles::Cubic { handle_start, handle_end } => [
Bezier::from_cubic_dvec2(
self.start,
t * handle_start - t_minus_one * self.start,
t_squared * handle_end - 2. * t * t_minus_one * handle_start + squared_t_minus_one * self.start,
split_point,
),
Bezier::from_cubic_dvec2(
split_point,
t_squared * self.end - 2. * t * t_minus_one * handle_end + squared_t_minus_one * handle_start,
t * self.end - t_minus_one * handle_end,
self.end,
),
],
}
}
/// 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 };
let t2_split_side = if t1 <= t2 { 0 } else { 1 };
let bezier_starting_at_t1 = self.split(t1)[t1_split_side];
// Adjust the ratio `t2` to its corresponding value on the new curve that was split on `t1`
let adjusted_t2 = if t1 < t2 || (t1 == t2 && t1 == 0.) {
// Case where we took the split from t1 to the end
// Also cover the `t1` == t2 case where there would otherwise be a divide by 0
(t2 - t1) / (1. - t1)
} else {
// Case where we took the split from the beginning to `t1`
t2 / t1
};
bezier_starting_at_t1.split(adjusted_t2)[t2_split_side]
}
}