diff --git a/packages/2d/src/components/Knot.ts b/packages/2d/src/components/Knot.ts new file mode 100644 index 000000000..827ae8893 --- /dev/null +++ b/packages/2d/src/components/Knot.ts @@ -0,0 +1,155 @@ +import {Signal, SignalValue} from '@motion-canvas/core/lib/signals'; +import { + PossibleVector2, + Vector2, + Vector2Signal, +} from '@motion-canvas/core/lib/types'; +import {KnotInfo} from '../curves'; +import {Node, NodeProps} from './Node'; +import { + cloneable, + compound, + computed, + initial, + parser, + signal, + wrapper, +} from '../decorators'; + +export interface KnotProps extends NodeProps { + /** + * {@inheritDoc Knot.startHandle} + */ + startHandle?: SignalValue; + /** + * {@inheritDoc Knot.endHandle} + */ + endHandle?: SignalValue; + /** + * {@inheritDoc Knot.auto} + */ + auto?: SignalValue; + startHandleAuto?: SignalValue; + endHandleAuto?: SignalValue; +} + +export type KnotAuto = {startHandle: number; endHandle: number}; +export type PossibleKnotAuto = KnotAuto | number | [number, number]; +export type KnotAutoSignal = Signal< + PossibleKnotAuto, + KnotAuto, + TOwner +> & { + endHandle: Signal; + startHandle: Signal; +}; + +/** + * A node representing a knot of a {@link Spline}. + */ +export class Knot extends Node { + /** + * The position of the knot's start handle. The position is provided relative + * to the knot's position. + * + * @remarks + * By default, the position of the start handle will be the mirrored position + * of the {@link endHandle}. + * + * If neither an end handle nor a start handle is provided, the positions of + * the handles gets calculated automatically to create smooth curve through + * the knot. The smoothness of the resulting curve can be controlled via the + * {@link Spline.smoothness} property. + * + * It is also possible to blend between a user-defined position and the + * auto-calculated position by using the {@link auto} property. + * + * @defaultValue Mirrored position of the endHandle. + */ + @wrapper(Vector2) + @signal() + public declare readonly startHandle: Vector2Signal; + + /** + * The position of the knot's end handle. The position is provided relative + * to the knot's position. + * + * @remarks + * By default, the position of the end handle will be the mirrored position + * of the {@link startHandle}. + * + * If neither an end handle nor a start handle is provided, the positions of + * the handles gets calculated automatically to create smooth curve through + * the knot. The smoothness of the resulting curve can be controlled via the + * {@link Spline.smoothness} property. + * + * It is also possible to blend between a user-defined position and the + * auto-calculated position by using the {@link auto} property. + * + * @defaultValue Mirrored position of the startHandle. + */ + @wrapper(Vector2) + @signal() + public declare readonly endHandle: Vector2Signal; + + /** + * How much to blend between the user-provided handles and the auto-calculated + * handles. + * + * @remarks + * This property has no effect if no explicit handles are provided for the + * knot. + * + * @defaultValue 0 + */ + @cloneable(false) + @initial(() => ({startHandle: 0, endHandle: 0})) + @parser((value: PossibleKnotAuto) => { + if (typeof value === 'object' && !Array.isArray(value)) { + return value; + } + if (typeof value === 'number') { + value = [value, value]; + } + return {startHandle: value[0], endHandle: value[1]}; + }) + @compound({startHandle: 'startHandleAuto', endHandle: 'endHandleAuto'}) + public declare readonly auto: KnotAutoSignal; + public get startHandleAuto() { + return this.auto.startHandle; + } + public get endHandleAuto() { + return this.auto.endHandle; + } + + public constructor(props: KnotProps) { + super( + props.startHandle === undefined && props.endHandle === undefined + ? {auto: 1, ...props} + : props, + ); + } + + @computed() + public points(): KnotInfo { + const hasExplicitHandles = + !this.startHandle.isInitial() || !this.endHandle.isInitial(); + const startHandle = hasExplicitHandles ? this.startHandle() : Vector2.zero; + const endHandle = hasExplicitHandles ? this.endHandle() : Vector2.zero; + + return { + position: this.position(), + startHandle: startHandle.transformAsPoint(this.localToParent()), + endHandle: endHandle.transformAsPoint(this.localToParent()), + auto: {start: this.startHandleAuto(), end: this.endHandleAuto()}, + }; + } + + private getDefaultEndHandle() { + return this.startHandle().flipped; + } + + private getDefaultStartHandle() { + return this.endHandle().flipped; + } +} diff --git a/packages/2d/src/components/Line.ts b/packages/2d/src/components/Line.ts index 1f5cc4cd1..0936e87f1 100644 --- a/packages/2d/src/components/Line.ts +++ b/packages/2d/src/components/Line.ts @@ -18,10 +18,10 @@ import {DesiredLength} from '../partials'; import {Layout} from './Layout'; import { CurveDrawingInfo, + CurvePoint, CurveProfile, - getPolylineProfile, getPointAtDistance, - CurvePoint, + getPolylineProfile, } from '../curves'; import {useLogger} from '@motion-canvas/core/lib/utils'; import lineWithoutPoints from './__logs__/line-without-points.md'; @@ -90,13 +90,7 @@ export class Line extends Shape { public constructor(props: LineProps) { super(props); - if (props.children === undefined && props.points === undefined) { - useLogger().warn({ - message: 'No points specified for the line', - remarks: lineWithoutPoints, - inspect: this.key, - }); - } + this.validateProps(props); } @computed() @@ -199,6 +193,10 @@ export class Line extends Shape { } } + if (this.end() === 1 && this.closed()) { + path.closePath(); + } + return { startPoint: startPoint ?? Vector2.zero, startTangent: startTangent ?? Vector2.up, @@ -225,11 +223,24 @@ export class Line extends Shape { } protected override getComputedLayout(): BBox { - const box = super.getComputedLayout(); + return this.offsetComputedLayout(super.getComputedLayout()); + } + + protected offsetComputedLayout(box: BBox): BBox { box.position = box.position.sub(this.childrenBBox().center); return box; } + protected validateProps(props: LineProps) { + if (props.children === undefined && props.points === undefined) { + useLogger().warn({ + message: 'No points specified for the line', + remarks: lineWithoutPoints, + inspect: this.key, + }); + } + } + protected override getPath(): Path2D { return this.curveDrawingInfo().path; } diff --git a/packages/2d/src/components/Spline.ts b/packages/2d/src/components/Spline.ts new file mode 100644 index 000000000..d863d6c57 --- /dev/null +++ b/packages/2d/src/components/Spline.ts @@ -0,0 +1,270 @@ +import {SignalValue, SimpleSignal} from '@motion-canvas/core/lib/signals'; +import {useLogger} from '@motion-canvas/core/lib/utils'; +import {Line, LineProps} from './Line'; +import {Node} from './Node'; +import { + CubicBezierSegment, + CurveProfile, + getBezierSplineProfile, + KnotInfo, +} from '../curves'; +import {computed, initial, signal} from '../decorators'; +import { + arc, + bezierCurveTo, + drawLine, + lineTo, + moveTo, + quadraticCurveTo, +} from '../utils'; +import {Knot} from './Knot'; +import {BBox, SerializedVector2, Vector2} from '@motion-canvas/core/lib/types'; +import {DesiredLength} from '../partials'; +import splineWithInsufficientKnots from './__logs__/spline-with-insufficient-knots.md'; +import {PolynomialSegment} from '../curves/PolynomialSegment'; + +export interface SplineProps extends LineProps { + /** + * {@inheritDoc Spline.smoothness} + */ + smoothness?: SignalValue; +} + +/** + * A node for drawing a smooth line through a number of points. + * + * @remarks + * This node uses Bézier curves for drawing each segment of the spline. + * + * @example + * Defining knots using the `points` property. This will automatically + * calculate the handle positions for each knot do draw a smooth curve. You + * can control the smoothness of the resulting curve via the + * {@link Spline.smoothness} property: + * + * ```tsx + * + * ``` + * + * Defining knots with {@link Knot} nodes: + * + * ```tsx + * + * + * + * + * + * + * + * ``` + */ +export class Spline extends Line { + /** + * Determine the smoothness of the spline when using auto-calculated handles. + * + * @remarks + * This property is only applied to knots that don't use explicit handles. + * + * @defaultValue 0.4 + */ + @initial(0.4) + @signal() + public declare readonly smoothness: SimpleSignal; + + public constructor(props: SplineProps) { + super(props); + } + + public override profile(): CurveProfile { + return getBezierSplineProfile( + this.knots(), + this.closed(), + this.smoothness(), + ); + } + + @computed() + public knots(): KnotInfo[] { + if (this.points()) { + return this.parsedPoints().map(point => ({ + position: point, + startHandle: point, + endHandle: point, + auto: {start: 1, end: 1}, + })); + } + + return this.children() + .filter(this.isKnot) + .map(knot => knot.points()); + } + + protected override childrenBBox() { + const points = (this.profile().segments as PolynomialSegment[]).flatMap( + segment => segment.points, + ); + return BBox.fromPoints(...points); + } + + protected override validateProps(props: SplineProps) { + if ( + (props.children === undefined || props.children.length < 2) && + (props.points === undefined || props.points.length < 2) && + props.spawner === undefined + ) { + useLogger().warn({ + message: + 'Insufficient number of knots specified for spline. A spline needs at least two knots.', + remarks: splineWithInsufficientKnots, + inspect: this.key, + }); + } + } + + protected override desiredSize(): SerializedVector2 { + return this.getTightBBox().size; + } + + protected override offsetComputedLayout(box: BBox): BBox { + box.position = box.position.sub(this.getTightBBox().center); + return box; + } + + @computed() + private getTightBBox(): BBox { + const bounds = (this.profile().segments as PolynomialSegment[]).map( + segment => segment.getBBox(), + ); + return BBox.fromBBoxes(...bounds); + } + + public override drawOverlay( + context: CanvasRenderingContext2D, + matrix: DOMMatrix, + ) { + const size = this.computedSize(); + const box = this.childrenBBox().transformCorners(matrix); + const offset = size.mul(this.offset()).scale(0.5).transformAsPoint(matrix); + const segments = this.profile().segments as PolynomialSegment[]; + + context.lineWidth = 1; + context.strokeStyle = 'white'; + context.fillStyle = 'white'; + + const splinePath = new Path2D(); + + // Draw the actual spline first so that all control points get drawn on top of it. + for (let i = 0; i < segments.length; i++) { + const segment = segments[i]; + const [from, startHandle, endHandle, to] = + segment.transformPoints(matrix); + + moveTo(splinePath, from); + if (segment instanceof CubicBezierSegment) { + bezierCurveTo(splinePath, startHandle, endHandle, to as Vector2); + } else { + quadraticCurveTo(splinePath, startHandle, endHandle); + } + } + context.stroke(splinePath); + + for (let i = 0; i < segments.length; i++) { + const segment = segments[i]; + context.fillStyle = 'white'; + + const [from, startHandle, endHandle, to] = + segment.transformPoints(matrix); + + const handlePath = new Path2D(); + + context.globalAlpha = 0.5; + // Line from p0 to p1 + moveTo(handlePath, from); + lineTo(handlePath, startHandle); + + if (segment instanceof CubicBezierSegment) { + // Line from p2 to p3 + moveTo(handlePath, endHandle); + lineTo(handlePath, to as Vector2); + context.beginPath(); + context.stroke(handlePath); + } else { + // Line from p1 to p2 + lineTo(handlePath, endHandle); + context.beginPath(); + context.stroke(handlePath); + } + + context.globalAlpha = 1; + context.lineWidth = 2; + + // Draw first point of segment + moveTo(context, from); + context.beginPath(); + arc(context, from, 4); + context.closePath(); + context.stroke(); + context.fill(); + + // Draw final point of segment only if we're on the last segment. + // Otherwise, it will get drawn as the start point of the next segment. + if (i === segments.length - 1) { + if (to !== undefined) { + moveTo(context, to); + context.beginPath(); + arc(context, to, 4); + context.closePath(); + context.stroke(); + context.fill(); + } + } + + // Draw the control points + context.fillStyle = 'black'; + for (const point of [startHandle, endHandle]) { + if (point.magnitude > 0) { + moveTo(context, point); + context.beginPath(); + arc(context, point, 4); + context.closePath(); + context.fill(); + context.stroke(); + } + } + } + + context.lineWidth = 1; + const radius = 8; + context.beginPath(); + lineTo(context, offset.addY(-radius)); + lineTo(context, offset.addY(radius)); + lineTo(context, offset); + lineTo(context, offset.addX(-radius)); + context.arc(offset.x, offset.y, radius, 0, Math.PI * 2); + context.stroke(); + + context.beginPath(); + drawLine(context, box); + context.closePath(); + context.stroke(); + } + + private isKnot(node: Node): node is Knot { + return node instanceof Knot; + } +} diff --git a/packages/2d/src/components/__logs__/spline-with-insufficient-knots.md b/packages/2d/src/components/__logs__/spline-with-insufficient-knots.md new file mode 100644 index 000000000..777ee9b00 --- /dev/null +++ b/packages/2d/src/components/__logs__/spline-with-insufficient-knots.md @@ -0,0 +1,24 @@ +The spline won't be visible unless you specify at least two knots: + +```tsx + +``` + +For more control over the knot handles, you can alternatively provide the knots +as children to the spline using the `Knot` component: + +```tsx + + + + + +``` diff --git a/packages/2d/src/components/index.ts b/packages/2d/src/components/index.ts index 115b45ea4..325798040 100644 --- a/packages/2d/src/components/index.ts +++ b/packages/2d/src/components/index.ts @@ -2,6 +2,7 @@ export * from './Circle'; export * from './Icon'; export * from './Img'; export * from './Grid'; +export * from './Knot'; export * from './Latex'; export * from './Layout'; export * from './Line'; @@ -9,6 +10,7 @@ export * from './Node'; export * from './Rect'; export * from './Polygon'; export * from './Shape'; +export * from './Spline'; export * from './Txt'; export * from './types'; export * from './Video'; diff --git a/packages/2d/src/curves/CircleSegment.ts b/packages/2d/src/curves/CircleSegment.ts index d6beb7432..6c2d99bec 100644 --- a/packages/2d/src/curves/CircleSegment.ts +++ b/packages/2d/src/curves/CircleSegment.ts @@ -1,6 +1,7 @@ import {Vector2} from '@motion-canvas/core/lib/types'; import {Segment} from './Segment'; import {clamp} from '@motion-canvas/core/lib/tweening'; +import {CurvePoint} from './CurvePoint'; export class CircleSegment extends Segment { private readonly length: number; @@ -53,15 +54,15 @@ export class CircleSegment extends Segment { ]; } - public getPoint(distance: number): [Vector2, Vector2] { + public getPoint(distance: number): CurvePoint { const counterFactor = this.counter ? -1 : 1; const angle = this.from.radians + distance * this.angle * counterFactor; const tangent = Vector2.fromRadians(angle); - return [ - this.center.add(tangent.scale(this.radius)), - this.counter ? tangent : tangent.flipped, - ]; + return { + position: this.center.add(tangent.scale(this.radius)), + tangent: this.counter ? tangent : tangent.flipped, + }; } } diff --git a/packages/2d/src/curves/CubicBezierSegment.ts b/packages/2d/src/curves/CubicBezierSegment.ts new file mode 100644 index 000000000..279388687 --- /dev/null +++ b/packages/2d/src/curves/CubicBezierSegment.ts @@ -0,0 +1,80 @@ +import {Vector2} from '@motion-canvas/core/lib/types'; +import {bezierCurveTo} from '../utils'; +import {PolynomialSegment} from './PolynomialSegment'; +import {Polynomial2D} from './Polynomial2D'; + +/** + * A spline segment representing a cubic Bézier curve. + */ +export class CubicBezierSegment extends PolynomialSegment { + private static el = document.createElementNS( + 'http://www.w3.org/2000/svg', + 'path', + ); + + public get points(): Vector2[] { + return [this.p0, this.p1, this.p2, this.p3]; + } + + public constructor( + public readonly p0: Vector2, + public readonly p1: Vector2, + public readonly p2: Vector2, + public readonly p3: Vector2, + ) { + super( + new Polynomial2D( + p0, + // 3*(-p0+p1) + p0.flipped.add(p1).scale(3), + // 3*p0-6*p1+3*p2 + p0.scale(3).sub(p1.scale(6)).add(p2.scale(3)), + // -p0+3*p1-3*p2+p3 + p0.flipped.add(p1.scale(3)).sub(p2.scale(3)).add(p3), + ), + CubicBezierSegment.getLength(p0, p1, p2, p3), + ); + } + + public split(t: number): [PolynomialSegment, PolynomialSegment] { + const a = new Vector2( + this.p0.x + (this.p1.x - this.p0.x) * t, + this.p0.y + (this.p1.y - this.p0.y) * t, + ); + const b = new Vector2( + this.p1.x + (this.p2.x - this.p1.x) * t, + this.p1.y + (this.p2.y - this.p1.y) * t, + ); + const c = new Vector2( + this.p2.x + (this.p3.x - this.p2.x) * t, + this.p2.y + (this.p3.y - this.p2.y) * t, + ); + const d = new Vector2(a.x + (b.x - a.x) * t, a.y + (b.y - a.y) * t); + const e = new Vector2(b.x + (c.x - b.x) * t, b.y + (c.y - b.y) * t); + const p = new Vector2(d.x + (e.x - d.x) * t, d.y + (e.y - d.y) * t); + + const left = new CubicBezierSegment(this.p0, a, d, p); + const right = new CubicBezierSegment(p, e, c, this.p3); + + return [left, right]; + } + + protected override doDraw(context: CanvasRenderingContext2D | Path2D) { + bezierCurveTo(context, this.p1, this.p2, this.p3); + } + + protected static getLength( + p0: Vector2, + p1: Vector2, + p2: Vector2, + p3: Vector2, + ): number { + // Let the browser do the work for us instead of calculating the arclength + // manually. + CubicBezierSegment.el.setAttribute( + 'd', + `M ${p0.x} ${p0.y} C ${p1.x} ${p1.y} ${p2.x} ${p2.y} ${p3.x} ${p3.y}`, + ); + return CubicBezierSegment.el.getTotalLength(); + } +} diff --git a/packages/2d/src/curves/KnotInfo.ts b/packages/2d/src/curves/KnotInfo.ts new file mode 100644 index 000000000..5b55c27e3 --- /dev/null +++ b/packages/2d/src/curves/KnotInfo.ts @@ -0,0 +1,10 @@ +import {Vector2} from '@motion-canvas/core/lib/types'; + +export type KnotAutoHandles = {start: number; end: number}; + +export interface KnotInfo { + position: Vector2; + startHandle: Vector2; + endHandle: Vector2; + auto: KnotAutoHandles; +} diff --git a/packages/2d/src/curves/LineSegment.ts b/packages/2d/src/curves/LineSegment.ts index 781bd9135..79b00d1ff 100644 --- a/packages/2d/src/curves/LineSegment.ts +++ b/packages/2d/src/curves/LineSegment.ts @@ -1,6 +1,7 @@ import {Vector2} from '@motion-canvas/core/lib/types'; import {lineTo, moveTo} from '../utils'; import {Segment} from './Segment'; +import {CurvePoint} from './CurvePoint'; export class LineSegment extends Segment { private readonly length: number; @@ -33,8 +34,8 @@ export class LineSegment extends Segment { return [from, this.tangent.flipped, to, this.tangent]; } - public getPoint(distance: number): [Vector2, Vector2] { + public getPoint(distance: number): CurvePoint { const point = this.from.add(this.vector.scale(distance)); - return [point, this.tangent.flipped]; + return {position: point, tangent: this.tangent.flipped}; } } diff --git a/packages/2d/src/curves/Polynomial.ts b/packages/2d/src/curves/Polynomial.ts new file mode 100644 index 000000000..0839bcc8d --- /dev/null +++ b/packages/2d/src/curves/Polynomial.ts @@ -0,0 +1,355 @@ +import {clamp} from '@motion-canvas/core/lib/tweening'; + +/** + * A polynomial in the form ax^3 + bx^2 + cx + d up to a cubic polynomial. + * + * Source code liberally taken from: + * https://github.com/FreyaHolmer/Mathfs/blob/master/Runtime/Curves/Polynomial.cs + */ +export class Polynomial { + public readonly c1: number; + public readonly c2: number; + public readonly c3: number; + + /** + * Constructs a constant polynomial + * + * @param c0 - The constant coefficient + */ + public static constant(c0: number): Polynomial { + return new Polynomial(c0); + } + + /** + * Constructs a linear polynomial + * + * @param c0 - The constant coefficient + * @param c1 - The linear coefficient + */ + public static linear(c0: number, c1: number): Polynomial { + return new Polynomial(c0, c1); + } + + /** + * Constructs a quadratic polynomial + * + * @param c0 - The constant coefficient + * @param c1 - The linear coefficient + * @param c2 - The quadratic coefficient + */ + public static quadratic(c0: number, c1: number, c2: number): Polynomial { + return new Polynomial(c0, c1, c2); + } + + /** + * Constructs a cubic polynomial + * + * @param c0 - The constant coefficient + * @param c1 - The linear coefficient + * @param c2 - The quadratic coefficient + * @param c3 - The cubic coefficient + */ + public static cubic( + c0: number, + c1: number, + c2: number, + c3: number, + ): Polynomial { + return new Polynomial(c0, c1, c2, c3); + } + + /** + * The degree of the polynomial + */ + public get degree(): number { + if (this.c3 !== 0) { + return 3; + } else if (this.c2 !== 0) { + return 2; + } else if (this.c1 !== 0) { + return 1; + } + return 0; + } + + /** + * @param c0 - The constant coefficient + */ + public constructor(c0: number); + /** + * @param c0 - The constant coefficient + * @param c1 - The linear coefficient + */ + public constructor(c0: number, c1: number); + /** + * @param c0 - The constant coefficient + * @param c1 - The linear coefficient + * @param c2 - The quadratic coefficient + */ + public constructor(c0: number, c1: number, c2: number); + /** + * @param c0 - The constant coefficient + * @param c1 - The linear coefficient + * @param c2 - The quadratic coefficient + * @param c3 - The cubic coefficient + */ + public constructor(c0: number, c1: number, c2: number, c3: number); + public constructor( + public readonly c0: number, + c1?: number, + c2?: number, + c3?: number, + ) { + this.c1 = c1 ?? 0; + this.c2 = c2 ?? 0; + this.c3 = c3 ?? 0; + } + + /** + * Return the nth derivative of the polynomial. + * + * @param n - The number of times to differentiate the polynomial. + */ + public differentiate(n = 1): Polynomial { + switch (n) { + case 0: + return this; + case 1: + return new Polynomial(this.c1, 2 * this.c2, 3 * this.c3, 0); + case 2: + return new Polynomial(2 * this.c2, 6 * this.c3, 0, 0); + case 3: + return new Polynomial(6 * this.c3, 0, 0, 0); + default: + throw new Error('Unsupported derivative'); + } + } + + /** + * Evaluate the polynomial at the given value t. + * + * @param t - The value to sample at + */ + public eval(t: number): number; + /** + * Evaluate the nth derivative of the polynomial at the given value t. + * + * @param t - The value to sample at + * @param derivative - The derivative of the polynomial to sample from + */ + public eval(t: number, derivative: number): number; + public eval(t: number, derivative = 0): number { + if (derivative !== 0) { + return this.differentiate(derivative).eval(t); + } + return this.c3 * (t * t * t) + this.c2 * (t * t) + this.c1 * t + this.c0; + } + + /** + * Split the polynomial into two polynomials of the same overall shape. + * + * @param u - The point at which to split the polynomial. + */ + public split(u: number): [Polynomial, Polynomial] { + const d = 1 - u; + + const pre = new Polynomial( + this.c0, + this.c1 * u, + this.c2 * u * u, + this.c3 * u * u * u, + ); + const post = new Polynomial( + this.eval(0), + d * this.differentiate(1).eval(u), + ((d * d) / 2) * this.differentiate(2).eval(u), + ((d * d * d) / 6) * this.differentiate(3).eval(u), + ); + + return [pre, post]; + } + + /** + * Calculate the roots (values where this polynomial = 0). + * + * @remarks + * Depending on the degree of the polynomial, returns between 0 and 3 results. + */ + public roots(): number[] { + switch (this.degree) { + case 3: + return this.solveCubicRoots(); + case 2: + return this.solveQuadraticRoots(); + case 1: + return this.solveLinearRoot(); + case 0: + return []; + default: + throw new Error(`Unsupported polynomial degree: ${this.degree}`); + } + } + + /** + * Calculate the local extrema of the polynomial. + */ + public localExtrema(): number[] { + return this.differentiate().roots(); + } + + /** + * Calculate the local extrema of the polynomial in the unit interval. + */ + public localExtrema01(): number[] { + const all = this.localExtrema(); + const valids = []; + for (let i = 0; i < all.length; i++) { + const t = all[i]; + if (t >= 0 && t <= 1) { + valids.push(all[i]); + } + } + return valids; + } + + /** + * Return the output value range within the unit interval. + */ + public outputRange01(): number[] { + let range = [this.eval(0), this.eval(1)]; + + // Expands the minimum or maximum value of the range to contain the given + // value. + const encapsulate = (value: number) => { + if (range[1] > range[0]) { + range = [Math.min(range[0], value), Math.max(range[1], value)]; + } else { + range = [Math.min(range[1], value), Math.max(range[0], value)]; + } + }; + + this.localExtrema01().forEach(t => encapsulate(this.eval(t))); + + return range; + } + + private solveCubicRoots() { + const a = this.c0; + const b = this.c1; + const c = this.c2; + const d = this.c3; + + // First, depress the cubic to make it easier to solve + const aa = a * a; + const ac = a * c; + const bb = b * b; + const p = (3 * ac - bb) / (3 * aa); + const q = (2 * bb * b - 9 * ac * b + 27 * aa * d) / (27 * aa * a); + + const dpr = this.solveDepressedCubicRoots(p, q); + + // We now have the roots of the depressed cubic, now convert back to the + // normal cubic + const undepressRoot = (r: number) => r - b / (3 * a); + switch (dpr.length) { + case 1: + return [undepressRoot(dpr[0])]; + case 2: + return [undepressRoot(dpr[0]), undepressRoot(dpr[1])]; + case 3: + return [ + undepressRoot(dpr[0]), + undepressRoot(dpr[1]), + undepressRoot(dpr[2]), + ]; + default: + return []; + } + } + + private solveDepressedCubicRoots(p: number, q: number): number[] { + // t³+pt+q = 0 + + // Triple root - one solution. solve x³+q = 0 => x = cr(-q) + if (this.almostZero(p)) { + return [Math.cbrt(-q)]; + } + + const TAU = Math.PI * 2; + const discriminant = 4 * p * p * p + 27 * q * q; + if (discriminant < 0.00001) { + // Two or three roots guaranteed, use trig solution + const pre = 2 * Math.sqrt(-p / 3); + const acosInner = ((3 * q) / (2 * p)) * Math.sqrt(-3 / p); + + const getRoot = (k: number) => + pre * + Math.cos((1 / 3) * Math.acos(clamp(-1, 1, acosInner)) - (TAU / 3) * k); + + // If acos hits 0 or TAU/2, the offsets will have the same value, + // which means we have a double root plus one regular root on our hands + if (acosInner >= 0.9999) { + // two roots - one single and one double root + return [getRoot(0), getRoot(2)]; + } + + if (acosInner <= -0.9999) { + // two roots - one single and one double root + return [getRoot(1), getRoot(2)]; + } + + return [getRoot(0), getRoot(1), getRoot(2)]; + } + + if (discriminant > 0 && p < 0) { + // one root + const coshInner = + (1 / 3) * + Math.acosh(((-3 * Math.abs(q)) / (2 * p)) * Math.sqrt(-3 / p)); + const r = -2 * Math.sign(q) * Math.sqrt(-p / 3) * Math.cosh(coshInner); + return [r]; + } + + if (p > 0) { + // one root + const sinhInner = + (1 / 3) * Math.asinh(((3 * q) / (2 * p)) * Math.sqrt(3 / p)); + const r = -2 * Math.sqrt(p / 3) * Math.sinh(sinhInner); + return [r]; + } + + // no roots + return []; + } + + private solveQuadraticRoots() { + const a = this.c2; + const b = this.c1; + const c = this.c0; + const rootContent = b * b - 4 * a * c; + + if (this.almostZero(rootContent)) { + // two equivalent solutions at one point + return [-b / (2 * a)]; + } + + if (rootContent >= 0) { + const root = Math.sqrt(rootContent); + // crosses at two points + const r0 = (-b - root) / (2 * a); + const r1 = (-b + root) / (2 * a); + + return [Math.min(r0, r1), Math.max(r0, r1)]; + } + + return []; + } + + private solveLinearRoot() { + return [-this.c0 / this.c1]; + } + + private almostZero(value: number) { + return Math.abs(0 - value) <= Number.EPSILON; + } +} diff --git a/packages/2d/src/curves/Polynomial2D.ts b/packages/2d/src/curves/Polynomial2D.ts new file mode 100644 index 000000000..eb1c314bb --- /dev/null +++ b/packages/2d/src/curves/Polynomial2D.ts @@ -0,0 +1,62 @@ +import {BBox, Vector2} from '@motion-canvas/core/lib/types'; + +import {Polynomial} from './Polynomial'; + +export class Polynomial2D { + public readonly x: Polynomial; + public readonly y: Polynomial; + + public constructor(c0: Vector2, c1: Vector2, c2: Vector2, c3: Vector2); + public constructor(c0: Vector2, c1: Vector2, c2: Vector2); + public constructor(x: Polynomial, y: Polynomial); + public constructor( + public readonly c0: Vector2 | Polynomial, + public readonly c1: Vector2 | Polynomial, + public readonly c2?: Vector2, + public readonly c3?: Vector2, + ) { + if (c0 instanceof Polynomial) { + this.x = c0; + this.y = c1 as Polynomial; + } else if (c3 !== undefined) { + this.x = new Polynomial(c0.x, (c1 as Vector2).x, c2!.x, c3.x); + this.y = new Polynomial(c0.y, (c1 as Vector2).y, c2!.y, c3.y); + } else { + this.x = new Polynomial(c0.x, (c1 as Vector2).x, c2!.x); + this.y = new Polynomial(c0.y, (c1 as Vector2).y, c2!.y); + } + } + + public eval(t: number, derivative = 0): Vector2 { + return new Vector2( + this.x.differentiate(derivative).eval(t), + this.y.differentiate(derivative).eval(t), + ); + } + + public split(u: number): [Polynomial2D, Polynomial2D] { + const [xPre, xPost] = this.x.split(u); + const [yPre, yPost] = this.y.split(u); + return [new Polynomial2D(xPre, yPre), new Polynomial2D(xPost, yPost)]; + } + + public differentiate(n = 1): Polynomial2D { + return new Polynomial2D(this.x.differentiate(n), this.y.differentiate(n)); + } + + public evalDerivative(t: number): Vector2 { + return this.differentiate().eval(t); + } + + /** + * Calculate the tight axis-aligned bounds of the curve in the unit interval. + */ + public getBounds(): BBox { + const rangeX = this.x.outputRange01(); + const rangeY = this.y.outputRange01(); + return BBox.fromPoints( + new Vector2(Math.min(...rangeX), Math.max(...rangeY)), + new Vector2(Math.max(...rangeX), Math.min(...rangeY)), + ); + } +} diff --git a/packages/2d/src/curves/PolynomialSegment.ts b/packages/2d/src/curves/PolynomialSegment.ts new file mode 100644 index 000000000..6bad7f52a --- /dev/null +++ b/packages/2d/src/curves/PolynomialSegment.ts @@ -0,0 +1,108 @@ +import {Segment} from './Segment'; +import {BBox, Vector2} from '@motion-canvas/core/lib/types'; +import {UniformPolynomialCurveSampler} from './UniformPolynomialCurveSampler'; +import {moveTo} from '../utils'; +import {Polynomial2D} from './Polynomial2D'; +import {CurvePoint} from './CurvePoint'; + +export abstract class PolynomialSegment extends Segment { + protected readonly pointSampler: UniformPolynomialCurveSampler; + + public get arcLength(): number { + return this.length; + } + + public abstract get points(): Vector2[]; + + protected constructor( + protected readonly curve: Polynomial2D, + protected readonly length: number, + ) { + super(); + this.pointSampler = new UniformPolynomialCurveSampler(this); + } + + public getBBox(): BBox { + return this.curve.getBounds(); + } + + /** + * Evaluate the polynomial at the given t value. + * + * @param t - The t value at which to evaluate the curve. + */ + public eval(t: number): CurvePoint { + return { + position: this.curve.eval(t), + tangent: this.tangent(t), + }; + } + + /** + * Split the curve into two separate polynomials at the given t value. The two + * resulting curves form the same overall shape as the original curve. + * + * @param t - The t value at which to split the curve. + */ + public abstract split(t: number): [PolynomialSegment, PolynomialSegment]; + + public getPoint(distance: number): CurvePoint { + const closestPoint = this.pointSampler.pointAtDistance( + this.arcLength * distance, + ); + return {position: closestPoint.position, tangent: closestPoint.tangent}; + } + + public transformPoints(matrix: DOMMatrix): Vector2[] { + return this.points.map(point => point.transformAsPoint(matrix)); + } + + /** + * Return the tangent of the point that sits at the provided t value on the + * curve. + * + * @param t - The t value at which to evaluate the curve. + */ + public tangent(t: number): Vector2 { + return this.curve.evalDerivative(t).normalized; + } + + public draw( + context: CanvasRenderingContext2D | Path2D, + start = 0, + end = 1, + move = true, + ): [Vector2, Vector2, Vector2, Vector2] { + let curve: PolynomialSegment | null = null; + let startT = start; + let endT = end; + let points = this.points; + + if (start !== 0 || end !== 1) { + const startDistance = this.length * start; + const endDistance = this.length * end; + + startT = this.pointSampler.distanceToT(startDistance); + endT = this.pointSampler.distanceToT(endDistance); + endT = (endT - startT) / (1 - startT); + + const [, startSegment] = this.split(startT); + [curve] = startSegment.split(endT); + points = curve.points; + } + + if (move) { + moveTo(context, points[0]); + } + (curve ?? this).doDraw(context); + + return [ + points[0], + this.tangent(startT), + points.at(-1)!, + this.tangent(endT), + ]; + } + + protected abstract doDraw(context: CanvasRenderingContext2D | Path2D): void; +} diff --git a/packages/2d/src/curves/QuadBezierSegment.ts b/packages/2d/src/curves/QuadBezierSegment.ts new file mode 100644 index 000000000..930157c3a --- /dev/null +++ b/packages/2d/src/curves/QuadBezierSegment.ts @@ -0,0 +1,66 @@ +import {Vector2} from '@motion-canvas/core/lib/types'; +import {quadraticCurveTo} from '../utils'; +import {PolynomialSegment} from './PolynomialSegment'; +import {Polynomial2D} from './Polynomial2D'; + +/** + * A spline segment representing a quadratic Bézier curve. + */ +export class QuadBezierSegment extends PolynomialSegment { + private static el = document.createElementNS( + 'http://www.w3.org/2000/svg', + 'path', + ); + + public get points(): Vector2[] { + return [this.p0, this.p1, this.p2]; + } + + public constructor( + public readonly p0: Vector2, + public readonly p1: Vector2, + public readonly p2: Vector2, + ) { + super( + new Polynomial2D( + p0, + // 2*(-p0+p1) + p0.flipped.add(p1).scale(2), + // p0-2*p1+p2 + p0.sub(p1.scale(2)).add(p2), + ), + QuadBezierSegment.getLength(p0, p1, p2), + ); + } + + public split(t: number): [PolynomialSegment, PolynomialSegment] { + const a = new Vector2( + this.p0.x + (this.p1.x - this.p0.x) * t, + this.p0.y + (this.p1.y - this.p0.y) * t, + ); + const b = new Vector2( + this.p1.x + (this.p2.x - this.p1.x) * t, + this.p1.y + (this.p2.y - this.p1.y) * t, + ); + const p = new Vector2(a.x + (b.x - a.x) * t, a.y + (b.y - a.y) * t); + + const left = new QuadBezierSegment(this.p0, a, p); + const right = new QuadBezierSegment(p, b, this.p2); + + return [left, right]; + } + + protected static getLength(p0: Vector2, p1: Vector2, p2: Vector2): number { + // Let the browser do the work for us instead of calculating the arclength + // manually. + QuadBezierSegment.el.setAttribute( + 'd', + `M ${p0.x} ${p0.y} Q ${p1.x} ${p1.y} ${p2.x} ${p2.y}`, + ); + return QuadBezierSegment.el.getTotalLength(); + } + + protected override doDraw(context: CanvasRenderingContext2D | Path2D) { + quadraticCurveTo(context, this.p1, this.p2); + } +} diff --git a/packages/2d/src/curves/Segment.ts b/packages/2d/src/curves/Segment.ts index f88b4e261..a4584469e 100644 --- a/packages/2d/src/curves/Segment.ts +++ b/packages/2d/src/curves/Segment.ts @@ -1,4 +1,5 @@ import {Vector2} from '@motion-canvas/core/lib/types'; +import {CurvePoint} from './CurvePoint'; export abstract class Segment { public abstract draw( @@ -8,7 +9,7 @@ export abstract class Segment { move: boolean, ): [Vector2, Vector2, Vector2, Vector2]; - public abstract getPoint(distance: number): [Vector2, Vector2]; + public abstract getPoint(distance: number): CurvePoint; public abstract get arcLength(): number; } diff --git a/packages/2d/src/curves/UniformPolynomialCurveSampler.ts b/packages/2d/src/curves/UniformPolynomialCurveSampler.ts new file mode 100644 index 000000000..0d354724a --- /dev/null +++ b/packages/2d/src/curves/UniformPolynomialCurveSampler.ts @@ -0,0 +1,91 @@ +import {clamp, remap} from '@motion-canvas/core/lib/tweening'; +import {Vector2} from '@motion-canvas/core/lib/types'; +import {PolynomialSegment} from './PolynomialSegment'; +import {CurvePoint} from './CurvePoint'; + +/** + * Class to uniformly sample points on a given polynomial curve. + * + * @remarks + * In order to uniformly sample points from non-linear curves, this sampler + * re-parameterizes the curve by arclength. + */ +export class UniformPolynomialCurveSampler { + private sampledDistances: number[] = []; + + /** + * @param curve - The curve to sample + * @param samples - How many points to sample from the provided curve. The + * more points get sampled, the higher the resolution–and + * therefore precision–of the sampler. + */ + public constructor(private readonly curve: PolynomialSegment, samples = 20) { + this.resample(samples); + } + + /** + * Discard all previously sampled points and resample the provided number of + * points from the curve. + * + * @param samples - The number of points to sample. + */ + public resample(samples: number): void { + this.sampledDistances = [0]; + + let length = 0; + let previous: Vector2 = this.curve.eval(0).position; + for (let i = 1; i < samples; i++) { + const t = i / (samples - 1); + const curvePoint = this.curve.eval(t); + const segmentLength = previous.sub(curvePoint.position).magnitude; + + length += segmentLength; + + this.sampledDistances.push(length); + previous = curvePoint.position; + } + + // Account for any accumulated floating point errors and explicitly set the + // distance of the last point to the arclength of the curve. + this.sampledDistances[this.sampledDistances.length - 1] = + this.curve.arcLength; + } + + /** + * Return the point at the provided distance along the sampled curve's + * arclength. + * + * @param distance - The distance along the curve's arclength for which to + * retrieve the point. + */ + public pointAtDistance(distance: number): CurvePoint { + return this.curve.eval(this.distanceToT(distance)); + } + + /** + * Return the t value for the point at the provided distance along the sampled + * curve's arc length. + * + * @param distance - The distance along the arclength + */ + public distanceToT(distance: number): number { + const samples = this.sampledDistances.length; + distance = clamp(0, this.curve.arcLength, distance); + + for (let i = 0; i < samples; i++) { + const lower = this.sampledDistances[i]; + const upper = this.sampledDistances[i + 1]; + if (distance >= lower && distance <= upper) { + return remap( + lower, + upper, + i / (samples - 1), + (i + 1) / (samples - 1), + distance, + ); + } + } + + return 1; + } +} diff --git a/packages/2d/src/curves/getBezierSplineProfile.ts b/packages/2d/src/curves/getBezierSplineProfile.ts new file mode 100644 index 000000000..df38dd386 --- /dev/null +++ b/packages/2d/src/curves/getBezierSplineProfile.ts @@ -0,0 +1,224 @@ +import {CurveProfile} from './CurveProfile'; +import {CubicBezierSegment} from './CubicBezierSegment'; +import {KnotInfo} from './KnotInfo'; +import {Vector2} from '@motion-canvas/core/lib/types'; +import {QuadBezierSegment} from './QuadBezierSegment'; +import {clamp} from '@motion-canvas/core/lib/tweening'; +import {PolynomialSegment} from './PolynomialSegment'; + +function isCubicSegment( + segment: PolynomialSegment, +): segment is CubicBezierSegment { + return segment instanceof CubicBezierSegment; +} + +/** + * Update a given knot's handles to be a blend between the user provided handles + * and a set of auto calculated handles that smoothly connect the knot to its + * two neighboring knots. + * + * @param knot - The knot for which to calculate the handles + * @param previous - The previous knot in the spline, relative to the provided + * knot. + * @param next - The next knot in the spline, relative to the provided knot. + * @param smoothness - The desired smoothness of the spline. Affects the scaling + * of the auto calculated handles. + */ +function calculateSmoothHandles( + knot: KnotInfo, + previous: KnotInfo, + next: KnotInfo, + smoothness: number, +) { + if (knot.auto.start === 0 && knot.auto.end === 0) { + return; + } + + // See for reference: + // http://scaledinnovation.com/analytics/splines/aboutSplines.html + const distanceToPrev = knot.position.sub(previous.position).magnitude; + const distanceToNext = next.position.sub(knot.position).magnitude; + const fa = (smoothness * distanceToPrev) / (distanceToPrev + distanceToNext); + const fb = smoothness - fa; + const startHandle = new Vector2( + knot.position.x - fa * (next.position.x - previous.position.x), + knot.position.y - fa * (next.position.y - previous.position.y), + ); + const endHandle = new Vector2( + knot.position.x + fb * (next.position.x - previous.position.x), + knot.position.y + fb * (next.position.y - previous.position.y), + ); + + knot.startHandle = knot.startHandle.lerp(startHandle, knot.auto.start); + knot.endHandle = knot.endHandle.lerp(endHandle, knot.auto.end); +} + +/** + * Calculate the `minSin` value of the curve profile so that miter joins get + * taken into account properly. + */ +function updateMinSin(profile: CurveProfile) { + for (let i = 0; i < profile.segments.length; i++) { + const segmentA = profile.segments[i] as PolynomialSegment; + const segmentB = profile.segments[ + (i + 1) % profile.segments.length + ] as PolynomialSegment; + + // Quadratic Bézier segments will always join smoothly with the previous + // segment. This means that we can skip the segment since it's impossible + // to have a miter join between the two segments. + if (!isCubicSegment(segmentA) || !isCubicSegment(segmentB)) { + continue; + } + + const startVector = segmentA.p2.sub(segmentA.p3).normalized.safe; + const endVector = segmentB.p1.sub(segmentB.p0).normalized.safe; + const dot = startVector.dot(endVector); + + // A miter join can only occur if the handle is broken, so we can skip the + // segment if the handles are mirrored. + const isBroken = 1 - Math.abs(dot) > 0.0001; + if (!isBroken) { + continue; + } + + const angleBetween = Math.acos(clamp(-1, 1, dot)); + const angleSin = Math.sin(angleBetween / 2); + + profile.minSin = Math.min(profile.minSin, Math.abs(angleSin)); + } +} + +function addSegmentToProfile( + profile: CurveProfile, + p0: Vector2, + p1: Vector2, + p2: Vector2, + p3?: Vector2, +) { + const segment = + p3 !== undefined + ? new CubicBezierSegment(p0, p1, p2, p3) + : new QuadBezierSegment(p0, p1, p2); + profile.segments.push(segment); + profile.arcLength += segment.arcLength; +} + +/** + * Calculate the curve profile of a spline based on a set of knots. + * + * @param knots - The knots defining the spline + * @param closed - Whether the spline should be closed or not + * @param smoothness - The desired smoothness of the spline when using auto + * calculated handles. + */ +export function getBezierSplineProfile( + knots: KnotInfo[], + closed: boolean, + smoothness: number, +): CurveProfile { + const profile: CurveProfile = { + segments: [], + arcLength: 0, + minSin: 1, + }; + + // First, we want to calculate the actual handle positions for each knot. We + // do so using the knot's `auto` value to blend between the user-provided + // handles and the auto calculated smooth handles. + const numberOfKnots = knots.length; + for (let i = 0; i < numberOfKnots; i++) { + // Calculating the auto handles for a given knot requires both of the knot's + // neighboring knots. To make sure that this works properly for the first + // and last knots of the spline, we want to make sure to wrap around to the + // beginning and end of the array, respectively. + const prevIndex = (i - 1 + numberOfKnots) % numberOfKnots; + const nextIndex = (i + 1) % numberOfKnots; + calculateSmoothHandles( + knots[i], + knots[prevIndex], + knots[nextIndex], + smoothness, + ); + } + + const firstKnot = knots[0]; + const secondKnot = knots[1]; + + // Drawing the first and last segments of a spline has a few edge cases we + // need to consider: + // If the spline is not closed and the first knot should use the auto + // calculated handles, we want to draw a quadratic Bézier curve instead of a + // cubic one. + if (!closed && firstKnot.auto.start === 1 && firstKnot.auto.end === 1) { + addSegmentToProfile( + profile, + firstKnot.position, + secondKnot.startHandle, + secondKnot.position, + ); + } else { + // Otherwise, draw a cubic Bézier segment like we do for the other segments. + addSegmentToProfile( + profile, + firstKnot.position, + firstKnot.endHandle, + secondKnot.startHandle, + secondKnot.position, + ); + } + + // Add all intermediate spline segments as cubic Bézier curve segments. + for (let i = 1; i < numberOfKnots - 2; i++) { + const start = knots[i]; + const end = knots[i + 1]; + addSegmentToProfile( + profile, + start.position, + start.endHandle, + end.startHandle, + end.position, + ); + } + + const lastKnot = knots.at(-1)!; + const secondToLastKnot = knots.at(-2)!; + + if (knots.length > 2) { + // Similar to the first segment, we also want to draw the last segment as a + // quadratic Bézier curve if the curve is not closed and the knot should + // use the auto calculated handles. + if (!closed && lastKnot.auto.start === 1 && lastKnot.auto.end === 1) { + addSegmentToProfile( + profile, + secondToLastKnot.position, + secondToLastKnot.endHandle, + lastKnot.position, + ); + } else { + addSegmentToProfile( + profile, + secondToLastKnot.position, + secondToLastKnot.endHandle, + lastKnot.startHandle, + lastKnot.position, + ); + } + } + + // If the spline should be closed, add one final cubic Bézier segment + // connecting the last and first knots. + if (closed) { + addSegmentToProfile( + profile, + lastKnot.position, + lastKnot.endHandle, + firstKnot.startHandle, + firstKnot.position, + ); + } + + updateMinSin(profile); + + return profile; +} diff --git a/packages/2d/src/curves/getPointAtDistance.ts b/packages/2d/src/curves/getPointAtDistance.ts index 6fe174c82..fb2869d39 100644 --- a/packages/2d/src/curves/getPointAtDistance.ts +++ b/packages/2d/src/curves/getPointAtDistance.ts @@ -14,8 +14,7 @@ export function getPointAtDistance( length += segment.arcLength; if (length >= clamped) { const relative = (clamped - previousLength) / segment.arcLength; - const [position, tangent] = segment.getPoint(clamp(0, 1, relative)); - return {position, tangent}; + return segment.getPoint(clamp(0, 1, relative)); } } diff --git a/packages/2d/src/curves/index.ts b/packages/2d/src/curves/index.ts index 8368ed4f1..29cb1c19f 100644 --- a/packages/2d/src/curves/index.ts +++ b/packages/2d/src/curves/index.ts @@ -1,8 +1,14 @@ export * from './CircleSegment'; +export * from './CubicBezierSegment'; export * from './CurveDrawingInfo'; export * from './CurvePoint'; export * from './CurveProfile'; +export * from './getBezierSplineProfile'; export * from './getPointAtDistance'; export * from './getPolylineProfile'; +export * from './KnotInfo'; export * from './LineSegment'; +export * from './Polynomial'; +export * from './Polynomial2D'; +export * from './QuadBezierSegment'; export * from './Segment'; diff --git a/packages/2d/src/scenes/Scene2D.ts b/packages/2d/src/scenes/Scene2D.ts index 1a22984b7..178fea719 100644 --- a/packages/2d/src/scenes/Scene2D.ts +++ b/packages/2d/src/scenes/Scene2D.ts @@ -99,12 +99,14 @@ export class Scene2D extends GeneratorScene implements Inspectable { ): void { const node = this.getNode(element); if (node) { - node.drawOverlay( - context, - matrix - .scale(1 / this.resolutionScale, 1 / this.resolutionScale) - .multiplySelf(node.localToWorld()), - ); + this.execute(() => { + node.drawOverlay( + context, + matrix + .scale(1 / this.resolutionScale, 1 / this.resolutionScale) + .multiplySelf(node.localToWorld()), + ); + }); } } diff --git a/packages/2d/src/utils/CanvasUtils.ts b/packages/2d/src/utils/CanvasUtils.ts index 9c66af9fc..fef43e780 100644 --- a/packages/2d/src/utils/CanvasUtils.ts +++ b/packages/2d/src/utils/CanvasUtils.ts @@ -266,3 +266,27 @@ export function arc( counterclockwise, ); } + +export function bezierCurveTo( + context: CanvasRenderingContext2D | Path2D, + controlPoint1: Vector2, + controlPoint2: Vector2, + to: Vector2, +) { + context.bezierCurveTo( + controlPoint1.x, + controlPoint1.y, + controlPoint2.x, + controlPoint2.y, + to.x, + to.y, + ); +} + +export function quadraticCurveTo( + context: CanvasRenderingContext2D | Path2D, + controlPoint: Vector2, + to: Vector2, +) { + context.quadraticCurveTo(controlPoint.x, controlPoint.y, to.x, to.y); +}