From b3c6f3aee1162aab38fa65b36056d1f4f12fae6f Mon Sep 17 00:00:00 2001 From: Baku Hashimoto Date: Tue, 28 Nov 2023 02:44:43 +0900 Subject: [PATCH] Add basic functions for BBox and CubicBezier --- jest.test.ts | 14 +++++ package.json | 4 ++ src/BBox.test.ts | 25 ++++++++ src/BBox.ts | 22 +++++++ src/CubicBezier.test.ts | 44 ++++++++++++++ src/CubicBezier.ts | 129 +++++++++++++++++++++++++++++++++++++++- src/index.ts | 6 +- yarn.lock | 20 +++++++ 8 files changed, 261 insertions(+), 3 deletions(-) create mode 100644 jest.test.ts create mode 100644 src/BBox.test.ts create mode 100644 src/BBox.ts create mode 100644 src/CubicBezier.test.ts diff --git a/jest.test.ts b/jest.test.ts new file mode 100644 index 0000000..57ca54a --- /dev/null +++ b/jest.test.ts @@ -0,0 +1,14 @@ +import {expect} from '@jest/globals' + +beforeEach(() => { + const EPSILON = 1e-6 + + function nearlyEqual(a: number, b: number) { + return ( + Math.abs(a - b) <= EPSILON * Math.max(1, Math.abs(a), Math.abs(b)) || + undefined + ) + } + + expect.addEqualityTesters([nearlyEqual]) +}) diff --git a/package.json b/package.json index 5bf5eb0..d84df82 100644 --- a/package.json +++ b/package.json @@ -38,6 +38,7 @@ "homepage": "https://baku89.github.io/pathed", "devDependencies": { "@types/jest": "^29.5.4", + "@types/lodash": "^4.14.202", "@typescript-eslint/eslint-plugin": "^6.7.0", "@typescript-eslint/parser": "^6.7.0", "eslint": "^8.49.0", @@ -47,6 +48,7 @@ "eslint-plugin-unused-imports": "^3.0.0", "jest": "^29.7.0", "jest-runner-eslint": "^2.1.1", + "lodash": "^4.17.21", "prettier": "^3.0.3", "ts-jest": "^29.1.1", "ts-node": "^10.9.1", @@ -55,6 +57,8 @@ "typescript": "^5.2.2" }, "dependencies": { + "@types/bezier-js": "^4.1.3", + "bezier-js": "^6.1.4", "linearly": "^0.21.0" } } diff --git a/src/BBox.test.ts b/src/BBox.test.ts new file mode 100644 index 0000000..ba06677 --- /dev/null +++ b/src/BBox.test.ts @@ -0,0 +1,25 @@ +import '../jest.config' + +import {BBox, unite} from './BBox' + +describe('unite', () => { + it('should unite multiple bounding boxes correctly', () => { + const bbox1: BBox = [ + [0, 0], + [2, 2], + ] + const bbox2: BBox = [ + [1, 1], + [3, 3], + ] + const bbox3: BBox = [ + [-1, -1], + [1, 1], + ] + const result = unite(bbox1, bbox2, bbox3) + expect(result).toEqual([ + [-1, -1], + [3, 3], + ]) + }) +}) diff --git a/src/BBox.ts b/src/BBox.ts new file mode 100644 index 0000000..ad3bafd --- /dev/null +++ b/src/BBox.ts @@ -0,0 +1,22 @@ +import {vec2} from 'linearly' + +export type BBox = readonly [min: vec2, max: vec2] + +export function unite(...bboxes: BBox[]): BBox { + let minX = Infinity, + minY = Infinity, + maxX = -Infinity, + maxY = -Infinity + + for (const [min, max] of bboxes) { + minX = Math.min(minX, min[0]) + minY = Math.min(minY, min[1]) + maxX = Math.max(maxX, max[0]) + maxY = Math.max(maxY, max[1]) + } + + return [ + [minX, minY], + [maxX, maxY], + ] +} diff --git a/src/CubicBezier.test.ts b/src/CubicBezier.test.ts new file mode 100644 index 0000000..a1a2501 --- /dev/null +++ b/src/CubicBezier.test.ts @@ -0,0 +1,44 @@ +import '../jest.config' + +import {atT, bound, CubicBezier, length, normal} from './CubicBezier' + +describe('CubicBezier', () => { + const a: CubicBezier = [ + [0, 0], + [0, 1], + [1, 1], + [1, 0], + ] + const b: CubicBezier = [ + [0, 0], + [2, 0], + [1, 1], + [1, 0], + ] + + it('should compute the `length` correctly', () => { + expect(length(a)).toBeCloseTo(2, 10) + expect(length(b)).toBeCloseTo(2, 10) + }) + + it('should compute the `bound` correctly', () => { + expect(bound(a)).toEqual([ + [0, 0], + [1, 0.75], + ]) + expect(bound(b)).toEqual([ + [0, 0], + [1.25, 0.444444], + ]) + }) + + it('should compute the `atT` correctly', () => { + expect(atT(a, 0)).toEqual([0, 0]) + expect(atT(a, 0.5)).toEqual([0.5, 0.75]) + expect(atT(a, 1)).toEqual([1, 0]) + }) + + it('should compute the `normal` correctly', () => { + expect(normal(a, 0.5)).toEqual([0, 1]) + }) +}) diff --git a/src/CubicBezier.ts b/src/CubicBezier.ts index ed97722..993846c 100644 --- a/src/CubicBezier.ts +++ b/src/CubicBezier.ts @@ -1,8 +1,135 @@ +import {Bezier as BezierJS, Point} from 'bezier-js' import {vec2} from 'linearly' -export type CubicBezier = [ +export type CubicBezier = readonly [ start: vec2, control1: vec2, control2: vec2, end: vec2, ] + +export const toBezierJS = memoizeCubicBezierFunction( + (bezier: CubicBezier): BezierJS => { + const [start, control1, control2, end] = bezier + return new BezierJS( + start[0], + start[1], + control1[0], + control1[1], + control2[0], + control2[1], + end[0], + end[1] + ) + } +) + +function toPoint([x, y]: vec2): Point { + return {x, y} +} + +/** + * Calculates the length of the Bezier curve. Length is calculated using numerical approximation, specifically the Legendre-Gauss quadrature algorithm. + */ +export const length = memoizeCubicBezierFunction( + (bezier: CubicBezier): number => { + const bezierJS = toBezierJS(bezier) + return bezierJS.length() + } +) + +/** + * Calculates the bounding box of this Bezier curve. + */ +export const bound = memoizeCubicBezierFunction( + (bezier: CubicBezier): [vec2, vec2] => { + const bezierJS = toBezierJS(bezier) + const {x, y} = bezierJS.bbox() + + return [ + [x.min, y.min], + [x.max, y.max], + ] + } +) + +/** + * Calculates the point on the curve at the specified `t` value. + */ +export function atT(bezier: CubicBezier, t: number): vec2 { + const bezierJS = toBezierJS(bezier) + const {x, y} = bezierJS.get(t) + return [x, y] +} + +/** + * Calculates the curve tangent at the specified `t` value. Note that this yields a not-normalized vector. + */ +export function derivative(bezier: CubicBezier, t: number): vec2 { + const bezierJS = toBezierJS(bezier) + const {x, y} = bezierJS.derivative(t) + return [x, y] +} + +/** + * Calculates the curve tangent at the specified `t` value. Unlike {@link derivative}, this yields a normalized vector. + */ +export function tangent(bezier: CubicBezier, t: number): vec2 { + return vec2.normalize(derivative(bezier, t)) +} + +/** + * Calculates the curve normal at the specified `t` value. Note that this yields a normalized vector. + */ +export function normal(bezier: CubicBezier, t: number): vec2 { + const bezierJS = toBezierJS(bezier) + const {x, y} = bezierJS.normal(t) + return [x, y] +} + +/** + * Finds the on-curve point closest to the specific off-curve point + */ +export function project( + bezier: CubicBezier, + origin: vec2 +): {position: vec2; t?: number; distance?: number} { + const bezierJS = toBezierJS(bezier) + const {x, y, t, d} = bezierJS.project(toPoint(origin)) + return {position: [x, y], t, distance: d} +} + +function memoizeCubicBezierFunction(f: (bezier: CubicBezier) => T) { + const cache = new WeakMap< + vec2, + WeakMap>> + >() + + return (bezier: CubicBezier): T => { + const [start, control1, control2, end] = bezier + + if (!cache.has(start)) { + cache.set(start, new WeakMap()) + } + + const cache1 = cache.get(start)! + + if (!cache1.has(control1)) { + cache1.set(control1, new WeakMap()) + } + + const cache2 = cache1.get(control1)! + + if (!cache2.has(control2)) { + cache2.set(control2, new WeakMap()) + } + + const cache3 = cache2.get(control2)! + + if (!cache3.has(end)) { + cache3.set(end, f(bezier)) + } + + return cache3.get(end)! + } +} diff --git a/src/index.ts b/src/index.ts index a1e48ad..3169246 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,2 +1,4 @@ -export * from './CubicBezier' -export * from './Path' +export type * from './CubicBezier' +export * as CubicBezier from './CubicBezier' +export type * from './Path' +export * as Path from './Path' diff --git a/yarn.lock b/yarn.lock index 18027d3..0c541be 100644 --- a/yarn.lock +++ b/yarn.lock @@ -701,6 +701,11 @@ dependencies: "@babel/types" "^7.20.7" +"@types/bezier-js@^4.1.3": + version "4.1.3" + resolved "https://registry.yarnpkg.com/@types/bezier-js/-/bezier-js-4.1.3.tgz#237d4fe7e9aae7edd0c27a71f9f236f4ddc1c562" + integrity sha512-FNVVCu5mx/rJCWBxLTcL7oOajmGtWtBTDjq6DSUWUI12GeePivrZZXz+UgE0D6VYsLEjvExRO03z4hVtu3pTEQ== + "@types/graceful-fs@^4.1.3": version "4.1.6" resolved "https://registry.yarnpkg.com/@types/graceful-fs/-/graceful-fs-4.1.6.tgz#e14b2576a1c25026b7f02ede1de3b84c3a1efeae" @@ -740,6 +745,11 @@ resolved "https://registry.yarnpkg.com/@types/json-schema/-/json-schema-7.0.12.tgz#d70faba7039d5fca54c83c7dbab41051d2b6f6cb" integrity sha512-Hr5Jfhc9eYOQNPYO5WLDq/n4jqijdHNlDXjuAQkkt+mWdQR+XJToOHrsD4cPaMXpn6KO7y2+wM8AZEs8VpBLVA== +"@types/lodash@^4.14.202": + version "4.14.202" + resolved "https://registry.yarnpkg.com/@types/lodash/-/lodash-4.14.202.tgz#f09dbd2fb082d507178b2f2a5c7e74bd72ff98f8" + integrity sha512-OvlIYQK9tNneDlS0VN54LLd5uiPCBOp7gS5Z0f1mjoJYBrtStzgmJBxONW3U6OZqdtNzZPmn9BS/7WI7BFFcFQ== + "@types/node@*": version "20.6.0" resolved "https://registry.yarnpkg.com/@types/node/-/node-20.6.0.tgz#9d7daa855d33d4efec8aea88cd66db1c2f0ebe16" @@ -1061,6 +1071,11 @@ balanced-match@^1.0.0: resolved "https://registry.yarnpkg.com/balanced-match/-/balanced-match-1.0.2.tgz#e83e3a7e3f300b34cb9d87f615fa0cbf357690ee" integrity sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw== +bezier-js@^6.1.4: + version "6.1.4" + resolved "https://registry.yarnpkg.com/bezier-js/-/bezier-js-6.1.4.tgz#c7828f6c8900562b69d5040afb881bcbdad82001" + integrity sha512-PA0FW9ZpcHbojUCMu28z9Vg/fNkwTj5YhusSAjHHDfHDGLxJ6YUKrAN2vk1fP2MMOxVw4Oko16FMlRGVBGqLKg== + brace-expansion@^1.1.7: version "1.1.11" resolved "https://registry.yarnpkg.com/brace-expansion/-/brace-expansion-1.1.11.tgz#3c7fcbf529d87226f3d2f52b966ff5271eb441dd" @@ -2396,6 +2411,11 @@ lodash.merge@^4.6.2: resolved "https://registry.yarnpkg.com/lodash.merge/-/lodash.merge-4.6.2.tgz#558aa53b43b661e1925a0afdfa36a9a1085fe57a" integrity sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ== +lodash@^4.17.21: + version "4.17.21" + resolved "https://registry.yarnpkg.com/lodash/-/lodash-4.17.21.tgz#679591c564c3bffaae8454cf0b3df370c3d6911c" + integrity sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg== + lru-cache@^5.1.1: version "5.1.1" resolved "https://registry.yarnpkg.com/lru-cache/-/lru-cache-5.1.1.tgz#1da27e6710271947695daf6848e847f01d84b920"