Support linear bezier curve segments in the Bezier math library (#717)

* Support Linear line segments, add linear section to interactive docs

* Fix regression, customize points in UI examples, add optional subdivisions to length, minor refactors

* Refactor ExamplePane, use better example curves

* Update consts.rs comments

* Code review changes

* Address PR comments

* Code review

Co-authored-by: Keavon Chambers <keavon@keavon.com>
This commit is contained in:
Hannah Li
2022-07-08 17:41:15 -04:00
committed by Keavon Chambers
co-authored by Keavon Chambers
parent ad7097ea92
commit 00cc50d531
11 changed files with 440 additions and 215 deletions
+80 -25
View File
@@ -9,7 +9,9 @@
:name="feature.name"
:callback="feature.callback"
:createThroughPoints="feature.createThroughPoints"
:cubicOptions="feature.cubicOptions"
:curveDegrees="feature.curveDegrees"
:customPoints="feature.customPoints"
:customOptions="feature.customOptions"
/>
</div>
</div>
@@ -19,7 +21,7 @@
import { defineComponent, markRaw } from "vue";
import { drawText, drawPoint, drawBezier, drawLine, getContextFromCanvas, drawBezierHelper, COLORS } from "@/utils/drawing";
import { Point, WasmBezierInstance } from "@/utils/types";
import { BezierCurveType, Point, WasmBezierInstance } from "@/utils/types";
import ExamplePane from "@/components/ExamplePane.vue";
import SliderExample from "@/components/SliderExample.vue";
@@ -51,6 +53,7 @@ export default defineComponent({
name: "Bezier Through Points",
// eslint-disable-next-line
callback: (): void => {},
curveDegrees: new Set([BezierCurveType.Quadratic, BezierCurveType.Cubic]),
createThroughPoints: true,
template: markRaw(SliderExample),
templateOptions: {
@@ -64,22 +67,31 @@ export default defineComponent({
},
],
},
cubicOptions: {
sliders: [
{
min: 0.01,
max: 0.99,
step: 0.01,
default: 0.5,
variable: "t",
},
{
min: 0,
max: 100,
step: 5,
default: 10,
variable: "midpoint separation",
},
customOptions: {
[BezierCurveType.Cubic]: {
sliders: [
{
min: 0.01,
max: 0.99,
step: 0.01,
default: 0.5,
variable: "t",
},
{
min: 0,
max: 100,
step: 2,
default: 30,
variable: "midpoint separation",
},
],
},
},
customPoints: {
[BezierCurveType.Quadratic]: [
[30, 50],
[120, 70],
[160, 170],
],
},
},
@@ -90,9 +102,9 @@ export default defineComponent({
},
},
{
name: "Compute",
name: "Evaluate",
callback: (canvas: HTMLCanvasElement, bezier: WasmBezierInstance, options: Record<string, number>): void => {
const point = JSON.parse(bezier.compute(options.t));
const point = JSON.parse(bezier.evaluate(options.t));
drawPoint(getContextFromCanvas(canvas), point, 4, COLORS.NON_INTERACTIVE.STROKE_1);
},
template: markRaw(SliderExample),
@@ -121,12 +133,42 @@ export default defineComponent({
],
},
},
{
name: "Derivative",
callback: (canvas: HTMLCanvasElement, bezier: WasmBezierInstance): void => {
const context = getContextFromCanvas(canvas);
const derivativeBezier = bezier.derivative();
if (derivativeBezier) {
const points: Point[] = derivativeBezier.get_points().map((p) => JSON.parse(p));
if (points.length === 2) {
drawLine(context, points[0], points[1], COLORS.NON_INTERACTIVE.STROKE_1);
} else {
drawBezier(context, points, null, { curveStrokeColor: COLORS.NON_INTERACTIVE.STROKE_1, radius: 3.5 });
}
}
},
curveDegrees: new Set([BezierCurveType.Quadratic, BezierCurveType.Cubic]),
customPoints: {
[BezierCurveType.Quadratic]: [
[30, 40],
[110, 50],
[120, 130],
],
[BezierCurveType.Cubic]: [
[50, 50],
[60, 100],
[100, 140],
[140, 150],
],
},
},
{
name: "Tangent",
callback: (canvas: HTMLCanvasElement, bezier: WasmBezierInstance, options: Record<string, number>): void => {
const context = getContextFromCanvas(canvas);
const intersection = JSON.parse(bezier.compute(options.t));
const intersection = JSON.parse(bezier.evaluate(options.t));
const tangent = JSON.parse(bezier.tangent(options.t));
const tangentEnd = {
@@ -146,7 +188,7 @@ export default defineComponent({
callback: (canvas: HTMLCanvasElement, bezier: WasmBezierInstance, options: Record<string, number>): void => {
const context = getContextFromCanvas(canvas);
const intersection = JSON.parse(bezier.compute(options.t));
const intersection = JSON.parse(bezier.evaluate(options.t));
const normal = JSON.parse(bezier.normal(options.t));
const normalEnd = {
@@ -218,13 +260,26 @@ export default defineComponent({
const extrema: number[][] = JSON.parse(bezier.local_extrema());
extrema.forEach((tValues, index) => {
tValues.forEach((t) => {
const point: Point = JSON.parse(bezier.compute(t));
const point: Point = JSON.parse(bezier.evaluate(t));
drawPoint(context, point, 4, dimensionColors[index]);
});
});
drawText(getContextFromCanvas(canvas), "X extrema", 5, canvas.height - 20, dimensionColors[0]);
drawText(getContextFromCanvas(canvas), "Y extrema", 5, canvas.height - 5, dimensionColors[1]);
},
customPoints: {
[BezierCurveType.Quadratic]: [
[40, 40],
[160, 30],
[110, 150],
],
[BezierCurveType.Cubic]: [
[160, 180],
[170, 10],
[30, 90],
[180, 160],
],
},
},
{
name: "Rotate",
@@ -243,8 +298,8 @@ export default defineComponent({
variable: "angle",
min: 0,
max: 2,
step: 1 / 16,
default: 1 / 8,
step: 1 / 50,
default: 0.12,
unit: "π",
},
],
@@ -1,13 +1,19 @@
import { WasmBezier } from "@/../wasm/pkg";
import { COLORS, drawBezier, drawPoint, getContextFromCanvas, getPointSizeByIndex } from "@/utils/drawing";
import { BezierCallback, BezierPoint, BezierStyleConfig, Point, WasmBezierMutatorKey, WasmBezierInstance } from "@/utils/types";
import { BezierCallback, BezierPoint, BezierStyleConfig, Point, WasmBezierManipulatorKey, WasmBezierInstance } from "@/utils/types";
// Offset to increase selectable range, used to make points easier to grab
const FUDGE_FACTOR = 3;
class BezierDrawing {
static indexToMutator: WasmBezierMutatorKey[] = ["set_start", "set_handle_start", "set_handle_end", "set_end"];
// Given the number of points in the curve, map the index of a point to the correct manipulator key
const MANIPULATOR_KEYS_FROM_BEZIER_TYPE: { [k: number]: WasmBezierManipulatorKey[] } = {
2: ["set_start", "set_end"],
3: ["set_start", "set_handle_start", "set_end"],
4: ["set_start", "set_handle_start", "set_handle_end", "set_end"],
};
class BezierDrawing {
points: BezierPoint[];
canvas: HTMLCanvasElement;
@@ -37,7 +43,7 @@ class BezierDrawing {
y: p.y,
r: getPointSizeByIndex(i, points.length),
selected: false,
mutator: BezierDrawing.indexToMutator[points.length === 3 && i > 1 ? i + 1 : i],
manipulator: MANIPULATOR_KEYS_FROM_BEZIER_TYPE[points.length][i],
}));
if (this.createThroughPoints && this.points.length === 4) {
@@ -70,10 +76,7 @@ class BezierDrawing {
}
mouseMoveHandler(evt: MouseEvent): void {
if (evt.buttons === 0) {
this.deselectPointHandler();
return;
}
if (evt.buttons === 0) this.deselectPointHandler();
const mx = evt.offsetX;
const my = evt.offsetY;
@@ -84,7 +87,7 @@ class BezierDrawing {
const selectedPoint = this.points[this.dragIndex];
selectedPoint.x = mx;
selectedPoint.y = my;
this.bezier[selectedPoint.mutator](selectedPoint.x, selectedPoint.y);
this.bezier[selectedPoint.manipulator](selectedPoint.x, selectedPoint.y);
}
}
this.updateBezier({ x: mx, y: my });
@@ -1,7 +1,7 @@
<template>
<div>
<h4 class="example_header">{{ title }}</h4>
<figure class="example_figure" ref="drawing"></figure>
<h4 class="example-header">{{ title }}</h4>
<figure class="example-figure" ref="drawing"></figure>
</div>
</template>
@@ -54,10 +54,11 @@ export default defineComponent({
</script>
<style scoped>
.example_header {
.example-header {
margin-bottom: 0;
}
.example_figure {
.example-figure {
margin-top: 0.5em;
}
</style>
@@ -1,7 +1,7 @@
<template>
<div>
<h2 class="example_pane_header">{{ name }}</h2>
<div class="example_row">
<h2 class="example-pane-header">{{ name }}</h2>
<div class="example-row">
<div v-for="(example, index) in exampleData" :key="index">
<component :is="template" :templateOptions="example.templateOptions" :title="example.title" :bezier="example.bezier" :callback="callback" :createThroughPoints="createThroughPoints" />
</div>
@@ -12,7 +12,7 @@
<script lang="ts">
import { defineComponent, PropType, Component } from "vue";
import { BezierCallback, TemplateOption, WasmBezierInstance, WasmRawInstance } from "@/utils/types";
import { BezierCallback, BezierCurveType, TemplateOption, WasmBezierConstructorKey, WasmBezierInstance, WasmRawInstance } from "@/utils/types";
import Example from "@/components/Example.vue";
@@ -22,13 +22,51 @@ type ExampleData = {
templateOptions: TemplateOption;
};
type CustomTemplateOptions = {
[key in BezierCurveType]?: TemplateOption;
};
type CustomPoints = {
[key in BezierCurveType]?: number[][];
};
const CurveTypeMapping = {
[BezierCurveType.Linear]: {
points: [
[30, 60],
[140, 120],
],
constructor: "new_linear" as WasmBezierConstructorKey,
},
[BezierCurveType.Quadratic]: {
points: [
[30, 50],
[140, 30],
[160, 170],
],
constructor: "new_quadratic" as WasmBezierConstructorKey,
},
[BezierCurveType.Cubic]: {
points: [
[30, 30],
[60, 140],
[150, 30],
[160, 160],
],
constructor: "new_cubic" as WasmBezierConstructorKey,
},
};
export default defineComponent({
name: "ExamplePane",
components: {
Example,
},
props: {
name: String,
name: {
type: String as PropType<string>,
required: true,
},
callback: {
type: Function as PropType<BezierCallback>,
required: true,
@@ -37,15 +75,26 @@ export default defineComponent({
type: Object as PropType<Component>,
default: Example,
},
templateOptions: Object as PropType<TemplateOption>,
cubicOptions: {
templateOptions: {
type: Object as PropType<TemplateOption>,
default: null,
required: false,
},
customOptions: {
type: Object as PropType<CustomTemplateOptions>,
default: () => ({}),
},
createThroughPoints: {
type: Boolean as PropType<boolean>,
default: false,
},
curveDegrees: {
type: Set as PropType<Set<BezierCurveType>>,
default: () => new Set(Object.values(BezierCurveType)),
},
customPoints: {
type: Object as PropType<CustomPoints>,
default: () => ({}),
},
},
data() {
return {
@@ -54,42 +103,32 @@ export default defineComponent({
},
mounted() {
import("@/../wasm/pkg").then((wasm: WasmRawInstance) => {
const quadraticPoints = [
[30, 50],
[140, 30],
[160, 170],
];
const cubicPoints = [
[30, 30],
[60, 140],
[150, 30],
[160, 160],
];
this.exampleData = [
{
title: "Quadratic",
bezier: wasm.WasmBezier.new_quadratic(quadraticPoints),
templateOptions: this.templateOptions as TemplateOption,
},
{
title: "Cubic",
bezier: wasm.WasmBezier.new_cubic(cubicPoints),
templateOptions: (this.cubicOptions || this.templateOptions) as TemplateOption,
},
];
this.exampleData = [];
// Only add example for BezierCurveType that is in the curveDegrees set
Object.values(BezierCurveType).forEach((bezierType) => {
if (this.curveDegrees.has(bezierType)) {
const { points, constructor } = CurveTypeMapping[bezierType];
this.exampleData.push({
title: bezierType,
// Use custom options if they were provided for the current BezierCurveType
bezier: wasm.WasmBezier[constructor](this.customPoints[bezierType] || points),
templateOptions: (this.customOptions[bezierType] || this.templateOptions) as TemplateOption,
});
}
});
});
},
});
</script>
<style>
.example_row {
.example-row {
display: flex; /* or inline-flex */
flex-direction: row;
justify-content: center;
}
.example_pane_header {
.example-pane-header {
margin-bottom: 0;
}
</style>
@@ -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] }}{{ 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>
@@ -28,9 +28,9 @@ export const getContextFromCanvas = (canvas: HTMLCanvasElement): CanvasRendering
return ctx;
};
export const drawLine = (ctx: CanvasRenderingContext2D, point1: Point, point2: Point, strokeColor = COLORS.INTERACTIVE.STROKE_2): void => {
export const drawLine = (ctx: CanvasRenderingContext2D, point1: Point, point2: Point, strokeColor = COLORS.INTERACTIVE.STROKE_2, lineWidth = 1): void => {
ctx.strokeStyle = strokeColor;
ctx.lineWidth = 1;
ctx.lineWidth = lineWidth;
ctx.beginPath();
ctx.moveTo(point1.x, point1.y);
@@ -59,6 +59,20 @@ export const drawText = (ctx: CanvasRenderingContext2D, text: string, x: number,
ctx.fillText(text, x, y);
};
export const drawCurve = (ctx: CanvasRenderingContext2D, points: Point[], strokeColor = COLORS.INTERACTIVE.STROKE_1): void => {
ctx.strokeStyle = strokeColor;
ctx.lineWidth = 2;
ctx.beginPath();
ctx.moveTo(points[0].x, points[0].y);
if (points.length === 3) {
ctx.quadraticCurveTo(points[1].x, points[1].y, points[2].x, points[2].y);
} else {
ctx.bezierCurveTo(points[1].x, points[1].y, points[2].x, points[2].y, points[3].x, points[3].y);
}
ctx.stroke();
};
export const drawBezierHelper = (ctx: CanvasRenderingContext2D, bezier: WasmBezierInstance, bezierStyleConfig: Partial<BezierStyleConfig> = {}): void => {
const points = bezier.get_points().map((p: string) => JSON.parse(p));
drawBezier(ctx, points, null, bezierStyleConfig);
@@ -95,27 +109,24 @@ export const drawBezier = (ctx: CanvasRenderingContext2D, points: Point[], dragI
handleStart = points[1];
handleEnd = points[2];
end = points[3];
} else {
} else if (points.length === 3) {
handleStart = points[1];
handleEnd = handleStart;
end = points[2];
}
ctx.strokeStyle = styleConfig.curveStrokeColor;
ctx.lineWidth = 2;
ctx.beginPath();
ctx.moveTo(points[0].x, points[0].y);
if (points.length === 3) {
ctx.quadraticCurveTo(handleStart.x, handleStart.y, end.x, end.y);
} else {
ctx.bezierCurveTo(handleStart.x, handleStart.y, handleEnd.x, handleEnd.y, end.x, end.y);
handleStart = start;
handleEnd = points[1];
end = handleEnd;
}
ctx.stroke();
if (styleConfig.drawHandles) {
drawLine(ctx, start, handleStart, styleConfig.handleLineStrokeColor);
drawLine(ctx, end, handleEnd, styleConfig.handleLineStrokeColor);
if (points.length === 2) {
drawLine(ctx, start, end, styleConfig.curveStrokeColor, 2);
} else {
drawCurve(ctx, points, styleConfig.curveStrokeColor);
if (styleConfig.drawHandles) {
drawLine(ctx, start, handleStart, styleConfig.handleLineStrokeColor);
drawLine(ctx, end, handleEnd, styleConfig.handleLineStrokeColor);
}
}
points.forEach((point, index) => {
@@ -2,7 +2,14 @@ export type WasmRawInstance = typeof import("../../wasm/pkg");
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 WasmBezierConstructorKey = "new_linear" | "new_quadratic" | "new_cubic";
export type WasmBezierManipulatorKey = "set_start" | "set_handle_start" | "set_handle_end" | "set_end";
export enum BezierCurveType {
Linear = "Linear",
Quadratic = "Quadratic",
Cubic = "Cubic",
}
export type BezierCallback = (canvas: HTMLCanvasElement, bezier: WasmBezierInstance, options: Record<string, number>, mouseLocation?: Point) => void;
@@ -25,7 +32,7 @@ export type Point = {
};
export type BezierPoint = Point & {
mutator: WasmBezierMutatorKey;
manipulator: WasmBezierManipulatorKey;
};
export type BezierStyleConfig = {
@@ -21,7 +21,7 @@ fn vec_to_point(p: &DVec2) -> JsValue {
/// Convert a bezier to a list of points.
fn bezier_to_points(bezier: Bezier) -> Vec<Point> {
bezier.get_points().iter().flatten().map(|point| Point { x: point.x, y: point.y }).collect()
bezier.get_points().map(|point| Point { x: point.x, y: point.y }).collect()
}
/// Serialize some data and then convert it to a JsValue.
@@ -31,6 +31,12 @@ fn to_js_value<T: Serialize>(data: T) -> JsValue {
#[wasm_bindgen]
impl WasmBezier {
/// Expect js_points to be a list of 3 pairs.
pub fn new_linear(js_points: &JsValue) -> WasmBezier {
let points: [DVec2; 2] = js_points.into_serde().unwrap();
WasmBezier(Bezier::from_linear_dvec2(points[0], points[1]))
}
/// 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();
@@ -70,7 +76,7 @@ impl WasmBezier {
}
pub fn get_points(&self) -> Vec<JsValue> {
self.0.get_points().iter().flatten().map(vec_to_point).collect()
self.0.get_points().map(|point| vec_to_point(&point)).collect()
}
pub fn to_svg(&self) -> String {
@@ -78,17 +84,21 @@ impl WasmBezier {
}
pub fn length(&self) -> f64 {
self.0.length()
self.0.length(None)
}
pub fn compute(&self, t: f64) -> JsValue {
vec_to_point(&self.0.compute(t))
pub fn evaluate(&self, t: f64) -> JsValue {
vec_to_point(&self.0.evaluate(t))
}
pub fn compute_lookup_table(&self, steps: i32) -> Vec<JsValue> {
self.0.compute_lookup_table(Some(steps)).iter().map(vec_to_point).collect()
}
pub fn derivative(&self) -> Option<WasmBezier> {
self.0.derivative().map(WasmBezier)
}
pub fn tangent(&self, t: f64) -> JsValue {
vec_to_point(&self.0.tangent(t))
}