feat: ✨ add geometry library
This commit is contained in:
1
packages/x6-geometry/README.md
Normal file
1
packages/x6-geometry/README.md
Normal file
@ -0,0 +1 @@
|
||||
# x6-geometry
|
10
packages/x6-geometry/jest.config.js
Normal file
10
packages/x6-geometry/jest.config.js
Normal file
@ -0,0 +1,10 @@
|
||||
module.exports = {
|
||||
transform: {
|
||||
'^.+\\.ts$': 'ts-jest',
|
||||
},
|
||||
testMatch: ['**/src/**/*.test.ts'],
|
||||
moduleFileExtensions: ['ts', 'tsx', 'js', 'jsx'],
|
||||
collectCoverage: true,
|
||||
coverageDirectory: './test/coverage',
|
||||
coverageReporters: ['lcov', 'text-summary'],
|
||||
}
|
119
packages/x6-geometry/package.json
Normal file
119
packages/x6-geometry/package.json
Normal file
@ -0,0 +1,119 @@
|
||||
{
|
||||
"private": true,
|
||||
"version": "1.0.0",
|
||||
"name": "@antv/x6-geometry",
|
||||
"description": "Some useful geometry operations.",
|
||||
"main": "lib/index.js",
|
||||
"module": "es/index.js",
|
||||
"unpkg": "dist/x6-geometry.js",
|
||||
"jsdelivr": "dist/x6-geometry.js",
|
||||
"types": "lib/index.d.ts",
|
||||
"files": [
|
||||
"dist",
|
||||
"es",
|
||||
"lib"
|
||||
],
|
||||
"keywords": [
|
||||
"geometry",
|
||||
"shape",
|
||||
"x6",
|
||||
"antv"
|
||||
],
|
||||
"scripts": {
|
||||
"clean:build": "rimraf dist es lib",
|
||||
"clean:coverage": "rimraf ./test/coverage",
|
||||
"clean": "run-p clean:build clean:coverage",
|
||||
"lint": "eslint 'src/**/*.{js,ts}?(x)' --fix",
|
||||
"build:esm": "tsc --module esnext --target es2015 --outDir ./es",
|
||||
"build:cjs": "tsc --module commonjs --target es5 --outDir ./lib",
|
||||
"build:umd": "rollup -c",
|
||||
"build:version": "node ../../scripts/version.js",
|
||||
"build:watch": "yarn build:esm --w",
|
||||
"build:watch:esm": "yarn build:esm --w",
|
||||
"build:watch:cjs": "yarn build:cjs --w",
|
||||
"build:dev": "run-p build:cjs build:esm",
|
||||
"build": "run-p build:version build:dev build:umd",
|
||||
"prebuild": "run-s lint clean",
|
||||
"test": "jest",
|
||||
"coveralls": "cat ./test/coverage/lcov.info | coveralls",
|
||||
"pretest": "run-p clean:coverage",
|
||||
"prepare": "run-s build:version test build",
|
||||
"precommit": "lint-staged"
|
||||
},
|
||||
"lint-staged": {
|
||||
"src/**/*.ts": [
|
||||
"eslint --fix"
|
||||
]
|
||||
},
|
||||
"inherits": [
|
||||
"@antv/x6-package-json/cli.json",
|
||||
"@antv/x6-package-json/jest.json",
|
||||
"@antv/x6-package-json/eslint.json",
|
||||
"@antv/x6-package-json/rollup.json"
|
||||
],
|
||||
"devDependencies": {
|
||||
"@rollup/plugin-commonjs": "^17.1.0",
|
||||
"@rollup/plugin-node-resolve": "^11.2.0",
|
||||
"@rollup/plugin-replace": "^2.4.1",
|
||||
"@rollup/plugin-typescript": "^8.2.0",
|
||||
"@types/jest": "^26.0.21",
|
||||
"@types/node": "^14.14.35",
|
||||
"@typescript-eslint/eslint-plugin": "^4.18.0",
|
||||
"@typescript-eslint/parser": "^4.18.0",
|
||||
"coveralls": "^3.1.0",
|
||||
"eslint": "^7.22.0",
|
||||
"eslint-config-airbnb-base": "^14.2.1",
|
||||
"eslint-config-prettier": "^8.1.0",
|
||||
"eslint-plugin-eslint-comments": "^3.2.0",
|
||||
"eslint-plugin-import": "^2.22.1",
|
||||
"eslint-plugin-jest": "^24.3.2",
|
||||
"eslint-plugin-jsx-a11y": "^6.4.1",
|
||||
"eslint-plugin-prettier": "^3.3.1",
|
||||
"eslint-plugin-promise": "^4.3.1",
|
||||
"eslint-plugin-react": "^7.22.0",
|
||||
"eslint-plugin-react-hooks": "^4.2.0",
|
||||
"eslint-plugin-unicorn": "^29.0.0",
|
||||
"fs-extra": "^9.1.0",
|
||||
"jest": "^26.6.3",
|
||||
"lint-staged": "^10.5.4",
|
||||
"npm-run-all": "^4.1.5",
|
||||
"postcss": "^8.2.8",
|
||||
"prettier": "^2.2.1",
|
||||
"pretty-quick": "^3.1.0",
|
||||
"rimraf": "^3.0.2",
|
||||
"rollup": "^2.42.2",
|
||||
"rollup-plugin-auto-external": "^2.0.0",
|
||||
"rollup-plugin-filesize": "^9.1.1",
|
||||
"rollup-plugin-postcss": "^4.0.0",
|
||||
"rollup-plugin-progress": "^1.1.2",
|
||||
"rollup-plugin-terser": "^7.0.2",
|
||||
"ts-jest": "^26.5.4",
|
||||
"ts-node": "^9.1.1",
|
||||
"tslib": "^2.1.0",
|
||||
"typescript": "^4.2.3"
|
||||
},
|
||||
"browserslist": [
|
||||
"last 2 versions",
|
||||
"Firefox ESR",
|
||||
"> 1%"
|
||||
],
|
||||
"author": {
|
||||
"name": "bubkoo",
|
||||
"email": "bubkoo.wy@gmail.com"
|
||||
},
|
||||
"contributors": [],
|
||||
"license": "MIT",
|
||||
"homepage": "https://github.com/antvis/x6",
|
||||
"bugs": {
|
||||
"url": "https://github.com/antvis/x6/issues"
|
||||
},
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "ssh://git@github.com/antvis/x6.git",
|
||||
"directory": "packages/x6"
|
||||
},
|
||||
"publishConfig": {
|
||||
"access": "public",
|
||||
"registry": "https://registry.npmjs.org"
|
||||
}
|
||||
}
|
12
packages/x6-geometry/rollup.config.js
Normal file
12
packages/x6-geometry/rollup.config.js
Normal file
@ -0,0 +1,12 @@
|
||||
import config from '../../configs/rollup-config'
|
||||
|
||||
export default config({
|
||||
output: [
|
||||
{
|
||||
name: 'X6Geometry',
|
||||
format: 'umd',
|
||||
file: 'dist/x6-geometry.js',
|
||||
sourcemap: true,
|
||||
},
|
||||
],
|
||||
})
|
26
packages/x6-geometry/src/angle.ts
Normal file
26
packages/x6-geometry/src/angle.ts
Normal file
@ -0,0 +1,26 @@
|
||||
export namespace Angle {
|
||||
/**
|
||||
* Converts radian angle to degree angle.
|
||||
* @param rad The radians to convert.
|
||||
*/
|
||||
export function toDeg(rad: number) {
|
||||
return ((180 * rad) / Math.PI) % 360
|
||||
}
|
||||
|
||||
/**
|
||||
* Converts degree angle to radian angle.
|
||||
* @param deg The degree angle to convert.
|
||||
* @param over360
|
||||
*/
|
||||
export const toRad = function (deg: number, over360 = false) {
|
||||
const d = over360 ? deg : deg % 360
|
||||
return (d * Math.PI) / 180
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the angle in degrees and clamps its value between `0` and `360`.
|
||||
*/
|
||||
export function normalize(angle: number) {
|
||||
return (angle % 360) + (angle < 0 ? 360 : 0)
|
||||
}
|
||||
}
|
936
packages/x6-geometry/src/curve.ts
Normal file
936
packages/x6-geometry/src/curve.ts
Normal file
@ -0,0 +1,936 @@
|
||||
import { Line } from './line'
|
||||
import { Point } from './point'
|
||||
import { Polyline } from './polyline'
|
||||
import { Rectangle } from './rectangle'
|
||||
import { Geometry } from './geometry'
|
||||
|
||||
export class Curve extends Geometry {
|
||||
start: Point
|
||||
end: Point
|
||||
controlPoint1: Point
|
||||
controlPoint2: Point
|
||||
|
||||
PRECISION = 3
|
||||
|
||||
protected get [Symbol.toStringTag]() {
|
||||
return Curve.toStringTag
|
||||
}
|
||||
|
||||
constructor(
|
||||
start: Point.PointLike | Point.PointData,
|
||||
controlPoint1: Point.PointLike | Point.PointData,
|
||||
controlPoint2: Point.PointLike | Point.PointData,
|
||||
end: Point.PointLike | Point.PointData,
|
||||
) {
|
||||
super()
|
||||
this.start = Point.create(start)
|
||||
this.controlPoint1 = Point.create(controlPoint1)
|
||||
this.controlPoint2 = Point.create(controlPoint2)
|
||||
this.end = Point.create(end)
|
||||
}
|
||||
|
||||
bbox() {
|
||||
const start = this.start
|
||||
const controlPoint1 = this.controlPoint1
|
||||
const controlPoint2 = this.controlPoint2
|
||||
const end = this.end
|
||||
|
||||
const x0 = start.x
|
||||
const y0 = start.y
|
||||
const x1 = controlPoint1.x
|
||||
const y1 = controlPoint1.y
|
||||
const x2 = controlPoint2.x
|
||||
const y2 = controlPoint2.y
|
||||
const x3 = end.x
|
||||
const y3 = end.y
|
||||
|
||||
const points = [] // local extremes
|
||||
const tvalues = [] // t values of local extremes
|
||||
const bounds: [number[], number[]] = [[], []]
|
||||
|
||||
let a
|
||||
let b
|
||||
let c
|
||||
let t
|
||||
let t1
|
||||
let t2
|
||||
let b2ac
|
||||
let sqrtb2ac
|
||||
|
||||
for (let i = 0; i < 2; i += 1) {
|
||||
if (i === 0) {
|
||||
b = 6 * x0 - 12 * x1 + 6 * x2
|
||||
a = -3 * x0 + 9 * x1 - 9 * x2 + 3 * x3
|
||||
c = 3 * x1 - 3 * x0
|
||||
} else {
|
||||
b = 6 * y0 - 12 * y1 + 6 * y2
|
||||
a = -3 * y0 + 9 * y1 - 9 * y2 + 3 * y3
|
||||
c = 3 * y1 - 3 * y0
|
||||
}
|
||||
|
||||
if (Math.abs(a) < 1e-12) {
|
||||
if (Math.abs(b) < 1e-12) {
|
||||
continue
|
||||
}
|
||||
|
||||
t = -c / b
|
||||
if (t > 0 && t < 1) tvalues.push(t)
|
||||
|
||||
continue
|
||||
}
|
||||
|
||||
b2ac = b * b - 4 * c * a
|
||||
sqrtb2ac = Math.sqrt(b2ac)
|
||||
|
||||
if (b2ac < 0) continue
|
||||
|
||||
t1 = (-b + sqrtb2ac) / (2 * a)
|
||||
if (t1 > 0 && t1 < 1) tvalues.push(t1)
|
||||
|
||||
t2 = (-b - sqrtb2ac) / (2 * a)
|
||||
if (t2 > 0 && t2 < 1) tvalues.push(t2)
|
||||
}
|
||||
|
||||
let x
|
||||
let y
|
||||
let mt
|
||||
let j = tvalues.length
|
||||
const jlen = j
|
||||
|
||||
while (j) {
|
||||
j -= 1
|
||||
t = tvalues[j]
|
||||
mt = 1 - t
|
||||
|
||||
x =
|
||||
mt * mt * mt * x0 +
|
||||
3 * mt * mt * t * x1 +
|
||||
3 * mt * t * t * x2 +
|
||||
t * t * t * x3
|
||||
bounds[0][j] = x
|
||||
|
||||
y =
|
||||
mt * mt * mt * y0 +
|
||||
3 * mt * mt * t * y1 +
|
||||
3 * mt * t * t * y2 +
|
||||
t * t * t * y3
|
||||
|
||||
bounds[1][j] = y
|
||||
points[j] = { X: x, Y: y }
|
||||
}
|
||||
|
||||
tvalues[jlen] = 0
|
||||
tvalues[jlen + 1] = 1
|
||||
|
||||
points[jlen] = { X: x0, Y: y0 }
|
||||
points[jlen + 1] = { X: x3, Y: y3 }
|
||||
|
||||
bounds[0][jlen] = x0
|
||||
bounds[1][jlen] = y0
|
||||
|
||||
bounds[0][jlen + 1] = x3
|
||||
bounds[1][jlen + 1] = y3
|
||||
|
||||
tvalues.length = jlen + 2
|
||||
bounds[0].length = jlen + 2
|
||||
bounds[1].length = jlen + 2
|
||||
points.length = jlen + 2
|
||||
|
||||
const left = Math.min.apply(null, bounds[0])
|
||||
const top = Math.min.apply(null, bounds[1])
|
||||
const right = Math.max.apply(null, bounds[0])
|
||||
const bottom = Math.max.apply(null, bounds[1])
|
||||
|
||||
return new Rectangle(left, top, right - left, bottom - top)
|
||||
}
|
||||
|
||||
closestPoint(
|
||||
p: Point.PointLike | Point.PointData,
|
||||
options: Curve.Options = {},
|
||||
) {
|
||||
return this.pointAtT(this.closestPointT(p, options))
|
||||
}
|
||||
|
||||
closestPointLength(
|
||||
p: Point.PointLike | Point.PointData,
|
||||
options: Curve.Options = {},
|
||||
) {
|
||||
const opts = this.getOptions(options)
|
||||
return this.lengthAtT(this.closestPointT(p, opts), opts)
|
||||
}
|
||||
|
||||
closestPointNormalizedLength(
|
||||
p: Point.PointLike | Point.PointData,
|
||||
options: Curve.Options = {},
|
||||
) {
|
||||
const opts = this.getOptions(options)
|
||||
const cpLength = this.closestPointLength(p, opts)
|
||||
if (!cpLength) {
|
||||
return 0
|
||||
}
|
||||
|
||||
const length = this.length(opts)
|
||||
if (length === 0) {
|
||||
return 0
|
||||
}
|
||||
|
||||
return cpLength / length
|
||||
}
|
||||
|
||||
closestPointT(
|
||||
p: Point.PointLike | Point.PointData,
|
||||
options: Curve.Options = {},
|
||||
) {
|
||||
const precision = this.getPrecision(options)
|
||||
const subdivisions = this.getDivisions(options)
|
||||
const precisionRatio = Math.pow(10, -precision) // eslint-disable-line
|
||||
|
||||
let investigatedSubdivision: Curve | null = null
|
||||
let investigatedSubdivisionStartT = 0
|
||||
let investigatedSubdivisionEndT = 0
|
||||
let distFromStart = 0
|
||||
let distFromEnd = 0
|
||||
let chordLength = 0
|
||||
let minSumDist: number | null = null
|
||||
|
||||
const count = subdivisions.length
|
||||
let piece = count > 0 ? 1 / count : 0
|
||||
|
||||
subdivisions.forEach((division, i) => {
|
||||
const startDist = division.start.distance(p)
|
||||
const endDist = division.end.distance(p)
|
||||
const sumDist = startDist + endDist
|
||||
if (minSumDist == null || sumDist < minSumDist) {
|
||||
investigatedSubdivision = division
|
||||
investigatedSubdivisionStartT = i * piece
|
||||
investigatedSubdivisionEndT = (i + 1) * piece
|
||||
|
||||
distFromStart = startDist
|
||||
distFromEnd = endDist
|
||||
minSumDist = sumDist
|
||||
chordLength = division.endpointDistance()
|
||||
}
|
||||
})
|
||||
|
||||
// Recursively divide investigated subdivision, until distance between
|
||||
// baselinePoint and closest path endpoint is within `10^(-precision)`,
|
||||
// then return the closest endpoint of that final subdivision.
|
||||
// eslint-disable-next-line
|
||||
while (true) {
|
||||
// check if we have reached at least one required observed precision
|
||||
// - calculated as: the difference in distances from point to start and end divided by the distance
|
||||
// - note that this function is not monotonic = it doesn't converge stably but has "teeth"
|
||||
// - the function decreases while one of the endpoints is fixed but "jumps" whenever we switch
|
||||
// - this criterion works well for points lying far away from the curve
|
||||
const startPrecisionRatio = distFromStart
|
||||
? Math.abs(distFromStart - distFromEnd!) / distFromStart
|
||||
: 0
|
||||
|
||||
const endPrecisionRatio =
|
||||
distFromEnd != null
|
||||
? Math.abs(distFromStart! - distFromEnd) / distFromEnd
|
||||
: 0
|
||||
|
||||
const hasRequiredPrecision =
|
||||
startPrecisionRatio < precisionRatio ||
|
||||
endPrecisionRatio < precisionRatio
|
||||
|
||||
// check if we have reached at least one required minimal distance
|
||||
// - calculated as: the subdivision chord length multiplied by precisionRatio
|
||||
// - calculation is relative so it will work for arbitrarily large/small curves and their subdivisions
|
||||
// - this is a backup criterion that works well for points lying "almost at" the curve
|
||||
const hasMiniStartDistance = distFromStart
|
||||
? distFromStart < chordLength * precisionRatio
|
||||
: true
|
||||
const hasMiniEndDistance = distFromEnd
|
||||
? distFromEnd < chordLength * precisionRatio
|
||||
: true
|
||||
const hasMiniDistance = hasMiniStartDistance || hasMiniEndDistance
|
||||
|
||||
if (hasRequiredPrecision || hasMiniDistance) {
|
||||
return distFromStart <= distFromEnd
|
||||
? investigatedSubdivisionStartT
|
||||
: investigatedSubdivisionEndT
|
||||
}
|
||||
|
||||
// otherwise, set up for next iteration
|
||||
const divided: [Curve, Curve] = investigatedSubdivision!.divide(0.5)
|
||||
piece /= 2
|
||||
|
||||
const startDist1 = divided[0].start.distance(p)
|
||||
const endDist1 = divided[0].end.distance(p)
|
||||
const sumDist1 = startDist1 + endDist1
|
||||
|
||||
const startDist2 = divided[1].start.distance(p)
|
||||
const endDist2 = divided[1].end.distance(p)
|
||||
const sumDist2 = startDist2 + endDist2
|
||||
|
||||
if (sumDist1 <= sumDist2) {
|
||||
investigatedSubdivision = divided[0]
|
||||
investigatedSubdivisionEndT -= piece
|
||||
distFromStart = startDist1
|
||||
distFromEnd = endDist1
|
||||
} else {
|
||||
investigatedSubdivision = divided[1]
|
||||
investigatedSubdivisionStartT += piece
|
||||
distFromStart = startDist2
|
||||
distFromEnd = endDist2
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
closestPointTangent(
|
||||
p: Point.PointLike | Point.PointData,
|
||||
options: Curve.Options = {},
|
||||
) {
|
||||
return this.tangentAtT(this.closestPointT(p, options))
|
||||
}
|
||||
|
||||
containsPoint(
|
||||
p: Point.PointLike | Point.PointData,
|
||||
options: Curve.Options = {},
|
||||
) {
|
||||
const polyline = this.toPolyline(options)
|
||||
return polyline.containsPoint(p)
|
||||
}
|
||||
|
||||
divideAt(ratio: number, options: Curve.Options = {}): [Curve, Curve] {
|
||||
if (ratio <= 0) {
|
||||
return this.divideAtT(0)
|
||||
}
|
||||
|
||||
if (ratio >= 1) {
|
||||
return this.divideAtT(1)
|
||||
}
|
||||
|
||||
const t = this.tAt(ratio, options)
|
||||
return this.divideAtT(t)
|
||||
}
|
||||
|
||||
divideAtLength(length: number, options: Curve.Options = {}): [Curve, Curve] {
|
||||
const t = this.tAtLength(length, options)
|
||||
return this.divideAtT(t)
|
||||
}
|
||||
|
||||
divide(t: number) {
|
||||
return this.divideAtT(t)
|
||||
}
|
||||
|
||||
divideAtT(t: number): [Curve, Curve] {
|
||||
const start = this.start
|
||||
const controlPoint1 = this.controlPoint1
|
||||
const controlPoint2 = this.controlPoint2
|
||||
const end = this.end
|
||||
|
||||
if (t <= 0) {
|
||||
return [
|
||||
new Curve(start, start, start, start),
|
||||
new Curve(start, controlPoint1, controlPoint2, end),
|
||||
]
|
||||
}
|
||||
|
||||
if (t >= 1) {
|
||||
return [
|
||||
new Curve(start, controlPoint1, controlPoint2, end),
|
||||
new Curve(end, end, end, end),
|
||||
]
|
||||
}
|
||||
|
||||
const dividerPoints = this.getSkeletonPoints(t)
|
||||
const startControl1 = dividerPoints.startControlPoint1
|
||||
const startControl2 = dividerPoints.startControlPoint2
|
||||
const divider = dividerPoints.divider
|
||||
const dividerControl1 = dividerPoints.dividerControlPoint1
|
||||
const dividerControl2 = dividerPoints.dividerControlPoint2
|
||||
|
||||
return [
|
||||
new Curve(start, startControl1, startControl2, divider),
|
||||
new Curve(divider, dividerControl1, dividerControl2, end),
|
||||
]
|
||||
}
|
||||
|
||||
endpointDistance() {
|
||||
return this.start.distance(this.end)
|
||||
}
|
||||
|
||||
getSkeletonPoints(t: number) {
|
||||
const start = this.start
|
||||
const control1 = this.controlPoint1
|
||||
const control2 = this.controlPoint2
|
||||
const end = this.end
|
||||
|
||||
// shortcuts for `t` values that are out of range
|
||||
if (t <= 0) {
|
||||
return {
|
||||
startControlPoint1: start.clone(),
|
||||
startControlPoint2: start.clone(),
|
||||
divider: start.clone(),
|
||||
dividerControlPoint1: control1.clone(),
|
||||
dividerControlPoint2: control2.clone(),
|
||||
}
|
||||
}
|
||||
|
||||
if (t >= 1) {
|
||||
return {
|
||||
startControlPoint1: control1.clone(),
|
||||
startControlPoint2: control2.clone(),
|
||||
divider: end.clone(),
|
||||
dividerControlPoint1: end.clone(),
|
||||
dividerControlPoint2: end.clone(),
|
||||
}
|
||||
}
|
||||
|
||||
const midpoint1 = new Line(start, control1).pointAt(t)
|
||||
const midpoint2 = new Line(control1, control2).pointAt(t)
|
||||
const midpoint3 = new Line(control2, end).pointAt(t)
|
||||
|
||||
const subControl1 = new Line(midpoint1, midpoint2).pointAt(t)
|
||||
const subControl2 = new Line(midpoint2, midpoint3).pointAt(t)
|
||||
|
||||
const divideLine = new Line(subControl1, subControl2).pointAt(t)
|
||||
|
||||
return {
|
||||
startControlPoint1: midpoint1,
|
||||
startControlPoint2: subControl1,
|
||||
divider: divideLine,
|
||||
dividerControlPoint1: subControl2,
|
||||
dividerControlPoint2: midpoint3,
|
||||
}
|
||||
}
|
||||
|
||||
getSubdivisions(options: Curve.Options = {}): Curve[] {
|
||||
const precision = this.getPrecision(options)
|
||||
let subdivisions = [
|
||||
new Curve(this.start, this.controlPoint1, this.controlPoint2, this.end),
|
||||
]
|
||||
|
||||
if (precision === 0) {
|
||||
return subdivisions
|
||||
}
|
||||
|
||||
let previousLength = this.endpointDistance()
|
||||
const precisionRatio = Math.pow(10, -precision) // eslint-disable-line
|
||||
|
||||
// Recursively divide curve at `t = 0.5`, until the difference between
|
||||
// observed length at subsequent iterations is lower than precision.
|
||||
let iteration = 0
|
||||
// eslint-disable-next-line
|
||||
while (true) {
|
||||
iteration += 1
|
||||
|
||||
const divisions: Curve[] = []
|
||||
subdivisions.forEach((c) => {
|
||||
// dividing at t = 0.5 (not at middle length!)
|
||||
const divided = c.divide(0.5)
|
||||
divisions.push(divided[0], divided[1])
|
||||
})
|
||||
|
||||
// measure new length
|
||||
const length = divisions.reduce(
|
||||
(memo, c) => memo + c.endpointDistance(),
|
||||
0,
|
||||
)
|
||||
|
||||
// check if we have reached required observed precision
|
||||
// sine-like curves may have the same observed length in iteration 0 and 1 - skip iteration 1
|
||||
// not a problem for further iterations because cubic curves cannot have more than two local extrema
|
||||
// (i.e. cubic curves cannot intersect the baseline more than once)
|
||||
// therefore two subsequent iterations cannot produce sampling with equal length
|
||||
const ratio = length !== 0 ? (length - previousLength) / length : 0
|
||||
if (iteration > 1 && ratio < precisionRatio) {
|
||||
return divisions
|
||||
}
|
||||
|
||||
subdivisions = divisions
|
||||
previousLength = length
|
||||
}
|
||||
}
|
||||
|
||||
length(options: Curve.Options = {}) {
|
||||
const divisions = this.getDivisions(options)
|
||||
return divisions.reduce((memo, c) => {
|
||||
return memo + c.endpointDistance()
|
||||
}, 0)
|
||||
}
|
||||
|
||||
lengthAtT(t: number, options: Curve.Options = {}) {
|
||||
if (t <= 0) {
|
||||
return 0
|
||||
}
|
||||
|
||||
const precision =
|
||||
options.precision === undefined ? this.PRECISION : options.precision
|
||||
const subCurve = this.divide(t)[0]
|
||||
return subCurve.length({ precision })
|
||||
}
|
||||
|
||||
pointAt(ratio: number, options: Curve.Options = {}) {
|
||||
if (ratio <= 0) {
|
||||
return this.start.clone()
|
||||
}
|
||||
|
||||
if (ratio >= 1) {
|
||||
return this.end.clone()
|
||||
}
|
||||
|
||||
const t = this.tAt(ratio, options)
|
||||
return this.pointAtT(t)
|
||||
}
|
||||
|
||||
pointAtLength(length: number, options: Curve.Options = {}) {
|
||||
const t = this.tAtLength(length, options)
|
||||
return this.pointAtT(t)
|
||||
}
|
||||
|
||||
pointAtT(t: number) {
|
||||
if (t <= 0) {
|
||||
return this.start.clone()
|
||||
}
|
||||
|
||||
if (t >= 1) {
|
||||
return this.end.clone()
|
||||
}
|
||||
|
||||
return this.getSkeletonPoints(t).divider
|
||||
}
|
||||
|
||||
isDifferentiable() {
|
||||
const start = this.start
|
||||
const control1 = this.controlPoint1
|
||||
const control2 = this.controlPoint2
|
||||
const end = this.end
|
||||
|
||||
return !(
|
||||
start.equals(control1) &&
|
||||
control1.equals(control2) &&
|
||||
control2.equals(end)
|
||||
)
|
||||
}
|
||||
|
||||
tangentAt(ratio: number, options: Curve.Options = {}) {
|
||||
if (!this.isDifferentiable()) return null
|
||||
|
||||
if (ratio < 0) {
|
||||
ratio = 0 // eslint-disable-line
|
||||
} else if (ratio > 1) {
|
||||
ratio = 1 // eslint-disable-line
|
||||
}
|
||||
|
||||
const t = this.tAt(ratio, options)
|
||||
return this.tangentAtT(t)
|
||||
}
|
||||
|
||||
tangentAtLength(length: number, options: Curve.Options = {}) {
|
||||
if (!this.isDifferentiable()) {
|
||||
return null
|
||||
}
|
||||
|
||||
const t = this.tAtLength(length, options)
|
||||
return this.tangentAtT(t)
|
||||
}
|
||||
|
||||
tangentAtT(t: number) {
|
||||
if (!this.isDifferentiable()) {
|
||||
return null
|
||||
}
|
||||
|
||||
if (t < 0) {
|
||||
t = 0 // eslint-disable-line
|
||||
}
|
||||
|
||||
if (t > 1) {
|
||||
t = 1 // eslint-disable-line
|
||||
}
|
||||
|
||||
const skeletonPoints = this.getSkeletonPoints(t)
|
||||
const p1 = skeletonPoints.startControlPoint2
|
||||
const p2 = skeletonPoints.dividerControlPoint1
|
||||
|
||||
const tangentStart = skeletonPoints.divider
|
||||
const tangentLine = new Line(p1, p2)
|
||||
// move so that tangent line starts at the point requested
|
||||
tangentLine.translate(tangentStart.x - p1.x, tangentStart.y - p1.y)
|
||||
return tangentLine
|
||||
}
|
||||
|
||||
protected getPrecision(options: Curve.Options = {}) {
|
||||
return options.precision == null ? this.PRECISION : options.precision
|
||||
}
|
||||
|
||||
protected getDivisions(options: Curve.Options = {}) {
|
||||
if (options.subdivisions != null) {
|
||||
return options.subdivisions
|
||||
}
|
||||
|
||||
const precision = this.getPrecision(options)
|
||||
return this.getSubdivisions({ precision })
|
||||
}
|
||||
|
||||
protected getOptions(options: Curve.Options = {}): Curve.Options {
|
||||
const precision = this.getPrecision(options)
|
||||
const subdivisions = this.getDivisions(options)
|
||||
return { precision, subdivisions }
|
||||
}
|
||||
|
||||
protected tAt(ratio: number, options: Curve.Options = {}) {
|
||||
if (ratio <= 0) {
|
||||
return 0
|
||||
}
|
||||
if (ratio >= 1) {
|
||||
return 1
|
||||
}
|
||||
|
||||
const opts = this.getOptions(options)
|
||||
const total = this.length(opts)
|
||||
const length = total * ratio
|
||||
return this.tAtLength(length, opts)
|
||||
}
|
||||
|
||||
protected tAtLength(length: number, options: Curve.Options = {}) {
|
||||
let fromStart = true
|
||||
if (length < 0) {
|
||||
fromStart = false
|
||||
length = -length // eslint-disable-line
|
||||
}
|
||||
|
||||
const precision = this.getPrecision(options)
|
||||
const subdivisions = this.getDivisions(options)
|
||||
const opts = { precision, subdivisions }
|
||||
|
||||
let investigatedSubdivision: Curve | null = null
|
||||
let investigatedSubdivisionStartT: number
|
||||
let investigatedSubdivisionEndT: number
|
||||
let baselinePointDistFromStart = 0
|
||||
let baselinePointDistFromEnd = 0
|
||||
let memo = 0
|
||||
|
||||
const count = subdivisions.length
|
||||
let piece = count > 0 ? 1 / count : 0
|
||||
|
||||
for (let i = 0; i < count; i += 1) {
|
||||
const index = fromStart ? i : count - 1 - i
|
||||
const division = subdivisions[i]
|
||||
const dist = division.endpointDistance()
|
||||
|
||||
if (length <= memo + dist) {
|
||||
investigatedSubdivision = division
|
||||
investigatedSubdivisionStartT = index * piece
|
||||
investigatedSubdivisionEndT = (index + 1) * piece
|
||||
|
||||
baselinePointDistFromStart = fromStart
|
||||
? length - memo
|
||||
: dist + memo - length
|
||||
baselinePointDistFromEnd = fromStart
|
||||
? dist + memo - length
|
||||
: length - memo
|
||||
|
||||
break
|
||||
}
|
||||
|
||||
memo += dist
|
||||
}
|
||||
|
||||
if (investigatedSubdivision == null) {
|
||||
return fromStart ? 1 : 0
|
||||
}
|
||||
|
||||
// note that precision affects what length is recorded
|
||||
// (imprecise measurements underestimate length by up to 10^(-precision) of the precise length)
|
||||
// e.g. at precision 1, the length may be underestimated by up to 10% and cause this function to return 1
|
||||
|
||||
const total = this.length(opts)
|
||||
const precisionRatio = Math.pow(10, -precision) // eslint-disable-line
|
||||
|
||||
// recursively divide investigated subdivision:
|
||||
// until distance between baselinePoint and closest path endpoint is within 10^(-precision)
|
||||
// then return the closest endpoint of that final subdivision
|
||||
// eslint-disable-next-line
|
||||
while (true) {
|
||||
let ratio
|
||||
|
||||
ratio = total !== 0 ? baselinePointDistFromStart / total : 0
|
||||
if (ratio < precisionRatio) {
|
||||
return investigatedSubdivisionStartT!
|
||||
}
|
||||
|
||||
ratio = total !== 0 ? baselinePointDistFromEnd / total : 0
|
||||
if (ratio < precisionRatio) {
|
||||
return investigatedSubdivisionEndT!
|
||||
}
|
||||
|
||||
// otherwise, set up for next iteration
|
||||
let newBaselinePointDistFromStart
|
||||
let newBaselinePointDistFromEnd
|
||||
|
||||
const divided: [Curve, Curve] = investigatedSubdivision.divide(0.5)
|
||||
piece /= 2
|
||||
|
||||
const baseline1Length = divided[0].endpointDistance()
|
||||
const baseline2Length = divided[1].endpointDistance()
|
||||
|
||||
if (baselinePointDistFromStart <= baseline1Length) {
|
||||
investigatedSubdivision = divided[0]
|
||||
investigatedSubdivisionEndT! -= piece
|
||||
|
||||
newBaselinePointDistFromStart = baselinePointDistFromStart
|
||||
newBaselinePointDistFromEnd =
|
||||
baseline1Length - newBaselinePointDistFromStart
|
||||
} else {
|
||||
investigatedSubdivision = divided[1]
|
||||
investigatedSubdivisionStartT! += piece
|
||||
|
||||
newBaselinePointDistFromStart =
|
||||
baselinePointDistFromStart - baseline1Length
|
||||
newBaselinePointDistFromEnd =
|
||||
baseline2Length - newBaselinePointDistFromStart
|
||||
}
|
||||
|
||||
baselinePointDistFromStart = newBaselinePointDistFromStart
|
||||
baselinePointDistFromEnd = newBaselinePointDistFromEnd
|
||||
}
|
||||
}
|
||||
|
||||
toPoints(options: Curve.Options = {}) {
|
||||
const subdivisions = this.getDivisions(options)
|
||||
const points = [subdivisions[0].start.clone()]
|
||||
subdivisions.forEach((c) => points.push(c.end.clone()))
|
||||
return points
|
||||
}
|
||||
|
||||
toPolyline(options: Curve.Options = {}) {
|
||||
return new Polyline(this.toPoints(options))
|
||||
}
|
||||
|
||||
scale(sx: number, sy: number, origin?: Point.PointLike | Point.PointData) {
|
||||
this.start.scale(sx, sy, origin)
|
||||
this.controlPoint1.scale(sx, sy, origin)
|
||||
this.controlPoint2.scale(sx, sy, origin)
|
||||
this.end.scale(sx, sy, origin)
|
||||
return this
|
||||
}
|
||||
|
||||
rotate(angle: number, origin?: Point.PointLike | Point.PointData) {
|
||||
this.start.rotate(angle, origin)
|
||||
this.controlPoint1.rotate(angle, origin)
|
||||
this.controlPoint2.rotate(angle, origin)
|
||||
this.end.rotate(angle, origin)
|
||||
return this
|
||||
}
|
||||
|
||||
translate(tx: number, ty: number): this
|
||||
translate(p: Point.PointLike | Point.PointData): this
|
||||
translate(tx: number | Point.PointLike | Point.PointData, ty?: number) {
|
||||
if (typeof tx === 'number') {
|
||||
this.start.translate(tx, ty as number)
|
||||
this.controlPoint1.translate(tx, ty as number)
|
||||
this.controlPoint2.translate(tx, ty as number)
|
||||
this.end.translate(tx, ty as number)
|
||||
} else {
|
||||
this.start.translate(tx)
|
||||
this.controlPoint1.translate(tx)
|
||||
this.controlPoint2.translate(tx)
|
||||
this.end.translate(tx)
|
||||
}
|
||||
|
||||
return this
|
||||
}
|
||||
|
||||
equals(c: Curve) {
|
||||
return (
|
||||
c != null &&
|
||||
this.start.equals(c.start) &&
|
||||
this.controlPoint1.equals(c.controlPoint1) &&
|
||||
this.controlPoint2.equals(c.controlPoint2) &&
|
||||
this.end.equals(c.end)
|
||||
)
|
||||
}
|
||||
|
||||
clone() {
|
||||
return new Curve(
|
||||
this.start,
|
||||
this.controlPoint1,
|
||||
this.controlPoint2,
|
||||
this.end,
|
||||
)
|
||||
}
|
||||
|
||||
toJSON() {
|
||||
return {
|
||||
start: this.start.toJSON(),
|
||||
controlPoint1: this.controlPoint1.toJSON(),
|
||||
controlPoint2: this.controlPoint2.toJSON(),
|
||||
end: this.end.toJSON(),
|
||||
}
|
||||
}
|
||||
|
||||
serialize() {
|
||||
return [
|
||||
this.start.serialize(),
|
||||
this.controlPoint1.serialize(),
|
||||
this.controlPoint2.serialize(),
|
||||
this.end.serialize(),
|
||||
].join(' ')
|
||||
}
|
||||
}
|
||||
|
||||
export namespace Curve {
|
||||
export const toStringTag = `X6.Geometry.${Curve.name}`
|
||||
|
||||
export function isCurve(instance: any): instance is Curve {
|
||||
if (instance == null) {
|
||||
return false
|
||||
}
|
||||
|
||||
if (instance instanceof Curve) {
|
||||
return true
|
||||
}
|
||||
|
||||
const tag = instance[Symbol.toStringTag]
|
||||
const curve = instance as Curve
|
||||
|
||||
if (
|
||||
(tag == null || tag === toStringTag) &&
|
||||
Point.isPoint(curve.start) &&
|
||||
Point.isPoint(curve.controlPoint1) &&
|
||||
Point.isPoint(curve.controlPoint2) &&
|
||||
Point.isPoint(curve.end) &&
|
||||
typeof curve.toPoints === 'function' &&
|
||||
typeof curve.toPolyline === 'function'
|
||||
) {
|
||||
return true
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
export namespace Curve {
|
||||
export interface Options {
|
||||
precision?: number
|
||||
subdivisions?: Curve[]
|
||||
}
|
||||
}
|
||||
export namespace Curve {
|
||||
function getFirstControlPoints(rhs: number[]) {
|
||||
const n = rhs.length
|
||||
const x = [] // `x` is a solution vector.
|
||||
const tmp = []
|
||||
let b = 2.0
|
||||
|
||||
x[0] = rhs[0] / b
|
||||
|
||||
// Decomposition and forward substitution.
|
||||
for (let i = 1; i < n; i += 1) {
|
||||
tmp[i] = 1 / b
|
||||
b = (i < n - 1 ? 4.0 : 3.5) - tmp[i]
|
||||
x[i] = (rhs[i] - x[i - 1]) / b
|
||||
}
|
||||
|
||||
for (let i = 1; i < n; i += 1) {
|
||||
// Backsubstitution.
|
||||
x[n - i - 1] -= tmp[n - i] * x[n - i]
|
||||
}
|
||||
|
||||
return x
|
||||
}
|
||||
|
||||
function getCurveControlPoints(
|
||||
points: (Point.PointLike | Point.PointData)[],
|
||||
) {
|
||||
const knots = points.map((p) => Point.clone(p))
|
||||
const firstControlPoints = []
|
||||
const secondControlPoints = []
|
||||
const n = knots.length - 1
|
||||
|
||||
// Special case: Bezier curve should be a straight line.
|
||||
if (n === 1) {
|
||||
// 3P1 = 2P0 + P3
|
||||
firstControlPoints[0] = new Point(
|
||||
(2 * knots[0].x + knots[1].x) / 3,
|
||||
(2 * knots[0].y + knots[1].y) / 3,
|
||||
)
|
||||
|
||||
// P2 = 2P1 – P0
|
||||
secondControlPoints[0] = new Point(
|
||||
2 * firstControlPoints[0].x - knots[0].x,
|
||||
2 * firstControlPoints[0].y - knots[0].y,
|
||||
)
|
||||
|
||||
return [firstControlPoints, secondControlPoints]
|
||||
}
|
||||
|
||||
// Calculate first Bezier control points.
|
||||
// Right hand side vector.
|
||||
const rhs = []
|
||||
|
||||
// Set right hand side X values.
|
||||
for (let i = 1; i < n - 1; i += 1) {
|
||||
rhs[i] = 4 * knots[i].x + 2 * knots[i + 1].x
|
||||
}
|
||||
|
||||
rhs[0] = knots[0].x + 2 * knots[1].x
|
||||
rhs[n - 1] = (8 * knots[n - 1].x + knots[n].x) / 2.0
|
||||
|
||||
// Get first control points X-values.
|
||||
const x = getFirstControlPoints(rhs)
|
||||
|
||||
// Set right hand side Y values.
|
||||
for (let i = 1; i < n - 1; i += 1) {
|
||||
rhs[i] = 4 * knots[i].y + 2 * knots[i + 1].y
|
||||
}
|
||||
|
||||
rhs[0] = knots[0].y + 2 * knots[1].y
|
||||
rhs[n - 1] = (8 * knots[n - 1].y + knots[n].y) / 2.0
|
||||
|
||||
// Get first control points Y-values.
|
||||
const y = getFirstControlPoints(rhs)
|
||||
|
||||
// Fill output arrays.
|
||||
for (let i = 0; i < n; i += 1) {
|
||||
// First control point.
|
||||
firstControlPoints.push(new Point(x[i], y[i]))
|
||||
|
||||
// Second control point.
|
||||
if (i < n - 1) {
|
||||
secondControlPoints.push(
|
||||
new Point(
|
||||
2 * knots[i + 1].x - x[i + 1],
|
||||
2 * knots[i + 1].y - y[i + 1],
|
||||
),
|
||||
)
|
||||
} else {
|
||||
secondControlPoints.push(
|
||||
new Point((knots[n].x + x[n - 1]) / 2, (knots[n].y + y[n - 1]) / 2),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
return [firstControlPoints, secondControlPoints]
|
||||
}
|
||||
|
||||
export function throughPoints(points: (Point.PointLike | Point.PointData)[]) {
|
||||
if (points == null || (Array.isArray(points) && points.length < 2)) {
|
||||
throw new Error('At least 2 points are required')
|
||||
}
|
||||
|
||||
const controlPoints = getCurveControlPoints(points)
|
||||
|
||||
const curves = []
|
||||
for (let i = 0, ii = controlPoints[0].length; i < ii; i += 1) {
|
||||
const controlPoint1 = new Point(
|
||||
controlPoints[0][i].x,
|
||||
controlPoints[0][i].y,
|
||||
)
|
||||
const controlPoint2 = new Point(
|
||||
controlPoints[1][i].x,
|
||||
controlPoints[1][i].y,
|
||||
)
|
||||
|
||||
curves.push(
|
||||
new Curve(points[i], controlPoint1, controlPoint2, points[i + 1]),
|
||||
)
|
||||
}
|
||||
|
||||
return curves
|
||||
}
|
||||
}
|
262
packages/x6-geometry/src/ellipse.test.ts
Normal file
262
packages/x6-geometry/src/ellipse.test.ts
Normal file
@ -0,0 +1,262 @@
|
||||
import { Ellipse } from './ellipse'
|
||||
import { Line } from './line'
|
||||
import { Point } from './point'
|
||||
import { Rectangle } from './rectangle'
|
||||
|
||||
describe('ellipse', () => {
|
||||
describe('#constructor', () => {
|
||||
it('should create an ellipse instance', () => {
|
||||
expect(new Ellipse()).toBeInstanceOf(Ellipse)
|
||||
expect(new Ellipse(1)).toBeInstanceOf(Ellipse)
|
||||
expect(new Ellipse(1, 2)).toBeInstanceOf(Ellipse)
|
||||
expect(new Ellipse(1, 2, 3)).toBeInstanceOf(Ellipse)
|
||||
expect(new Ellipse(1, 2, 3, 4)).toBeInstanceOf(Ellipse)
|
||||
expect(new Ellipse(1, 2, 3, 4).x).toEqual(1)
|
||||
expect(new Ellipse(1, 2, 3, 4).y).toEqual(2)
|
||||
expect(new Ellipse(1, 2, 3, 4).a).toEqual(3)
|
||||
expect(new Ellipse(1, 2, 3, 4).b).toEqual(4)
|
||||
expect(new Ellipse().equals(new Ellipse(0, 0, 0, 0)))
|
||||
})
|
||||
|
||||
it('should return the center point', () => {
|
||||
const ellipse = new Ellipse(1, 2, 3, 4)
|
||||
expect(ellipse.getCenter().equals({ x: 1, y: 2 }))
|
||||
expect(ellipse.center.equals({ x: 1, y: 2 }))
|
||||
})
|
||||
})
|
||||
|
||||
describe('#Ellipse.create', () => {
|
||||
it('should create an ellipse from number', () => {
|
||||
const ellipse = Ellipse.create(1, 2, 3, 4)
|
||||
expect(ellipse.x).toEqual(1)
|
||||
expect(ellipse.y).toEqual(2)
|
||||
expect(ellipse.a).toEqual(3)
|
||||
expect(ellipse.b).toEqual(4)
|
||||
})
|
||||
|
||||
it('should create an ellipse from Ellipse instance', () => {
|
||||
const ellipse = Ellipse.create(new Ellipse(1, 2, 3, 4))
|
||||
expect(ellipse.x).toEqual(1)
|
||||
expect(ellipse.y).toEqual(2)
|
||||
expect(ellipse.a).toEqual(3)
|
||||
expect(ellipse.b).toEqual(4)
|
||||
})
|
||||
|
||||
it('should create an ellipse from EllipseLike', () => {
|
||||
const ellipse = Ellipse.create({ x: 1, y: 2, a: 3, b: 4 })
|
||||
expect(ellipse.x).toEqual(1)
|
||||
expect(ellipse.y).toEqual(2)
|
||||
expect(ellipse.a).toEqual(3)
|
||||
expect(ellipse.b).toEqual(4)
|
||||
})
|
||||
|
||||
it('should create an ellipse from EllipseData', () => {
|
||||
const ellipse = Ellipse.create([1, 2, 3, 4])
|
||||
expect(ellipse.x).toEqual(1)
|
||||
expect(ellipse.y).toEqual(2)
|
||||
expect(ellipse.a).toEqual(3)
|
||||
expect(ellipse.b).toEqual(4)
|
||||
})
|
||||
})
|
||||
|
||||
describe('#Ellipse.fromRect', () => {
|
||||
it('should create an ellipse from a rectangle instance', () => {
|
||||
const ellipse = Ellipse.fromRect(new Rectangle(1, 2, 3, 4))
|
||||
expect(ellipse.x).toEqual(2.5)
|
||||
expect(ellipse.y).toEqual(4)
|
||||
expect(ellipse.a).toEqual(1.5)
|
||||
expect(ellipse.b).toEqual(2)
|
||||
})
|
||||
})
|
||||
|
||||
describe('#bbox', () => {
|
||||
it('should return the bbox', () => {
|
||||
expect(new Ellipse(1, 2, 3, 4).bbox().toJSON()).toEqual({
|
||||
x: -2,
|
||||
y: -2,
|
||||
width: 6,
|
||||
height: 8,
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('#inflate', () => {
|
||||
it('should inflate with the given `amount`', () => {
|
||||
expect(new Ellipse(1, 2, 3, 4).inflate(2).toJSON()).toEqual({
|
||||
x: 1,
|
||||
y: 2,
|
||||
a: 7,
|
||||
b: 8,
|
||||
})
|
||||
})
|
||||
|
||||
it('should inflate with the given `dx` and `dy`', () => {
|
||||
expect(new Ellipse(1, 2, 3, 4).inflate(2, 3).toJSON()).toEqual({
|
||||
x: 1,
|
||||
y: 2,
|
||||
a: 7,
|
||||
b: 10,
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('#normalizedDistance', () => {
|
||||
it('should return a normalized distance', () => {
|
||||
const ellipse = new Ellipse(0, 0, 3, 4)
|
||||
expect(ellipse.normalizedDistance(1, 1) < 1).toBeTruthy()
|
||||
expect(ellipse.normalizedDistance({ x: 5, y: 5 }) > 1).toBeTruthy()
|
||||
expect(ellipse.normalizedDistance([0, 4]) === 1).toBeTruthy()
|
||||
})
|
||||
})
|
||||
|
||||
describe('#containsPoint', () => {
|
||||
const ellipse = new Ellipse(0, 0, 3, 4)
|
||||
|
||||
it('shoule return true when ellipse contains the given point', () => {
|
||||
expect(ellipse.containsPoint(1, 1)).toBeTruthy()
|
||||
})
|
||||
|
||||
it('shoule return true when the given point is on the boundary of the ellipse', () => {
|
||||
expect(ellipse.containsPoint([0, 4])).toBeTruthy()
|
||||
})
|
||||
|
||||
it('shoule return true when ellipse not contains the given point', () => {
|
||||
expect(ellipse.containsPoint({ x: 5, y: 5 })).toBeFalsy()
|
||||
})
|
||||
})
|
||||
|
||||
describe('#intersectsWithLine', () => {
|
||||
const ellipse = new Ellipse(0, 0, 3, 4)
|
||||
|
||||
it('should return the intersections with line', () => {
|
||||
expect(
|
||||
Point.equalPoints(ellipse.intersectsWithLine(new Line(0, -5, 0, 5))!, [
|
||||
{ x: 0, y: -4 },
|
||||
{ x: 0, y: 4 },
|
||||
]),
|
||||
).toBeTruthy()
|
||||
|
||||
expect(
|
||||
Point.equalPoints(ellipse.intersectsWithLine(new Line(0, 0, 0, 5))!, [
|
||||
{ x: 0, y: 4 },
|
||||
]),
|
||||
).toBeTruthy()
|
||||
|
||||
expect(
|
||||
Point.equalPoints(ellipse.intersectsWithLine(new Line(3, 0, 3, 4))!, [
|
||||
{ x: 3, y: 0 },
|
||||
]),
|
||||
).toBeTruthy()
|
||||
})
|
||||
|
||||
it('should return null when not intersections', () => {
|
||||
expect(ellipse.intersectsWithLine(new Line(0, 0, 1, 1))).toBeNull()
|
||||
expect(ellipse.intersectsWithLine(new Line(3, 5, 3, 6))).toBeNull()
|
||||
expect(ellipse.intersectsWithLine(new Line(-6, -6, -6, 100))).toBeNull()
|
||||
expect(ellipse.intersectsWithLine(new Line(6, 6, 100, 100))).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('#intersectsWithLineFromCenterToPoint', () => {
|
||||
const ellipse = new Ellipse(0, 0, 3, 4)
|
||||
|
||||
it('should return the intersection point when the given point is outside of the ellipse', () => {
|
||||
expect(
|
||||
ellipse
|
||||
.intersectsWithLineFromCenterToPoint({ x: 0, y: 6 })
|
||||
.round()
|
||||
.toJSON(),
|
||||
).toEqual({ x: 0, y: 4 })
|
||||
|
||||
expect(
|
||||
ellipse
|
||||
.intersectsWithLineFromCenterToPoint({ x: 6, y: 0 })
|
||||
.round()
|
||||
.toJSON(),
|
||||
).toEqual({ x: 3, y: 0 })
|
||||
|
||||
expect(
|
||||
ellipse
|
||||
.intersectsWithLineFromCenterToPoint({ x: 0, y: 6 }, 90)
|
||||
.round()
|
||||
.toJSON(),
|
||||
).toEqual({ x: 0, y: 3 })
|
||||
})
|
||||
|
||||
it('should return the intersection point when the given point is inside of the ellipse', () => {
|
||||
expect(
|
||||
ellipse
|
||||
.intersectsWithLineFromCenterToPoint({ x: 0, y: 0 }, 90)
|
||||
.round()
|
||||
.toJSON(),
|
||||
).toEqual({ x: -0, y: -3 })
|
||||
|
||||
expect(
|
||||
ellipse
|
||||
.intersectsWithLineFromCenterToPoint({ x: 0, y: 2 })
|
||||
.round()
|
||||
.toJSON(),
|
||||
).toEqual({ x: 0, y: 4 })
|
||||
|
||||
expect(
|
||||
ellipse
|
||||
.intersectsWithLineFromCenterToPoint({ x: 0, y: 2 }, 90)
|
||||
.round()
|
||||
.toJSON(),
|
||||
).toEqual({ x: 0, y: 3 })
|
||||
|
||||
expect(
|
||||
ellipse
|
||||
.intersectsWithLineFromCenterToPoint({ x: 2, y: 0 }, 90)
|
||||
.round()
|
||||
.toJSON(),
|
||||
).toEqual({ x: 4, y: -0 })
|
||||
})
|
||||
})
|
||||
|
||||
describe('#tangentTheta', () => {
|
||||
it('should return the tangent theta', () => {
|
||||
const ellipse = new Ellipse(0, 0, 3, 4)
|
||||
expect(ellipse.tangentTheta({ x: 3, y: 0 })).toEqual(270)
|
||||
expect(ellipse.tangentTheta({ x: -3, y: 0 })).toEqual(90)
|
||||
expect(ellipse.tangentTheta({ x: 0, y: 4 })).toEqual(180)
|
||||
expect(ellipse.tangentTheta({ x: 0, y: -4 })).toEqual(-0)
|
||||
})
|
||||
})
|
||||
|
||||
describe('#scale', () => {
|
||||
it('should scale the ellipse with the given `sx` and `sy`', () => {
|
||||
const ellipse = new Ellipse(1, 2, 3, 4).scale(3, 4)
|
||||
expect(ellipse.x).toEqual(1)
|
||||
expect(ellipse.y).toEqual(2)
|
||||
expect(ellipse.a).toEqual(9)
|
||||
expect(ellipse.b).toEqual(16)
|
||||
})
|
||||
})
|
||||
|
||||
describe('#translate', () => {
|
||||
it('should translate the ellipse with the given `dx` and `dy`', () => {
|
||||
const ellipse = new Ellipse(1, 2, 3, 4).translate(3, 4)
|
||||
expect(ellipse.x).toEqual(4)
|
||||
expect(ellipse.y).toEqual(6)
|
||||
expect(ellipse.a).toEqual(3)
|
||||
expect(ellipse.b).toEqual(4)
|
||||
})
|
||||
})
|
||||
|
||||
describe('#clone', () => {
|
||||
it('should return the cloned ellipse', () => {
|
||||
const ellipse = new Ellipse(1, 2, 3, 4).clone()
|
||||
expect(ellipse.x).toEqual(1)
|
||||
expect(ellipse.y).toEqual(2)
|
||||
expect(ellipse.a).toEqual(3)
|
||||
expect(ellipse.b).toEqual(4)
|
||||
})
|
||||
})
|
||||
|
||||
describe('#serialize', () => {
|
||||
it('should return the serialized string', () => {
|
||||
expect(new Ellipse(1, 2, 3, 4).serialize()).toEqual('1 2 3 4')
|
||||
})
|
||||
})
|
||||
})
|
350
packages/x6-geometry/src/ellipse.ts
Normal file
350
packages/x6-geometry/src/ellipse.ts
Normal file
@ -0,0 +1,350 @@
|
||||
import { Line } from './line'
|
||||
import { Point } from './point'
|
||||
import { Rectangle } from './rectangle'
|
||||
import { Geometry } from './geometry'
|
||||
|
||||
export class Ellipse extends Geometry implements Ellipse.EllipseLike {
|
||||
public x: number
|
||||
public y: number
|
||||
public a: number
|
||||
public b: number
|
||||
|
||||
protected get [Symbol.toStringTag]() {
|
||||
return Ellipse.toStringTag
|
||||
}
|
||||
|
||||
get center() {
|
||||
return new Point(this.x, this.y)
|
||||
}
|
||||
|
||||
constructor(x?: number, y?: number, a?: number, b?: number) {
|
||||
super()
|
||||
this.x = x == null ? 0 : x
|
||||
this.y = y == null ? 0 : y
|
||||
this.a = a == null ? 0 : a
|
||||
this.b = b == null ? 0 : b
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns a rectangle that is the bounding box of the ellipse.
|
||||
*/
|
||||
bbox() {
|
||||
return Rectangle.fromEllipse(this)
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns a point that is the center of the ellipse.
|
||||
*/
|
||||
getCenter() {
|
||||
return this.center
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns ellipse inflated in axis-x by `2 * amount` and in axis-y by
|
||||
* `2 * amount`.
|
||||
*/
|
||||
inflate(amount: number): this
|
||||
/**
|
||||
* Returns ellipse inflated in axis-x by `2 * dx` and in axis-y by `2 * dy`.
|
||||
*/
|
||||
inflate(dx: number, dy: number): this
|
||||
inflate(dx: number, dy?: number): this {
|
||||
const w = dx
|
||||
const h = dy != null ? dy : dx
|
||||
this.a += 2 * w
|
||||
this.b += 2 * h
|
||||
|
||||
return this
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns a normalized distance from the ellipse center to point `p`.
|
||||
* Returns `n < 1` for points inside the ellipse, `n = 1` for points
|
||||
* lying on the ellipse boundary and `n > 1` for points outside the ellipse.
|
||||
*/
|
||||
normalizedDistance(x: number, y: number): number
|
||||
normalizedDistance(p: Point.PointLike | Point.PointData): number
|
||||
normalizedDistance(
|
||||
x: number | Point.PointLike | Point.PointData,
|
||||
y?: number,
|
||||
) {
|
||||
const ref = Point.create(x, y)
|
||||
const dx = ref.x - this.x
|
||||
const dy = ref.y - this.y
|
||||
const a = this.a
|
||||
const b = this.b
|
||||
|
||||
return (dx * dx) / (a * a) + (dy * dy) / (b * b)
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns `true` if the point `p` is inside the ellipse (inclusive).
|
||||
* Returns `false` otherwise.
|
||||
*/
|
||||
containsPoint(x: number, y: number): boolean
|
||||
containsPoint(p: Point.PointLike | Point.PointData): boolean
|
||||
containsPoint(x: number | Point.PointLike | Point.PointData, y?: number) {
|
||||
return this.normalizedDistance(x as number, y as number) <= 1
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns an array of the intersection points of the ellipse and the line.
|
||||
* Returns `null` if no intersection exists.
|
||||
*/
|
||||
intersectsWithLine(line: Line) {
|
||||
const intersections = []
|
||||
const rx = this.a
|
||||
const ry = this.b
|
||||
const a1 = line.start
|
||||
const a2 = line.end
|
||||
const dir = line.vector()
|
||||
const diff = a1.diff(new Point(this.x, this.y))
|
||||
const mDir = new Point(dir.x / (rx * rx), dir.y / (ry * ry))
|
||||
const mDiff = new Point(diff.x / (rx * rx), diff.y / (ry * ry))
|
||||
|
||||
const a = dir.dot(mDir)
|
||||
const b = dir.dot(mDiff)
|
||||
const c = diff.dot(mDiff) - 1.0
|
||||
const d = b * b - a * c
|
||||
|
||||
if (d < 0) {
|
||||
return null
|
||||
}
|
||||
|
||||
if (d > 0) {
|
||||
const root = Math.sqrt(d)
|
||||
const ta = (-b - root) / a
|
||||
const tb = (-b + root) / a
|
||||
|
||||
if ((ta < 0 || ta > 1) && (tb < 0 || tb > 1)) {
|
||||
// outside
|
||||
return null
|
||||
}
|
||||
|
||||
if (ta >= 0 && ta <= 1) {
|
||||
intersections.push(a1.lerp(a2, ta))
|
||||
}
|
||||
|
||||
if (tb >= 0 && tb <= 1) {
|
||||
intersections.push(a1.lerp(a2, tb))
|
||||
}
|
||||
} else {
|
||||
const t = -b / a
|
||||
if (t >= 0 && t <= 1) {
|
||||
intersections.push(a1.lerp(a2, t))
|
||||
} else {
|
||||
// outside
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
return intersections
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the point on the boundary of the ellipse that is the
|
||||
* intersection of the ellipse with a line starting in the center
|
||||
* of the ellipse ending in the point `p`.
|
||||
*
|
||||
* If angle is specified, the intersection will take into account
|
||||
* the rotation of the ellipse by angle degrees around its center.
|
||||
*/
|
||||
intersectsWithLineFromCenterToPoint(
|
||||
p: Point.PointLike | Point.PointData,
|
||||
angle = 0,
|
||||
) {
|
||||
const ref = Point.clone(p)
|
||||
if (angle) {
|
||||
ref.rotate(angle, this.getCenter())
|
||||
}
|
||||
|
||||
const dx = ref.x - this.x
|
||||
const dy = ref.y - this.y
|
||||
let result
|
||||
|
||||
if (dx === 0) {
|
||||
result = this.bbox().getNearestPointToPoint(ref)
|
||||
if (angle) {
|
||||
return result.rotate(-angle, this.getCenter())
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
const m = dy / dx
|
||||
const mSquared = m * m
|
||||
const aSquared = this.a * this.a
|
||||
const bSquared = this.b * this.b
|
||||
|
||||
let x = Math.sqrt(1 / (1 / aSquared + mSquared / bSquared))
|
||||
x = dx < 0 ? -x : x
|
||||
|
||||
const y = m * x
|
||||
result = new Point(this.x + x, this.y + y)
|
||||
|
||||
if (angle) {
|
||||
return result.rotate(-angle, this.getCenter())
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the angle between the x-axis and the tangent from a point. It is
|
||||
* valid for points lying on the ellipse boundary only.
|
||||
*/
|
||||
tangentTheta(p: Point.PointLike | Point.PointData) {
|
||||
const ref = Point.clone(p)
|
||||
const x0 = ref.x
|
||||
const y0 = ref.y
|
||||
const a = this.a
|
||||
const b = this.b
|
||||
const center = this.bbox().center
|
||||
const cx = center.x
|
||||
const cy = center.y
|
||||
const refPointDelta = 30
|
||||
|
||||
const q1 = x0 > center.x + a / 2
|
||||
const q3 = x0 < center.x - a / 2
|
||||
|
||||
let x
|
||||
let y
|
||||
|
||||
if (q1 || q3) {
|
||||
y = x0 > center.x ? y0 - refPointDelta : y0 + refPointDelta
|
||||
x =
|
||||
(a * a) / (x0 - cx) -
|
||||
(a * a * (y0 - cy) * (y - cy)) / (b * b * (x0 - cx)) +
|
||||
cx
|
||||
} else {
|
||||
x = y0 > center.y ? x0 + refPointDelta : x0 - refPointDelta
|
||||
y =
|
||||
(b * b) / (y0 - cy) -
|
||||
(b * b * (x0 - cx) * (x - cx)) / (a * a * (y0 - cy)) +
|
||||
cy
|
||||
}
|
||||
|
||||
return new Point(x, y).theta(ref)
|
||||
}
|
||||
|
||||
scale(sx: number, sy: number) {
|
||||
this.a *= sx
|
||||
this.b *= sy
|
||||
return this
|
||||
}
|
||||
|
||||
rotate(angle: number, origin?: Point.PointLike | Point.PointData) {
|
||||
const rect = Rectangle.fromEllipse(this)
|
||||
rect.rotate(angle, origin)
|
||||
const ellipse = Ellipse.fromRect(rect)
|
||||
this.a = ellipse.a
|
||||
this.b = ellipse.b
|
||||
this.x = ellipse.x
|
||||
this.y = ellipse.y
|
||||
return this
|
||||
}
|
||||
|
||||
translate(dx: number, dy: number): this
|
||||
translate(p: Point.PointLike | Point.PointData): this
|
||||
translate(dx: number | Point.PointLike | Point.PointData, dy?: number): this {
|
||||
const p = Point.create(dx, dy)
|
||||
this.x += p.x
|
||||
this.y += p.y
|
||||
return this
|
||||
}
|
||||
|
||||
equals(ellipse: Ellipse) {
|
||||
return (
|
||||
ellipse != null &&
|
||||
ellipse.x === this.x &&
|
||||
ellipse.y === this.y &&
|
||||
ellipse.a === this.a &&
|
||||
ellipse.b === this.b
|
||||
)
|
||||
}
|
||||
|
||||
clone() {
|
||||
return new Ellipse(this.x, this.y, this.a, this.b)
|
||||
}
|
||||
|
||||
toJSON() {
|
||||
return { x: this.x, y: this.y, a: this.a, b: this.b }
|
||||
}
|
||||
|
||||
serialize() {
|
||||
return `${this.x} ${this.y} ${this.a} ${this.b}`
|
||||
}
|
||||
}
|
||||
|
||||
export namespace Ellipse {
|
||||
export const toStringTag = `X6.Geometry.${Ellipse.name}`
|
||||
|
||||
export function isEllipse(instance: any): instance is Ellipse {
|
||||
if (instance == null) {
|
||||
return false
|
||||
}
|
||||
|
||||
if (instance instanceof Ellipse) {
|
||||
return true
|
||||
}
|
||||
|
||||
const tag = instance[Symbol.toStringTag]
|
||||
const ellipse = instance as Ellipse
|
||||
|
||||
if (
|
||||
(tag == null || tag === toStringTag) &&
|
||||
typeof ellipse.x === 'number' &&
|
||||
typeof ellipse.y === 'number' &&
|
||||
typeof ellipse.a === 'number' &&
|
||||
typeof ellipse.b === 'number' &&
|
||||
typeof ellipse.inflate === 'function' &&
|
||||
typeof ellipse.normalizedDistance === 'function'
|
||||
) {
|
||||
return true
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
export namespace Ellipse {
|
||||
export interface EllipseLike extends Point.PointLike {
|
||||
x: number
|
||||
y: number
|
||||
a: number
|
||||
b: number
|
||||
}
|
||||
|
||||
export type EllipseData = [number, number, number, number]
|
||||
}
|
||||
|
||||
export namespace Ellipse {
|
||||
export function create(
|
||||
x?: number | Ellipse | EllipseLike | EllipseData,
|
||||
y?: number,
|
||||
a?: number,
|
||||
b?: number,
|
||||
): Ellipse {
|
||||
if (x == null || typeof x === 'number') {
|
||||
return new Ellipse(x, y, a, b)
|
||||
}
|
||||
|
||||
return parse(x)
|
||||
}
|
||||
|
||||
export function parse(e: Ellipse | EllipseLike | EllipseData) {
|
||||
if (Ellipse.isEllipse(e)) {
|
||||
return e.clone()
|
||||
}
|
||||
|
||||
if (Array.isArray(e)) {
|
||||
return new Ellipse(e[0], e[1], e[2], e[3])
|
||||
}
|
||||
|
||||
return new Ellipse(e.x, e.y, e.a, e.b)
|
||||
}
|
||||
|
||||
export function fromRect(rect: Rectangle) {
|
||||
const center = rect.center
|
||||
return new Ellipse(center.x, center.y, rect.width / 2, rect.height / 2)
|
||||
}
|
||||
}
|
35
packages/x6-geometry/src/geometry.ts
Normal file
35
packages/x6-geometry/src/geometry.ts
Normal file
@ -0,0 +1,35 @@
|
||||
import { Point } from './point'
|
||||
import { JSONObject, JSONArray } from './types'
|
||||
|
||||
export abstract class Geometry {
|
||||
abstract scale(
|
||||
sx: number,
|
||||
sy: number,
|
||||
origin?: Point.PointLike | Point.PointData,
|
||||
): this
|
||||
|
||||
abstract rotate(
|
||||
angle: number,
|
||||
origin?: Point.PointLike | Point.PointData,
|
||||
): this
|
||||
|
||||
abstract translate(tx: number, ty: number): this
|
||||
|
||||
abstract translate(p: Point.PointLike | Point.PointData): this
|
||||
|
||||
abstract equals(g: any): boolean
|
||||
|
||||
abstract clone(): Geometry
|
||||
|
||||
abstract toJSON(): JSONObject | JSONArray
|
||||
|
||||
abstract serialize(): string
|
||||
|
||||
valueOf() {
|
||||
return this.toJSON()
|
||||
}
|
||||
|
||||
toString() {
|
||||
return JSON.stringify(this.toJSON())
|
||||
}
|
||||
}
|
9
packages/x6-geometry/src/index.ts
Normal file
9
packages/x6-geometry/src/index.ts
Normal file
@ -0,0 +1,9 @@
|
||||
export * from './version'
|
||||
export * from './angle'
|
||||
export * from './point'
|
||||
export * from './line'
|
||||
export * from './ellipse'
|
||||
export * from './rectangle'
|
||||
export * from './path'
|
||||
export * from './curve'
|
||||
export * from './polyline'
|
548
packages/x6-geometry/src/line.ts
Normal file
548
packages/x6-geometry/src/line.ts
Normal file
@ -0,0 +1,548 @@
|
||||
import { Path } from './path'
|
||||
import { Point } from './point'
|
||||
import { Ellipse } from './ellipse'
|
||||
import { Geometry } from './geometry'
|
||||
import { Polyline } from './polyline'
|
||||
import { Rectangle } from './rectangle'
|
||||
|
||||
export class Line extends Geometry {
|
||||
public start: Point
|
||||
public end: Point
|
||||
|
||||
protected get [Symbol.toStringTag]() {
|
||||
return Line.toStringTag
|
||||
}
|
||||
|
||||
get center() {
|
||||
return new Point(
|
||||
(this.start.x + this.end.x) / 2,
|
||||
(this.start.y + this.end.y) / 2,
|
||||
)
|
||||
}
|
||||
|
||||
constructor(x1: number, y1: number, x2: number, y2: number)
|
||||
constructor(
|
||||
p1: Point.PointLike | Point.PointData,
|
||||
p2: Point.PointLike | Point.PointData,
|
||||
)
|
||||
constructor(
|
||||
x1: number | Point.PointLike | Point.PointData,
|
||||
y1: number | Point.PointLike | Point.PointData,
|
||||
x2?: number,
|
||||
y2?: number,
|
||||
) {
|
||||
super()
|
||||
if (typeof x1 === 'number' && typeof y1 === 'number') {
|
||||
this.start = new Point(x1, y1)
|
||||
this.end = new Point(x2, y2)
|
||||
} else {
|
||||
this.start = Point.create(x1)
|
||||
this.end = Point.create(y1)
|
||||
}
|
||||
}
|
||||
|
||||
getCenter() {
|
||||
return this.center
|
||||
}
|
||||
|
||||
/**
|
||||
* Rounds the line to the given `precision`.
|
||||
*/
|
||||
round(precision = 0) {
|
||||
this.start.round(precision)
|
||||
this.end.round(precision)
|
||||
return this
|
||||
}
|
||||
|
||||
translate(tx: number, ty: number): this
|
||||
translate(p: Point.PointLike | Point.PointData): this
|
||||
translate(tx: number | Point.PointLike | Point.PointData, ty?: number) {
|
||||
if (typeof tx === 'number') {
|
||||
this.start.translate(tx, ty as number)
|
||||
this.end.translate(tx, ty as number)
|
||||
} else {
|
||||
this.start.translate(tx)
|
||||
this.end.translate(tx)
|
||||
}
|
||||
|
||||
return this
|
||||
}
|
||||
|
||||
/**
|
||||
* Rotate the line by `angle` around `origin`.
|
||||
*/
|
||||
rotate(angle: number, origin?: Point.PointLike | Point.PointData) {
|
||||
this.start.rotate(angle, origin)
|
||||
this.end.rotate(angle, origin)
|
||||
return this
|
||||
}
|
||||
|
||||
/**
|
||||
* Scale the line by `sx` and `sy` about the given `origin`. If origin is not
|
||||
* specified, the line is scaled around `0,0`.
|
||||
*/
|
||||
scale(sx: number, sy: number, origin?: Point.PointLike | Point.PointData) {
|
||||
this.start.scale(sx, sy, origin)
|
||||
this.end.scale(sx, sy, origin)
|
||||
return this
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the length of the line.
|
||||
*/
|
||||
length() {
|
||||
return Math.sqrt(this.squaredLength())
|
||||
}
|
||||
|
||||
/**
|
||||
* Useful for distance comparisons in which real length is not necessary
|
||||
* (saves one `Math.sqrt()` operation).
|
||||
*/
|
||||
squaredLength() {
|
||||
const dx = this.start.x - this.end.x
|
||||
const dy = this.start.y - this.end.y
|
||||
return dx * dx + dy * dy
|
||||
}
|
||||
|
||||
/**
|
||||
* Scale the line so that it has the requested length. The start point of
|
||||
* the line is preserved.
|
||||
*/
|
||||
setLength(length: number) {
|
||||
const total = this.length()
|
||||
if (!total) {
|
||||
return this
|
||||
}
|
||||
|
||||
const scale = length / total
|
||||
return this.scale(scale, scale, this.start)
|
||||
}
|
||||
|
||||
parallel(distance: number) {
|
||||
const line = this.clone()
|
||||
if (!line.isDifferentiable()) {
|
||||
return line
|
||||
}
|
||||
|
||||
const { start, end } = line
|
||||
const eRef = start.clone().rotate(270, end)
|
||||
const sRef = end.clone().rotate(90, start)
|
||||
start.move(sRef, distance)
|
||||
end.move(eRef, distance)
|
||||
return line
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the vector of the line with length equal to length of the line.
|
||||
*/
|
||||
vector() {
|
||||
return new Point(this.end.x - this.start.x, this.end.y - this.start.y)
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the angle of incline of the line.
|
||||
*
|
||||
* The function returns `NaN` if the start and end endpoints of the line
|
||||
* both lie at the same coordinates(it is impossible to determine the angle
|
||||
* of incline of a line that appears to be a point). The
|
||||
* `line.isDifferentiable()` function may be used in advance to determine
|
||||
* whether the angle of incline can be computed for a given line.
|
||||
*/
|
||||
angle() {
|
||||
const horizontal = new Point(this.start.x + 1, this.start.y)
|
||||
return this.start.angleBetween(this.end, horizontal)
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns a rectangle that is the bounding box of the line.
|
||||
*/
|
||||
bbox() {
|
||||
const left = Math.min(this.start.x, this.end.x)
|
||||
const top = Math.min(this.start.y, this.end.y)
|
||||
const right = Math.max(this.start.x, this.end.x)
|
||||
const bottom = Math.max(this.start.y, this.end.y)
|
||||
|
||||
return new Rectangle(left, top, right - left, bottom - top)
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the bearing (cardinal direction) of the line.
|
||||
*
|
||||
* The return value is one of the following strings:
|
||||
* 'NE', 'E', 'SE', 'S', 'SW', 'W', 'NW' and 'N'.
|
||||
*
|
||||
* The function returns 'N' if the two endpoints of the line are coincident.
|
||||
*/
|
||||
bearing() {
|
||||
return this.start.bearing(this.end)
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the point on the line that lies closest to point `p`.
|
||||
*/
|
||||
closestPoint(p: Point.PointLike | Point.PointData) {
|
||||
return this.pointAt(this.closestPointNormalizedLength(p))
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the length of the line up to the point that lies closest to point `p`.
|
||||
*/
|
||||
closestPointLength(p: Point.PointLike | Point.PointData) {
|
||||
return this.closestPointNormalizedLength(p) * this.length()
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns a line that is tangent to the line at the point that lies closest
|
||||
* to point `p`.
|
||||
*/
|
||||
closestPointTangent(p: Point.PointLike | Point.PointData) {
|
||||
return this.tangentAt(this.closestPointNormalizedLength(p))
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the normalized length (distance from the start of the line / total
|
||||
* line length) of the line up to the point that lies closest to point.
|
||||
*/
|
||||
closestPointNormalizedLength(p: Point.PointLike | Point.PointData) {
|
||||
const product = this.vector().dot(new Line(this.start, p).vector())
|
||||
const normalized = Math.min(1, Math.max(0, product / this.squaredLength()))
|
||||
|
||||
// normalized returns `NaN` if this line has zero length
|
||||
if (Number.isNaN(normalized)) {
|
||||
return 0
|
||||
}
|
||||
|
||||
return normalized
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns a point on the line that lies `rate` (normalized length) away from
|
||||
* the beginning of the line.
|
||||
*/
|
||||
pointAt(ratio: number) {
|
||||
const start = this.start
|
||||
const end = this.end
|
||||
|
||||
if (ratio <= 0) {
|
||||
return start.clone()
|
||||
}
|
||||
|
||||
if (ratio >= 1) {
|
||||
return end.clone()
|
||||
}
|
||||
|
||||
return start.lerp(end, ratio)
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns a point on the line that lies length away from the beginning of
|
||||
* the line.
|
||||
*/
|
||||
pointAtLength(length: number) {
|
||||
const start = this.start
|
||||
const end = this.end
|
||||
|
||||
let fromStart = true
|
||||
|
||||
if (length < 0) {
|
||||
fromStart = false // start calculation from end point
|
||||
length = -length // eslint-disable-line
|
||||
}
|
||||
|
||||
const total = this.length()
|
||||
if (length >= total) {
|
||||
return fromStart ? end.clone() : start.clone()
|
||||
}
|
||||
|
||||
const rate = (fromStart ? length : total - length) / total
|
||||
return this.pointAt(rate)
|
||||
}
|
||||
|
||||
/**
|
||||
* Divides the line into two lines at the point that lies `rate` (normalized
|
||||
* length) away from the beginning of the line.
|
||||
*/
|
||||
divideAt(ratio: number) {
|
||||
const dividerPoint = this.pointAt(ratio)
|
||||
return [
|
||||
new Line(this.start, dividerPoint),
|
||||
new Line(dividerPoint, this.end),
|
||||
]
|
||||
}
|
||||
|
||||
/**
|
||||
* Divides the line into two lines at the point that lies length away from
|
||||
* the beginning of the line.
|
||||
*/
|
||||
divideAtLength(length: number) {
|
||||
const dividerPoint = this.pointAtLength(length)
|
||||
return [
|
||||
new Line(this.start, dividerPoint),
|
||||
new Line(dividerPoint, this.end),
|
||||
]
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns `true` if the point `p` lies on the line. Return `false` otherwise.
|
||||
*/
|
||||
containsPoint(p: Point.PointLike | Point.PointData) {
|
||||
const start = this.start
|
||||
const end = this.end
|
||||
|
||||
// cross product of 0 indicates that this line and
|
||||
// the vector to `p` are collinear.
|
||||
if (start.cross(p, end) !== 0) {
|
||||
return false
|
||||
}
|
||||
|
||||
const length = this.length()
|
||||
if (new Line(start, p).length() > length) {
|
||||
return false
|
||||
}
|
||||
|
||||
if (new Line(p, end).length() > length) {
|
||||
return false
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns an array of the intersection points of the line with another
|
||||
* geometry shape.
|
||||
*/
|
||||
intersect(shape: Line | Rectangle | Polyline | Ellipse): Point[] | null
|
||||
intersect(shape: Path, options?: Path.Options): Point[] | null
|
||||
intersect(
|
||||
shape: Line | Rectangle | Polyline | Ellipse | Path,
|
||||
options?: Path.Options,
|
||||
): Point[] | null {
|
||||
const ret = shape.intersectsWithLine(this, options)
|
||||
if (ret) {
|
||||
return Array.isArray(ret) ? ret : [ret]
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the intersection point of the line with another line. Returns
|
||||
* `null` if no intersection exists.
|
||||
*/
|
||||
intersectsWithLine(line: Line) {
|
||||
const pt1Dir = new Point(
|
||||
this.end.x - this.start.x,
|
||||
this.end.y - this.start.y,
|
||||
)
|
||||
const pt2Dir = new Point(
|
||||
line.end.x - line.start.x,
|
||||
line.end.y - line.start.y,
|
||||
)
|
||||
const det = pt1Dir.x * pt2Dir.y - pt1Dir.y * pt2Dir.x
|
||||
const deltaPt = new Point(
|
||||
line.start.x - this.start.x,
|
||||
line.start.y - this.start.y,
|
||||
)
|
||||
const alpha = deltaPt.x * pt2Dir.y - deltaPt.y * pt2Dir.x
|
||||
const beta = deltaPt.x * pt1Dir.y - deltaPt.y * pt1Dir.x
|
||||
|
||||
if (det === 0 || alpha * det < 0 || beta * det < 0) {
|
||||
return null
|
||||
}
|
||||
|
||||
if (det > 0) {
|
||||
if (alpha > det || beta > det) {
|
||||
return null
|
||||
}
|
||||
} else if (alpha < det || beta < det) {
|
||||
return null
|
||||
}
|
||||
|
||||
return new Point(
|
||||
this.start.x + (alpha * pt1Dir.x) / det,
|
||||
this.start.y + (alpha * pt1Dir.y) / det,
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns `true` if a tangent line can be found for the line.
|
||||
*
|
||||
* Tangents cannot be found if both of the line endpoints are coincident
|
||||
* (the line appears to be a point).
|
||||
*/
|
||||
isDifferentiable() {
|
||||
return !this.start.equals(this.end)
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the perpendicular distance between the line and point. The
|
||||
* distance is positive if the point lies to the right of the line, negative
|
||||
* if the point lies to the left of the line, and `0` if the point lies on
|
||||
* the line.
|
||||
*/
|
||||
pointOffset(p: Point.PointLike | Point.PointData) {
|
||||
const ref = Point.clone(p)
|
||||
const start = this.start
|
||||
const end = this.end
|
||||
const determinant =
|
||||
(end.x - start.x) * (ref.y - start.y) -
|
||||
(end.y - start.y) * (ref.x - start.x)
|
||||
|
||||
return determinant / this.length()
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the squared distance between the line and the point.
|
||||
*/
|
||||
pointSquaredDistance(x: number, y: number): number
|
||||
pointSquaredDistance(p: Point.PointLike | Point.PointData): number
|
||||
pointSquaredDistance(
|
||||
x: number | Point.PointLike | Point.PointData,
|
||||
y?: number,
|
||||
) {
|
||||
const p = Point.create(x, y)
|
||||
return this.closestPoint(p).squaredDistance(p)
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the distance between the line and the point.
|
||||
*/
|
||||
pointDistance(x: number, y: number): number
|
||||
pointDistance(p: Point.PointLike | Point.PointData): number
|
||||
pointDistance(x: number | Point.PointLike | Point.PointData, y?: number) {
|
||||
const p = Point.create(x, y)
|
||||
return this.closestPoint(p).distance(p)
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns a line tangent to the line at point that lies `rate` (normalized
|
||||
* length) away from the beginning of the line.
|
||||
*/
|
||||
tangentAt(ratio: number) {
|
||||
if (!this.isDifferentiable()) {
|
||||
return null
|
||||
}
|
||||
|
||||
const start = this.start
|
||||
const end = this.end
|
||||
|
||||
const tangentStart = this.pointAt(ratio)
|
||||
const tangentLine = new Line(start, end)
|
||||
tangentLine.translate(tangentStart.x - start.x, tangentStart.y - start.y)
|
||||
|
||||
return tangentLine
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns a line tangent to the line at point that lies `length` away from
|
||||
* the beginning of the line.
|
||||
*/
|
||||
tangentAtLength(length: number) {
|
||||
if (!this.isDifferentiable()) {
|
||||
return null
|
||||
}
|
||||
|
||||
const start = this.start
|
||||
const end = this.end
|
||||
|
||||
const tangentStart = this.pointAtLength(length)
|
||||
const tangentLine = new Line(start, end)
|
||||
tangentLine.translate(tangentStart.x - start.x, tangentStart.y - start.y)
|
||||
|
||||
return tangentLine
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns which direction the line would have to rotate in order to direct
|
||||
* itself at a point.
|
||||
*
|
||||
* Returns 1 if the given point on the right side of the segment, 0 if its
|
||||
* on the segment, and -1 if the point is on the left side of the segment.
|
||||
*
|
||||
* @see https://softwareengineering.stackexchange.com/questions/165776/what-do-ptlinedist-and-relativeccw-do
|
||||
*/
|
||||
relativeCcw(x: number, y: number): -1 | 0 | 1
|
||||
relativeCcw(p: Point.PointLike | Point.PointData): -1 | 0 | 1
|
||||
relativeCcw(x: number | Point.PointLike | Point.PointData, y?: number) {
|
||||
const ref = Point.create(x, y)
|
||||
|
||||
let dx1 = ref.x - this.start.x
|
||||
let dy1 = ref.y - this.start.y
|
||||
const dx2 = this.end.x - this.start.x
|
||||
const dy2 = this.end.y - this.start.y
|
||||
|
||||
let ccw = dx1 * dy2 - dy1 * dx2
|
||||
if (ccw === 0) {
|
||||
ccw = dx1 * dx2 + dy1 * dy2
|
||||
if (ccw > 0.0) {
|
||||
dx1 -= dx2
|
||||
dy1 -= dy2
|
||||
ccw = dx1 * dx2 + dy1 * dy2
|
||||
if (ccw < 0.0) {
|
||||
ccw = 0.0
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return ccw < 0.0 ? -1 : ccw > 0.0 ? 1 : 0
|
||||
}
|
||||
|
||||
/**
|
||||
* Return `true` if the line equals the other line.
|
||||
*/
|
||||
equals(l: Line) {
|
||||
return (
|
||||
l != null &&
|
||||
this.start.x === l.start.x &&
|
||||
this.start.y === l.start.y &&
|
||||
this.end.x === l.end.x &&
|
||||
this.end.y === l.end.y
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns another line which is a clone of the line.
|
||||
*/
|
||||
clone() {
|
||||
return new Line(this.start, this.end)
|
||||
}
|
||||
|
||||
toJSON() {
|
||||
return { start: this.start.toJSON(), end: this.end.toJSON() }
|
||||
}
|
||||
|
||||
serialize() {
|
||||
return [this.start.serialize(), this.end.serialize()].join(' ')
|
||||
}
|
||||
}
|
||||
|
||||
export namespace Line {
|
||||
export const toStringTag = `X6.Geometry.${Line.name}`
|
||||
|
||||
export function isLine(instance: any): instance is Line {
|
||||
if (instance == null) {
|
||||
return false
|
||||
}
|
||||
|
||||
if (instance instanceof Line) {
|
||||
return true
|
||||
}
|
||||
|
||||
const tag = instance[Symbol.toStringTag]
|
||||
const line = instance as Line
|
||||
|
||||
if (
|
||||
(tag == null || tag === toStringTag) &&
|
||||
Point.isPoint(line.start) &&
|
||||
Point.isPoint(line.end) &&
|
||||
typeof line.vector === 'function' &&
|
||||
typeof line.bearing === 'function' &&
|
||||
typeof line.parallel === 'function' &&
|
||||
typeof line.intersect === 'function'
|
||||
) {
|
||||
return true
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
}
|
137
packages/x6-geometry/src/path/close.ts
Normal file
137
packages/x6-geometry/src/path/close.ts
Normal file
@ -0,0 +1,137 @@
|
||||
import { Line } from '../line'
|
||||
import { Point } from '../point'
|
||||
import { LineTo } from './lineto'
|
||||
import { Segment } from './segment'
|
||||
|
||||
export class Close extends Segment {
|
||||
get end() {
|
||||
if (!this.subpathStartSegment) {
|
||||
throw new Error(
|
||||
'Missing subpath start segment. (This segment needs a subpath ' +
|
||||
'start segment (e.g. MoveTo), or segment has not yet been added' +
|
||||
' to a path.)',
|
||||
)
|
||||
}
|
||||
|
||||
return this.subpathStartSegment.end
|
||||
}
|
||||
|
||||
get type() {
|
||||
return 'Z'
|
||||
}
|
||||
|
||||
get line() {
|
||||
return new Line(this.start, this.end)
|
||||
}
|
||||
|
||||
bbox() {
|
||||
return this.line.bbox()
|
||||
}
|
||||
|
||||
closestPoint(p: Point.PointLike | Point.PointData) {
|
||||
return this.line.closestPoint(p)
|
||||
}
|
||||
|
||||
closestPointLength(p: Point.PointLike | Point.PointData) {
|
||||
return this.line.closestPointLength(p)
|
||||
}
|
||||
|
||||
closestPointNormalizedLength(p: Point.PointLike | Point.PointData) {
|
||||
return this.line.closestPointNormalizedLength(p)
|
||||
}
|
||||
|
||||
closestPointTangent(p: Point.PointLike | Point.PointData) {
|
||||
return this.line.closestPointTangent(p)
|
||||
}
|
||||
|
||||
length() {
|
||||
return this.line.length()
|
||||
}
|
||||
|
||||
divideAt(ratio: number): [Segment, Segment] {
|
||||
const divided = this.line.divideAt(ratio)
|
||||
return [
|
||||
// do not actually cut into the segment, first divided part can stay as Z
|
||||
divided[1].isDifferentiable() ? new LineTo(divided[0]) : this.clone(),
|
||||
new LineTo(divided[1]),
|
||||
]
|
||||
}
|
||||
|
||||
divideAtLength(length: number): [Segment, Segment] {
|
||||
const divided = this.line.divideAtLength(length)
|
||||
return [
|
||||
divided[1].isDifferentiable() ? new LineTo(divided[0]) : this.clone(),
|
||||
new LineTo(divided[1]),
|
||||
]
|
||||
}
|
||||
|
||||
getSubdivisions() {
|
||||
return []
|
||||
}
|
||||
|
||||
pointAt(ratio: number) {
|
||||
return this.line.pointAt(ratio)
|
||||
}
|
||||
|
||||
pointAtLength(length: number) {
|
||||
return this.line.pointAtLength(length)
|
||||
}
|
||||
|
||||
tangentAt(ratio: number) {
|
||||
return this.line.tangentAt(ratio)
|
||||
}
|
||||
|
||||
tangentAtLength(length: number) {
|
||||
return this.line.tangentAtLength(length)
|
||||
}
|
||||
|
||||
isDifferentiable() {
|
||||
if (!this.previousSegment || !this.subpathStartSegment) {
|
||||
return false
|
||||
}
|
||||
|
||||
return !this.start.equals(this.end)
|
||||
}
|
||||
|
||||
scale() {
|
||||
return this
|
||||
}
|
||||
|
||||
rotate() {
|
||||
return this
|
||||
}
|
||||
|
||||
translate() {
|
||||
return this
|
||||
}
|
||||
|
||||
equals(s: Segment) {
|
||||
return (
|
||||
this.type === s.type &&
|
||||
this.start.equals(s.start) &&
|
||||
this.end.equals(s.end)
|
||||
)
|
||||
}
|
||||
|
||||
clone() {
|
||||
return new Close()
|
||||
}
|
||||
|
||||
toJSON() {
|
||||
return {
|
||||
type: this.type,
|
||||
start: this.start.toJSON(),
|
||||
end: this.end.toJSON(),
|
||||
}
|
||||
}
|
||||
|
||||
serialize() {
|
||||
return this.type
|
||||
}
|
||||
}
|
||||
|
||||
export namespace Close {
|
||||
export function create(): Close {
|
||||
return new Close()
|
||||
}
|
||||
}
|
276
packages/x6-geometry/src/path/curveto.ts
Normal file
276
packages/x6-geometry/src/path/curveto.ts
Normal file
@ -0,0 +1,276 @@
|
||||
import { Curve } from '../curve'
|
||||
import { Point } from '../point'
|
||||
import { Segment } from './segment'
|
||||
|
||||
export class CurveTo extends Segment {
|
||||
controlPoint1: Point
|
||||
controlPoint2: Point
|
||||
|
||||
constructor(curve: Curve)
|
||||
constructor(
|
||||
x1: number,
|
||||
y1: number,
|
||||
x2: number,
|
||||
y2: number,
|
||||
x: number,
|
||||
y: number,
|
||||
)
|
||||
constructor(
|
||||
p1: Point.PointLike | Point.PointData,
|
||||
p2: Point.PointLike | Point.PointData,
|
||||
p3: Point.PointLike | Point.PointData,
|
||||
)
|
||||
constructor(
|
||||
arg0: number | Curve | (Point.PointLike | Point.PointData),
|
||||
arg1?: number | (Point.PointLike | Point.PointData),
|
||||
arg2?: number | (Point.PointLike | Point.PointData),
|
||||
arg3?: number,
|
||||
arg4?: number,
|
||||
arg5?: number,
|
||||
) {
|
||||
super()
|
||||
|
||||
if (Curve.isCurve(arg0)) {
|
||||
this.controlPoint1 = arg0.controlPoint1.clone().round(2)
|
||||
this.controlPoint2 = arg0.controlPoint2.clone().round(2)
|
||||
this.endPoint = arg0.end.clone().round(2)
|
||||
} else if (typeof arg0 === 'number') {
|
||||
this.controlPoint1 = new Point(arg0, arg1 as number).round(2)
|
||||
this.controlPoint2 = new Point(arg2 as number, arg3).round(2)
|
||||
this.endPoint = new Point(arg4, arg5).round(2)
|
||||
} else {
|
||||
this.controlPoint1 = Point.create(arg0).round(2)
|
||||
this.controlPoint2 = Point.create(arg1).round(2)
|
||||
this.endPoint = Point.create(arg2).round(2)
|
||||
}
|
||||
}
|
||||
|
||||
get type() {
|
||||
return 'C'
|
||||
}
|
||||
|
||||
get curve() {
|
||||
return new Curve(
|
||||
this.start,
|
||||
this.controlPoint1,
|
||||
this.controlPoint2,
|
||||
this.end,
|
||||
)
|
||||
}
|
||||
|
||||
bbox() {
|
||||
return this.curve.bbox()
|
||||
}
|
||||
|
||||
closestPoint(p: Point.PointLike | Point.PointData) {
|
||||
return this.curve.closestPoint(p)
|
||||
}
|
||||
|
||||
closestPointLength(p: Point.PointLike | Point.PointData) {
|
||||
return this.curve.closestPointLength(p)
|
||||
}
|
||||
|
||||
closestPointNormalizedLength(p: Point.PointLike | Point.PointData) {
|
||||
return this.curve.closestPointNormalizedLength(p)
|
||||
}
|
||||
|
||||
closestPointTangent(p: Point.PointLike | Point.PointData) {
|
||||
return this.curve.closestPointTangent(p)
|
||||
}
|
||||
|
||||
length() {
|
||||
return this.curve.length()
|
||||
}
|
||||
|
||||
divideAt(ratio: number, options: Segment.Options = {}): [Segment, Segment] {
|
||||
// TODO: fix options
|
||||
const divided = this.curve.divideAt(ratio, options as any)
|
||||
return [new CurveTo(divided[0]), new CurveTo(divided[1])]
|
||||
}
|
||||
|
||||
divideAtLength(
|
||||
length: number,
|
||||
options: Segment.Options = {},
|
||||
): [Segment, Segment] {
|
||||
// TODO: fix options
|
||||
const divided = this.curve.divideAtLength(length, options as any)
|
||||
return [new CurveTo(divided[0]), new CurveTo(divided[1])]
|
||||
}
|
||||
|
||||
divideAtT(t: number): [Segment, Segment] {
|
||||
const divided = this.curve.divideAtT(t)
|
||||
return [new CurveTo(divided[0]), new CurveTo(divided[1])]
|
||||
}
|
||||
|
||||
getSubdivisions() {
|
||||
return []
|
||||
}
|
||||
|
||||
pointAt(ratio: number) {
|
||||
return this.curve.pointAt(ratio)
|
||||
}
|
||||
|
||||
pointAtLength(length: number) {
|
||||
return this.curve.pointAtLength(length)
|
||||
}
|
||||
|
||||
tangentAt(ratio: number) {
|
||||
return this.curve.tangentAt(ratio)
|
||||
}
|
||||
|
||||
tangentAtLength(length: number) {
|
||||
return this.curve.tangentAtLength(length)
|
||||
}
|
||||
|
||||
isDifferentiable() {
|
||||
if (!this.previousSegment) {
|
||||
return false
|
||||
}
|
||||
|
||||
const start = this.start
|
||||
const control1 = this.controlPoint1
|
||||
const control2 = this.controlPoint2
|
||||
const end = this.end
|
||||
|
||||
return !(
|
||||
start.equals(control1) &&
|
||||
control1.equals(control2) &&
|
||||
control2.equals(end)
|
||||
)
|
||||
}
|
||||
|
||||
scale(sx: number, sy: number, origin?: Point.PointLike | Point.PointData) {
|
||||
this.controlPoint1.scale(sx, sy, origin)
|
||||
this.controlPoint2.scale(sx, sy, origin)
|
||||
this.end.scale(sx, sy, origin)
|
||||
return this
|
||||
}
|
||||
|
||||
rotate(angle: number, origin?: Point.PointLike | Point.PointData) {
|
||||
this.controlPoint1.rotate(angle, origin)
|
||||
this.controlPoint2.rotate(angle, origin)
|
||||
this.end.rotate(angle, origin)
|
||||
return this
|
||||
}
|
||||
|
||||
translate(tx: number, ty: number): this
|
||||
translate(p: Point.PointLike | Point.PointData): this
|
||||
translate(tx: number | Point.PointLike | Point.PointData, ty?: number): this {
|
||||
if (typeof tx === 'number') {
|
||||
this.controlPoint1.translate(tx, ty as number)
|
||||
this.controlPoint2.translate(tx, ty as number)
|
||||
this.end.translate(tx, ty as number)
|
||||
} else {
|
||||
this.controlPoint1.translate(tx)
|
||||
this.controlPoint2.translate(tx)
|
||||
this.end.translate(tx)
|
||||
}
|
||||
|
||||
return this
|
||||
}
|
||||
|
||||
equals(s: Segment) {
|
||||
return (
|
||||
this.start.equals(s.start) &&
|
||||
this.end.equals(s.end) &&
|
||||
this.controlPoint1.equals((s as CurveTo).controlPoint1) &&
|
||||
this.controlPoint2.equals((s as CurveTo).controlPoint2)
|
||||
)
|
||||
}
|
||||
|
||||
clone() {
|
||||
return new CurveTo(this.controlPoint1, this.controlPoint2, this.end)
|
||||
}
|
||||
|
||||
toJSON() {
|
||||
return {
|
||||
type: this.type,
|
||||
start: this.start.toJSON(),
|
||||
controlPoint1: this.controlPoint1.toJSON(),
|
||||
controlPoint2: this.controlPoint2.toJSON(),
|
||||
end: this.end.toJSON(),
|
||||
}
|
||||
}
|
||||
|
||||
serialize() {
|
||||
const c1 = this.controlPoint1
|
||||
const c2 = this.controlPoint2
|
||||
const end = this.end
|
||||
return [this.type, c1.x, c1.y, c2.x, c2.y, end.x, end.y].join(' ')
|
||||
}
|
||||
}
|
||||
|
||||
export namespace CurveTo {
|
||||
export function create(
|
||||
x1: number,
|
||||
y1: number,
|
||||
x2: number,
|
||||
y2: number,
|
||||
x: number,
|
||||
y: number,
|
||||
): CurveTo
|
||||
export function create(
|
||||
x1: number,
|
||||
y1: number,
|
||||
x2: number,
|
||||
y2: number,
|
||||
x: number,
|
||||
y: number,
|
||||
...coords: number[]
|
||||
): CurveTo[]
|
||||
export function create(
|
||||
c1: Point.PointLike,
|
||||
c2: Point.PointLike,
|
||||
p: Point.PointLike,
|
||||
): CurveTo
|
||||
export function create(
|
||||
c1: Point.PointLike,
|
||||
c2: Point.PointLike,
|
||||
p: Point.PointLike,
|
||||
...points: Point.PointLike[]
|
||||
): CurveTo[]
|
||||
export function create(...args: any[]): CurveTo | CurveTo[] {
|
||||
const len = args.length
|
||||
const arg0 = args[0]
|
||||
|
||||
// curve provided
|
||||
if (Curve.isCurve(arg0)) {
|
||||
return new CurveTo(arg0)
|
||||
}
|
||||
|
||||
// points provided
|
||||
if (Point.isPointLike(arg0)) {
|
||||
if (len === 3) {
|
||||
return new CurveTo(args[0], args[1], args[2])
|
||||
}
|
||||
|
||||
// this is a poly-bezier segment
|
||||
const segments: CurveTo[] = []
|
||||
for (let i = 0; i < len; i += 3) {
|
||||
segments.push(new CurveTo(args[i], args[i + 1], args[i + 2]))
|
||||
}
|
||||
return segments
|
||||
}
|
||||
|
||||
// coordinates provided
|
||||
if (len === 6) {
|
||||
return new CurveTo(args[0], args[1], args[2], args[3], args[4], args[5])
|
||||
}
|
||||
|
||||
// this is a poly-bezier segment
|
||||
const segments: CurveTo[] = []
|
||||
for (let i = 0; i < len; i += 6) {
|
||||
segments.push(
|
||||
new CurveTo(
|
||||
args[i],
|
||||
args[i + 1],
|
||||
args[i + 2],
|
||||
args[i + 3],
|
||||
args[i + 4],
|
||||
args[i + 5],
|
||||
),
|
||||
)
|
||||
}
|
||||
return segments
|
||||
}
|
||||
}
|
2
packages/x6-geometry/src/path/index.ts
Normal file
2
packages/x6-geometry/src/path/index.ts
Normal file
@ -0,0 +1,2 @@
|
||||
export * from './path'
|
||||
export * from './segment'
|
181
packages/x6-geometry/src/path/lineto.ts
Normal file
181
packages/x6-geometry/src/path/lineto.ts
Normal file
@ -0,0 +1,181 @@
|
||||
import { Line } from '../line'
|
||||
import { Point } from '../point'
|
||||
import { Segment } from './segment'
|
||||
|
||||
export class LineTo extends Segment {
|
||||
constructor(line: Line)
|
||||
constructor(x: number, y: number)
|
||||
constructor(p: Point.PointLike | Point.PointData)
|
||||
constructor(
|
||||
x: number | Line | (Point.PointLike | Point.PointData),
|
||||
y?: number,
|
||||
) {
|
||||
super()
|
||||
|
||||
if (Line.isLine(x)) {
|
||||
this.endPoint = x.end.clone().round(2)
|
||||
} else {
|
||||
this.endPoint = Point.create(x, y).round(2)
|
||||
}
|
||||
}
|
||||
|
||||
get type() {
|
||||
return 'L'
|
||||
}
|
||||
|
||||
get line() {
|
||||
return new Line(this.start, this.end)
|
||||
}
|
||||
|
||||
bbox() {
|
||||
return this.line.bbox()
|
||||
}
|
||||
|
||||
closestPoint(p: Point.PointLike | Point.PointData) {
|
||||
return this.line.closestPoint(p)
|
||||
}
|
||||
|
||||
closestPointLength(p: Point.PointLike | Point.PointData) {
|
||||
return this.line.closestPointLength(p)
|
||||
}
|
||||
|
||||
closestPointNormalizedLength(p: Point.PointLike | Point.PointData) {
|
||||
return this.line.closestPointNormalizedLength(p)
|
||||
}
|
||||
|
||||
closestPointTangent(p: Point.PointLike | Point.PointData) {
|
||||
return this.line.closestPointTangent(p)
|
||||
}
|
||||
|
||||
length() {
|
||||
return this.line.length()
|
||||
}
|
||||
|
||||
divideAt(ratio: number): [Segment, Segment] {
|
||||
const divided = this.line.divideAt(ratio)
|
||||
return [new LineTo(divided[0]), new LineTo(divided[1])]
|
||||
}
|
||||
|
||||
divideAtLength(length: number): [Segment, Segment] {
|
||||
const divided = this.line.divideAtLength(length)
|
||||
return [new LineTo(divided[0]), new LineTo(divided[1])]
|
||||
}
|
||||
|
||||
getSubdivisions() {
|
||||
return []
|
||||
}
|
||||
|
||||
pointAt(ratio: number) {
|
||||
return this.line.pointAt(ratio)
|
||||
}
|
||||
|
||||
pointAtLength(length: number) {
|
||||
return this.line.pointAtLength(length)
|
||||
}
|
||||
|
||||
tangentAt(ratio: number) {
|
||||
return this.line.tangentAt(ratio)
|
||||
}
|
||||
|
||||
tangentAtLength(length: number) {
|
||||
return this.line.tangentAtLength(length)
|
||||
}
|
||||
|
||||
isDifferentiable() {
|
||||
if (this.previousSegment == null) {
|
||||
return false
|
||||
}
|
||||
|
||||
return !this.start.equals(this.end)
|
||||
}
|
||||
|
||||
clone() {
|
||||
return new LineTo(this.end)
|
||||
}
|
||||
|
||||
scale(sx: number, sy: number, origin?: Point.PointLike | Point.PointData) {
|
||||
this.end.scale(sx, sy, origin)
|
||||
return this
|
||||
}
|
||||
|
||||
rotate(angle: number, origin?: Point.PointLike | Point.PointData) {
|
||||
this.end.rotate(angle, origin)
|
||||
return this
|
||||
}
|
||||
|
||||
translate(tx: number, ty: number): this
|
||||
translate(p: Point.PointLike | Point.PointData): this
|
||||
translate(tx: number | Point.PointLike | Point.PointData, ty?: number): this {
|
||||
if (typeof tx === 'number') {
|
||||
this.end.translate(tx, ty as number)
|
||||
} else {
|
||||
this.end.translate(tx)
|
||||
}
|
||||
return this
|
||||
}
|
||||
|
||||
equals(s: Segment) {
|
||||
return (
|
||||
this.type === s.type &&
|
||||
this.start.equals(s.start) &&
|
||||
this.end.equals(s.end)
|
||||
)
|
||||
}
|
||||
|
||||
toJSON() {
|
||||
return {
|
||||
type: this.type,
|
||||
start: this.start.toJSON(),
|
||||
end: this.end.toJSON(),
|
||||
}
|
||||
}
|
||||
|
||||
serialize() {
|
||||
const end = this.end
|
||||
return `${this.type} ${end.x} ${end.y}`
|
||||
}
|
||||
}
|
||||
|
||||
export namespace LineTo {
|
||||
export function create(line: Line): LineTo
|
||||
export function create(point: Point.PointLike): LineTo
|
||||
export function create(x: number, y: number): LineTo
|
||||
export function create(
|
||||
point: Point.PointLike,
|
||||
...points: Point.PointLike[]
|
||||
): LineTo[]
|
||||
export function create(x: number, y: number, ...coords: number[]): LineTo[]
|
||||
export function create(...args: any[]): LineTo | LineTo[] {
|
||||
const len = args.length
|
||||
const arg0 = args[0]
|
||||
|
||||
// line provided
|
||||
if (Line.isLine(arg0)) {
|
||||
return new LineTo(arg0)
|
||||
}
|
||||
|
||||
// points provided
|
||||
if (Point.isPointLike(arg0)) {
|
||||
if (len === 1) {
|
||||
return new LineTo(arg0)
|
||||
}
|
||||
|
||||
// poly-line segment
|
||||
return args.map((arg) => new LineTo(arg as Point.PointLike))
|
||||
}
|
||||
|
||||
// coordinates provided
|
||||
if (len === 2) {
|
||||
return new LineTo(+args[0], +args[1])
|
||||
}
|
||||
|
||||
// poly-line segment
|
||||
const segments: LineTo[] = []
|
||||
for (let i = 0; i < len; i += 2) {
|
||||
const x = +args[i]
|
||||
const y = +args[i + 1]
|
||||
segments.push(new LineTo(x, y))
|
||||
}
|
||||
return segments
|
||||
}
|
||||
}
|
213
packages/x6-geometry/src/path/moveto.ts
Normal file
213
packages/x6-geometry/src/path/moveto.ts
Normal file
@ -0,0 +1,213 @@
|
||||
import { Line } from '../line'
|
||||
import { Curve } from '../curve'
|
||||
import { Point } from '../point'
|
||||
import { LineTo } from './lineto'
|
||||
import { Segment } from './segment'
|
||||
|
||||
export class MoveTo extends Segment {
|
||||
constructor(line: Line)
|
||||
constructor(curve: Curve)
|
||||
constructor(x: number, y: number)
|
||||
constructor(p: Point.PointLike | Point.PointData)
|
||||
constructor(
|
||||
x: number | Curve | Line | (Point.PointLike | Point.PointData),
|
||||
y?: number,
|
||||
) {
|
||||
super()
|
||||
|
||||
this.isVisible = false
|
||||
this.isSubpathStart = true
|
||||
|
||||
if (Line.isLine(x) || Curve.isCurve(x)) {
|
||||
this.endPoint = x.end.clone().round(2)
|
||||
} else {
|
||||
this.endPoint = Point.create(x, y).round(2)
|
||||
}
|
||||
}
|
||||
|
||||
get start(): Point {
|
||||
throw new Error(
|
||||
'Illegal access. Moveto segments should not need a start property.',
|
||||
)
|
||||
}
|
||||
|
||||
get type() {
|
||||
return 'M'
|
||||
}
|
||||
|
||||
bbox() {
|
||||
return null
|
||||
}
|
||||
|
||||
closestPoint() {
|
||||
return this.end.clone()
|
||||
}
|
||||
|
||||
closestPointLength() {
|
||||
return 0
|
||||
}
|
||||
|
||||
closestPointNormalizedLength() {
|
||||
return 0
|
||||
}
|
||||
|
||||
closestPointT() {
|
||||
return 1
|
||||
}
|
||||
|
||||
closestPointTangent() {
|
||||
return null
|
||||
}
|
||||
|
||||
length() {
|
||||
return 0
|
||||
}
|
||||
|
||||
lengthAtT() {
|
||||
return 0
|
||||
}
|
||||
|
||||
divideAt(): [Segment, Segment] {
|
||||
return [this.clone(), this.clone()]
|
||||
}
|
||||
|
||||
divideAtLength(): [Segment, Segment] {
|
||||
return [this.clone(), this.clone()]
|
||||
}
|
||||
|
||||
getSubdivisions() {
|
||||
return []
|
||||
}
|
||||
|
||||
pointAt() {
|
||||
return this.end.clone()
|
||||
}
|
||||
|
||||
pointAtLength() {
|
||||
return this.end.clone()
|
||||
}
|
||||
|
||||
pointAtT() {
|
||||
return this.end.clone()
|
||||
}
|
||||
|
||||
tangentAt() {
|
||||
return null
|
||||
}
|
||||
|
||||
tangentAtLength() {
|
||||
return null
|
||||
}
|
||||
|
||||
tangentAtT() {
|
||||
return null
|
||||
}
|
||||
|
||||
isDifferentiable() {
|
||||
return false
|
||||
}
|
||||
|
||||
scale(sx: number, sy: number, origin?: Point.PointLike | Point.PointData) {
|
||||
this.end.scale(sx, sy, origin)
|
||||
return this
|
||||
}
|
||||
|
||||
rotate(angle: number, origin?: Point.PointLike | Point.PointData) {
|
||||
this.end.rotate(angle, origin)
|
||||
return this
|
||||
}
|
||||
|
||||
translate(tx: number, ty: number): this
|
||||
translate(p: Point.PointLike | Point.PointData): this
|
||||
translate(tx: number | Point.PointLike | Point.PointData, ty?: number) {
|
||||
if (typeof tx === 'number') {
|
||||
this.end.translate(tx, ty as number)
|
||||
} else {
|
||||
this.end.translate(tx)
|
||||
}
|
||||
return this
|
||||
}
|
||||
|
||||
clone() {
|
||||
return new MoveTo(this.end)
|
||||
}
|
||||
|
||||
equals(s: Segment) {
|
||||
return this.type === s.type && this.end.equals(s.end)
|
||||
}
|
||||
|
||||
toJSON() {
|
||||
return {
|
||||
type: this.type,
|
||||
end: this.end.toJSON(),
|
||||
}
|
||||
}
|
||||
|
||||
serialize() {
|
||||
const end = this.end
|
||||
return `${this.type} ${end.x} ${end.y}`
|
||||
}
|
||||
}
|
||||
|
||||
export namespace MoveTo {
|
||||
export function create(line: Line): MoveTo
|
||||
export function create(curve: Curve): MoveTo
|
||||
export function create(point: Point.PointLike): MoveTo
|
||||
export function create(x: number, y: number): MoveTo
|
||||
export function create(
|
||||
point: Point.PointLike,
|
||||
...points: Point.PointLike[]
|
||||
): Segment[]
|
||||
export function create(x: number, y: number, ...coords: number[]): Segment[]
|
||||
export function create(...args: any[]): MoveTo | Segment[] {
|
||||
const len = args.length
|
||||
const arg0 = args[0]
|
||||
|
||||
// line provided
|
||||
if (Line.isLine(arg0)) {
|
||||
return new MoveTo(arg0)
|
||||
}
|
||||
|
||||
// curve provided
|
||||
if (Curve.isCurve(arg0)) {
|
||||
return new MoveTo(arg0)
|
||||
}
|
||||
|
||||
// points provided
|
||||
if (Point.isPointLike(arg0)) {
|
||||
if (len === 1) {
|
||||
return new MoveTo(arg0)
|
||||
}
|
||||
|
||||
// this is a moveto-with-subsequent-poly-line segment
|
||||
const segments: Segment[] = []
|
||||
// points come one by one
|
||||
for (let i = 0; i < len; i += 1) {
|
||||
if (i === 0) {
|
||||
segments.push(new MoveTo(args[i]))
|
||||
} else {
|
||||
segments.push(new LineTo(args[i]))
|
||||
}
|
||||
}
|
||||
return segments
|
||||
}
|
||||
|
||||
// coordinates provided
|
||||
if (len === 2) {
|
||||
return new MoveTo(+args[0], +args[1])
|
||||
}
|
||||
|
||||
// this is a moveto-with-subsequent-poly-line segment
|
||||
const segments: Segment[] = []
|
||||
for (let i = 0; i < len; i += 2) {
|
||||
const x = +args[i]
|
||||
const y = +args[i + 1]
|
||||
if (i === 0) {
|
||||
segments.push(new MoveTo(x, y))
|
||||
} else {
|
||||
segments.push(new LineTo(x, y))
|
||||
}
|
||||
}
|
||||
return segments
|
||||
}
|
||||
}
|
85
packages/x6-geometry/src/path/normalize.test.ts
Normal file
85
packages/x6-geometry/src/path/normalize.test.ts
Normal file
@ -0,0 +1,85 @@
|
||||
import { Path } from './path'
|
||||
import { normalizePathData } from './normalize'
|
||||
|
||||
describe('Path', () => {
|
||||
describe('#normalizePathData', () => {
|
||||
const paths = [
|
||||
['M 10 10 H 20', 'M 10 10 L 20 10'],
|
||||
['M 10 10 V 20', 'M 10 10 L 10 20'],
|
||||
[
|
||||
'M 10 20 C 10 10 25 10 25 20 S 40 30 40 20',
|
||||
'M 10 20 C 10 10 25 10 25 20 C 25 30 40 30 40 20',
|
||||
],
|
||||
['M 20 20 Q 40 0 60 20', 'M 20 20 C 33.33 6.67 46.67 6.67 60 20'],
|
||||
[
|
||||
'M 20 20 Q 40 0 60 20 T 100 20',
|
||||
'M 20 20 C 33.33 6.67 46.67 6.67 60 20 C 73.33 33.33 86.67 33.33 100 20',
|
||||
],
|
||||
['M 30 15 A 15 15 0 0 0 15 30', 'M 30 15 C 21.72 15 15 21.72 15 30'],
|
||||
['m 10 10', 'M 10 10'],
|
||||
['M 10 10 m 10 10', 'M 10 10 M 20 20'],
|
||||
['M 10 10 l 10 10', 'M 10 10 L 20 20'],
|
||||
['M 10 10 c 0 10 10 10 10 0', 'M 10 10 C 10 20 20 20 20 10'],
|
||||
['M 10 10 z', 'M 10 10 Z'],
|
||||
['M 10 10 20 20', 'M 10 10 L 20 20'],
|
||||
['M 10 10 L 20 20 30 30', 'M 10 10 L 20 20 L 30 30'],
|
||||
[
|
||||
'M 10 10 C 10 20 20 20 20 10 20 0 30 0 30 10',
|
||||
'M 10 10 C 10 20 20 20 20 10 C 20 0 30 0 30 10',
|
||||
],
|
||||
|
||||
// edge cases
|
||||
['L 10 10', 'M 0 0 L 10 10'],
|
||||
['C 10 20 20 20 20 10', 'M 0 0 C 10 20 20 20 20 10'],
|
||||
['Z', 'M 0 0 Z'],
|
||||
['M 10 10 Z L 20 20', 'M 10 10 Z L 20 20'],
|
||||
['M 10 10 Z C 10 20 20 20 20 10', 'M 10 10 Z C 10 20 20 20 20 10'],
|
||||
['M 10 10 Z Z', 'M 10 10 Z Z'],
|
||||
['', 'M 0 0'], // empty string
|
||||
['X', 'M 0 0'], // invalid command
|
||||
['M', 'M 0 0'], // no arguments for a command that needs them
|
||||
['M 10', 'M 0 0'], // too few arguments
|
||||
['M 10 10 20', 'M 10 10'], // too many arguments
|
||||
['X M 10 10', 'M 10 10'], // mixing invalid and valid commands
|
||||
|
||||
// invalid commands interspersed with valid commands
|
||||
['X M 10 10 X L 20 20', 'M 10 10 L 20 20'],
|
||||
['A 0 3 0 0 1 10 15', 'M 0 0 L 10 15'], // 0 x radius
|
||||
['A 3 0 0 0 1 10 15', 'M 0 0 L 10 15'], // 0 y radius
|
||||
['A 0 0 0 0 1 10 15', 'M 0 0 L 10 15'], // 0 x and y radii
|
||||
|
||||
// Make sure this does not throw an error because of
|
||||
// recursion in a2c() exceeding the maximum stack size
|
||||
['M 0 0 A 1 1 0 1 0 -1 -1'],
|
||||
['M 14.4 29.52 a .72 .72 0 1 0 -.72 -.72 A .72 .72 0 0 0 14.4 29.52Z'],
|
||||
]
|
||||
|
||||
it('should normalize path data', () => {
|
||||
paths.forEach((path) => {
|
||||
if (path[1]) {
|
||||
expect(normalizePathData(path[0])).toEqual(path[1])
|
||||
} else {
|
||||
normalizePathData(path[0])
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
it('should parsed by Path', () => {
|
||||
const path1 = 'M 10 10'
|
||||
const normalizedPath1 = normalizePathData(path1)
|
||||
const reconstructedPath1 = Path.parse(path1).serialize()
|
||||
expect(normalizedPath1).toEqual(reconstructedPath1)
|
||||
|
||||
const path2 = 'M 100 100 C 100 100 0 150 100 200 Z'
|
||||
const normalizedPath2 = normalizePathData(path2)
|
||||
const reconstructedPath2 = Path.parse(path2).serialize()
|
||||
expect(normalizedPath2).toEqual(reconstructedPath2)
|
||||
|
||||
const path3 =
|
||||
'M285.8,83V52.7h8.3v31c0,3.2-1,5.8-3,7.7c-2,1.9-4.4,2.8-7.2,2.8c-2.9,0-5.6-1.2-8.1-3.5l3.8-6.1c1.1,1.3,2.3,1.9,3.7,1.9c0.7,0,1.3-0.3,1.8-0.9C285.5,85,285.8,84.2,285.8,83z'
|
||||
const normalizedPath3 = normalizePathData(path3)
|
||||
const reconstructedPath3 = Path.parse(path3).serialize()
|
||||
expect(normalizedPath3).toEqual(reconstructedPath3)
|
||||
})
|
||||
})
|
||||
})
|
503
packages/x6-geometry/src/path/normalize.ts
Normal file
503
packages/x6-geometry/src/path/normalize.ts
Normal file
@ -0,0 +1,503 @@
|
||||
import { Util } from '../util'
|
||||
|
||||
type Segment = [string, ...number[]]
|
||||
|
||||
function rotate(x: number, y: number, rad: number) {
|
||||
return {
|
||||
x: x * Math.cos(rad) - y * Math.sin(rad),
|
||||
y: x * Math.sin(rad) + y * Math.cos(rad),
|
||||
}
|
||||
}
|
||||
|
||||
function q2c(
|
||||
x1: number,
|
||||
y1: number,
|
||||
ax: number,
|
||||
ay: number,
|
||||
x2: number,
|
||||
y2: number,
|
||||
) {
|
||||
const v13 = 1 / 3
|
||||
const v23 = 2 / 3
|
||||
return [
|
||||
v13 * x1 + v23 * ax,
|
||||
v13 * y1 + v23 * ay,
|
||||
v13 * x2 + v23 * ax,
|
||||
v13 * y2 + v23 * ay,
|
||||
x2,
|
||||
y2,
|
||||
]
|
||||
}
|
||||
|
||||
function a2c(
|
||||
x1: number,
|
||||
y1: number,
|
||||
rx: number,
|
||||
ry: number,
|
||||
angle: number,
|
||||
largeArcFlag: number,
|
||||
sweepFlag: number,
|
||||
x2: number,
|
||||
y2: number,
|
||||
recursive?: [number, number, number, number],
|
||||
): any[] {
|
||||
// for more information of where this math came from visit:
|
||||
// http://www.w3.org/TR/SVG11/implnote.html#ArcImplementationNotes
|
||||
const v120 = (Math.PI * 120) / 180
|
||||
const rad = (Math.PI / 180) * (+angle || 0)
|
||||
let res = []
|
||||
let xy
|
||||
let f1
|
||||
let f2
|
||||
let cx
|
||||
let cy
|
||||
|
||||
if (!recursive) {
|
||||
xy = rotate(x1, y1, -rad)
|
||||
x1 = xy.x // eslint-disable-line
|
||||
y1 = xy.y // eslint-disable-line
|
||||
|
||||
xy = rotate(x2, y2, -rad)
|
||||
x2 = xy.x // eslint-disable-line
|
||||
y2 = xy.y // eslint-disable-line
|
||||
|
||||
const x = (x1 - x2) / 2
|
||||
const y = (y1 - y2) / 2
|
||||
let h = (x * x) / (rx * rx) + (y * y) / (ry * ry)
|
||||
|
||||
if (h > 1) {
|
||||
h = Math.sqrt(h)
|
||||
rx = h * rx // eslint-disable-line
|
||||
ry = h * ry // eslint-disable-line
|
||||
}
|
||||
|
||||
const rx2 = rx * rx
|
||||
const ry2 = ry * ry
|
||||
|
||||
const k =
|
||||
(largeArcFlag === sweepFlag ? -1 : 1) *
|
||||
Math.sqrt(
|
||||
Math.abs(
|
||||
(rx2 * ry2 - rx2 * y * y - ry2 * x * x) / (rx2 * y * y + ry2 * x * x),
|
||||
),
|
||||
)
|
||||
|
||||
cx = (k * rx * y) / ry + (x1 + x2) / 2
|
||||
cy = (k * -ry * x) / rx + (y1 + y2) / 2
|
||||
|
||||
f1 = Math.asin((y1 - cy) / ry)
|
||||
f2 = Math.asin((y2 - cy) / ry)
|
||||
|
||||
f1 = x1 < cx ? Math.PI - f1 : f1
|
||||
f2 = x2 < cx ? Math.PI - f2 : f2
|
||||
|
||||
if (f1 < 0) {
|
||||
f1 = Math.PI * 2 + f1
|
||||
}
|
||||
|
||||
if (f2 < 0) {
|
||||
f2 = Math.PI * 2 + f2
|
||||
}
|
||||
|
||||
if (sweepFlag && f1 > f2) {
|
||||
f1 -= Math.PI * 2
|
||||
}
|
||||
|
||||
if (!sweepFlag && f2 > f1) {
|
||||
f2 -= Math.PI * 2
|
||||
}
|
||||
} else {
|
||||
f1 = recursive[0]
|
||||
f2 = recursive[1]
|
||||
cx = recursive[2]
|
||||
cy = recursive[3]
|
||||
}
|
||||
|
||||
let df = f2 - f1
|
||||
if (Math.abs(df) > v120) {
|
||||
const f2old = f2
|
||||
const x2old = x2
|
||||
const y2old = y2
|
||||
f2 = f1 + v120 * (sweepFlag && f2 > f1 ? 1 : -1)
|
||||
x2 = cx + rx * Math.cos(f2) // eslint-disable-line
|
||||
y2 = cy + ry * Math.sin(f2) // eslint-disable-line
|
||||
res = a2c(x2, y2, rx, ry, angle, 0, sweepFlag, x2old, y2old, [
|
||||
f2,
|
||||
f2old,
|
||||
cx,
|
||||
cy,
|
||||
])
|
||||
}
|
||||
|
||||
df = f2 - f1
|
||||
|
||||
const c1 = Math.cos(f1)
|
||||
const s1 = Math.sin(f1)
|
||||
const c2 = Math.cos(f2)
|
||||
const s2 = Math.sin(f2)
|
||||
const t = Math.tan(df / 4)
|
||||
const hx = (4 / 3) * (rx * t)
|
||||
const hy = (4 / 3) * (ry * t)
|
||||
const m1 = [x1, y1]
|
||||
const m2 = [x1 + hx * s1, y1 - hy * c1]
|
||||
const m3 = [x2 + hx * s2, y2 - hy * c2]
|
||||
const m4 = [x2, y2]
|
||||
|
||||
m2[0] = 2 * m1[0] - m2[0]
|
||||
m2[1] = 2 * m1[1] - m2[1]
|
||||
|
||||
if (recursive) {
|
||||
return [m2, m3, m4].concat(res)
|
||||
}
|
||||
|
||||
{
|
||||
res = [m2, m3, m4].concat(res).join().split(',')
|
||||
|
||||
const newres = []
|
||||
const ii = res.length
|
||||
for (let i = 0; i < ii; i += 1) {
|
||||
newres[i] =
|
||||
i % 2
|
||||
? rotate(+res[i - 1], +res[i], rad).y
|
||||
: rotate(+res[i], +res[i + 1], rad).x
|
||||
}
|
||||
return newres
|
||||
}
|
||||
}
|
||||
|
||||
function parse(pathData: string) {
|
||||
if (!pathData) {
|
||||
return null
|
||||
}
|
||||
|
||||
const spaces =
|
||||
'\x09\x0a\x0b\x0c\x0d\x20\xa0\u1680\u180e\u2000\u2001\u2002\u2003\u2004\u2005\u2006\u2007\u2008\u2009\u200a\u202f\u205f\u3000\u2028\u2029'
|
||||
|
||||
// https://regexper.com/#%28%5Ba-z%5D%29%5B%5Cs%2C%5D*%28%28-%3F%5Cd*%5C.%3F%5C%5Cd*%28%3F%3Ae%5B%5C-%2B%5D%3F%5Cd%2B%29%3F%5B%5Cs%5D*%2C%3F%5B%5Cs%5D*%29%2B%29
|
||||
const segmentReg = new RegExp(
|
||||
`([a-z])[${spaces},]*((-?\\d*\\.?\\d*(?:e[\\-+]?\\d+)?[${spaces}]*,?[${spaces}]*)+)`, // eslint-disable-line
|
||||
'ig',
|
||||
)
|
||||
|
||||
// https://regexper.com/#%28-%3F%5Cd*%5C.%3F%5Cd*%28%3F%3Ae%5B%5C-%2B%5D%3F%5Cd%2B%29%3F%29%5B%5Cs%5D*%2C%3F%5B%5Cs%5D*
|
||||
const commandParamReg = new RegExp(
|
||||
// eslint-disable-next-line
|
||||
`(-?\\d*\\.?\\d*(?:e[\\-+]?\\d+)?)[${spaces}]*,?[${spaces}]*`,
|
||||
'ig',
|
||||
)
|
||||
|
||||
const paramsCount = {
|
||||
a: 7,
|
||||
c: 6,
|
||||
h: 1,
|
||||
l: 2,
|
||||
m: 2,
|
||||
q: 4,
|
||||
s: 4,
|
||||
t: 2,
|
||||
v: 1,
|
||||
z: 0,
|
||||
}
|
||||
|
||||
const segmetns: Segment[] = []
|
||||
|
||||
pathData.replace(segmentReg, (input: string, cmd: string, args: string) => {
|
||||
const params: number[] = []
|
||||
let command = cmd.toLowerCase()
|
||||
|
||||
args.replace(commandParamReg, (a: string, b: string) => {
|
||||
if (b) {
|
||||
params.push(+b)
|
||||
}
|
||||
return a
|
||||
})
|
||||
|
||||
if (command === 'm' && params.length > 2) {
|
||||
segmetns.push([cmd, ...params.splice(0, 2)])
|
||||
command = 'l'
|
||||
cmd = cmd === 'm' ? 'l' : 'L' // eslint-disable-line
|
||||
}
|
||||
|
||||
const count = paramsCount[command as keyof typeof paramsCount]
|
||||
while (params.length >= count) {
|
||||
segmetns.push([cmd, ...params.splice(0, count)])
|
||||
if (!count) {
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
return input
|
||||
})
|
||||
|
||||
return segmetns
|
||||
}
|
||||
|
||||
function abs(pathString: string) {
|
||||
const pathArray = parse(pathString)
|
||||
|
||||
// if invalid string, return 'M 0 0'
|
||||
if (!pathArray || !pathArray.length) {
|
||||
return [['M', 0, 0]]
|
||||
}
|
||||
|
||||
let x = 0
|
||||
let y = 0
|
||||
let mx = 0
|
||||
let my = 0
|
||||
const segments = []
|
||||
|
||||
for (let i = 0, ii = pathArray.length; i < ii; i += 1) {
|
||||
const r: any = []
|
||||
|
||||
segments.push(r)
|
||||
|
||||
const segment = pathArray[i]
|
||||
const command = segment[0]
|
||||
if (command !== command.toUpperCase()) {
|
||||
r[0] = command.toUpperCase()
|
||||
|
||||
switch (r[0]) {
|
||||
case 'A':
|
||||
r[1] = segment[1]
|
||||
r[2] = segment[2]
|
||||
r[3] = segment[3]
|
||||
r[4] = segment[4]
|
||||
r[5] = segment[5]
|
||||
r[6] = +segment[6] + x
|
||||
r[7] = +segment[7] + y
|
||||
break
|
||||
|
||||
case 'V':
|
||||
r[1] = +segment[1] + y
|
||||
break
|
||||
|
||||
case 'H':
|
||||
r[1] = +segment[1] + x
|
||||
break
|
||||
|
||||
case 'M':
|
||||
mx = +segment[1] + x
|
||||
my = +segment[2] + y
|
||||
|
||||
for (let j = 1, jj = segment.length; j < jj; j += 1) {
|
||||
r[j] = +segment[j] + (j % 2 ? x : y)
|
||||
}
|
||||
break
|
||||
|
||||
default:
|
||||
for (let j = 1, jj = segment.length; j < jj; j += 1) {
|
||||
r[j] = +segment[j] + (j % 2 ? x : y)
|
||||
}
|
||||
break
|
||||
}
|
||||
} else {
|
||||
for (let j = 0, jj = segment.length; j < jj; j += 1) {
|
||||
r[j] = segment[j]
|
||||
}
|
||||
}
|
||||
|
||||
switch (r[0]) {
|
||||
case 'Z':
|
||||
x = +mx
|
||||
y = +my
|
||||
break
|
||||
|
||||
case 'H':
|
||||
x = r[1]
|
||||
break
|
||||
|
||||
case 'V':
|
||||
y = r[1]
|
||||
break
|
||||
|
||||
case 'M':
|
||||
mx = r[r.length - 2]
|
||||
my = r[r.length - 1]
|
||||
x = r[r.length - 2]
|
||||
y = r[r.length - 1]
|
||||
break
|
||||
|
||||
default:
|
||||
x = r[r.length - 2]
|
||||
y = r[r.length - 1]
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
return segments
|
||||
}
|
||||
|
||||
function normalize(path: string) {
|
||||
const pathArray = abs(path)
|
||||
const attrs = { x: 0, y: 0, bx: 0, by: 0, X: 0, Y: 0, qx: null, qy: null }
|
||||
|
||||
function processPath(path: any[], d: any, pcom: string) {
|
||||
let nx
|
||||
let ny
|
||||
|
||||
if (!path) {
|
||||
return ['C', d.x, d.y, d.x, d.y, d.x, d.y]
|
||||
}
|
||||
|
||||
if (!(path[0] in { T: 1, Q: 1 })) {
|
||||
d.qx = null
|
||||
d.qy = null
|
||||
}
|
||||
|
||||
switch (path[0]) {
|
||||
case 'M':
|
||||
d.X = path[1]
|
||||
d.Y = path[2]
|
||||
break
|
||||
|
||||
case 'A':
|
||||
if (parseFloat(path[1]) === 0 || parseFloat(path[2]) === 0) {
|
||||
// https://www.w3.org/TR/SVG/paths.html#ArcOutOfRangeParameters
|
||||
// "If either rx or ry is 0, then this arc is treated as a
|
||||
// straight line segment (a "lineto") joining the endpoints."
|
||||
return ['L', path[6], path[7]]
|
||||
}
|
||||
|
||||
return ['C'].concat(a2c.apply(0, [d.x, d.y].concat(path.slice(1))))
|
||||
|
||||
case 'S':
|
||||
if (pcom === 'C' || pcom === 'S') {
|
||||
// In 'S' case we have to take into account, if the previous command is C/S.
|
||||
nx = d.x * 2 - d.bx // And reflect the previous
|
||||
ny = d.y * 2 - d.by // command's control point relative to the current point.
|
||||
} else {
|
||||
// or some else or nothing
|
||||
nx = d.x
|
||||
ny = d.y
|
||||
}
|
||||
return ['C', nx, ny].concat(path.slice(1))
|
||||
|
||||
case 'T':
|
||||
if (pcom === 'Q' || pcom === 'T') {
|
||||
// In 'T' case we have to take into account, if the previous command is Q/T.
|
||||
d.qx = d.x * 2 - d.qx // And make a reflection similar
|
||||
d.qy = d.y * 2 - d.qy // to case 'S'.
|
||||
} else {
|
||||
// or something else or nothing
|
||||
d.qx = d.x
|
||||
d.qy = d.y
|
||||
}
|
||||
return ['C'].concat(
|
||||
q2c(d.x, d.y, d.qx, d.qy, path[1], path[2]) as any[],
|
||||
)
|
||||
|
||||
case 'Q':
|
||||
d.qx = path[1]
|
||||
d.qy = path[2]
|
||||
return ['C'].concat(
|
||||
q2c(d.x, d.y, path[1], path[2], path[3], path[4]) as any[],
|
||||
)
|
||||
|
||||
case 'H':
|
||||
return ['L'].concat(path[1], d.y)
|
||||
|
||||
case 'V':
|
||||
return ['L'].concat(d.x, path[1])
|
||||
|
||||
case 'L':
|
||||
break
|
||||
|
||||
case 'Z':
|
||||
break
|
||||
|
||||
default:
|
||||
break
|
||||
}
|
||||
|
||||
return path
|
||||
}
|
||||
|
||||
function fixArc(pp: any[], i: number) {
|
||||
if (pp[i].length > 7) {
|
||||
pp[i].shift()
|
||||
const pi = pp[i]
|
||||
|
||||
while (pi.length) {
|
||||
// if created multiple 'C's, their original seg is saved
|
||||
commands[i] = 'A'
|
||||
i += 1 // eslint-disable-line
|
||||
pp.splice(i, 0, ['C'].concat(pi.splice(0, 6)))
|
||||
}
|
||||
|
||||
pp.splice(i, 1)
|
||||
ii = pathArray.length
|
||||
}
|
||||
}
|
||||
|
||||
const commands = [] // path commands of original path p
|
||||
let prevCommand = '' // holder for previous path command of original path
|
||||
|
||||
let ii = pathArray.length
|
||||
for (let i = 0; i < ii; i += 1) {
|
||||
let command = '' // temporary holder for original path command
|
||||
|
||||
if (pathArray[i]) {
|
||||
command = pathArray[i][0] // save current path command
|
||||
}
|
||||
|
||||
if (command !== 'C') {
|
||||
// C is not saved yet, because it may be result of conversion
|
||||
commands[i] = command // Save current path command
|
||||
if (i > 0) {
|
||||
prevCommand = commands[i - 1] // Get previous path command pcom
|
||||
}
|
||||
}
|
||||
|
||||
// Previous path command is inputted to processPath
|
||||
pathArray[i] = processPath(pathArray[i], attrs, prevCommand)
|
||||
|
||||
if (commands[i] !== 'A' && command === 'C') {
|
||||
commands[i] = 'C' // 'A' is the only command
|
||||
}
|
||||
|
||||
// which may produce multiple 'C's
|
||||
// so we have to make sure that 'C' is also 'C' in original path
|
||||
|
||||
fixArc(pathArray, i) // fixArc adds also the right amount of 'A's to pcoms
|
||||
|
||||
const seg = pathArray[i]
|
||||
const seglen = seg.length
|
||||
|
||||
attrs.x = seg[seglen - 2]
|
||||
attrs.y = seg[seglen - 1]
|
||||
|
||||
attrs.bx = parseFloat(seg[seglen - 4]) || attrs.x
|
||||
attrs.by = parseFloat(seg[seglen - 3]) || attrs.y
|
||||
}
|
||||
|
||||
// make sure normalized path data string starts with an M segment
|
||||
if (!pathArray[0][0] || pathArray[0][0] !== 'M') {
|
||||
pathArray.unshift(['M', 0, 0])
|
||||
}
|
||||
|
||||
return pathArray
|
||||
}
|
||||
|
||||
/**
|
||||
* Converts provided SVG path data string into a normalized path data string.
|
||||
*
|
||||
* The normalization uses a restricted subset of path commands; all segments
|
||||
* are translated into lineto, curveto, moveto, and closepath segments.
|
||||
*
|
||||
* Relative path commands are changed into their absolute counterparts,
|
||||
* and chaining of coordinates is disallowed.
|
||||
*
|
||||
* The function will always return a valid path data string; if an input
|
||||
* string cannot be normalized, 'M 0 0' is returned.
|
||||
*/
|
||||
export function normalizePathData(pathData: string) {
|
||||
return normalize(pathData)
|
||||
.map((segment: Segment) =>
|
||||
segment.map((item) =>
|
||||
typeof item === 'string' ? item : Util.round(item, 2),
|
||||
),
|
||||
)
|
||||
.join(',')
|
||||
.split(',')
|
||||
.join(' ')
|
||||
}
|
1412
packages/x6-geometry/src/path/path.ts
Normal file
1412
packages/x6-geometry/src/path/path.ts
Normal file
File diff suppressed because it is too large
Load Diff
135
packages/x6-geometry/src/path/segment.ts
Normal file
135
packages/x6-geometry/src/path/segment.ts
Normal file
@ -0,0 +1,135 @@
|
||||
import { Line } from '../line'
|
||||
import { Point } from '../point'
|
||||
import { Rectangle } from '../rectangle'
|
||||
import { Geometry } from '../geometry'
|
||||
|
||||
export abstract class Segment extends Geometry {
|
||||
isVisible = true
|
||||
isSegment = true
|
||||
isSubpathStart = false
|
||||
nextSegment: Segment | null
|
||||
previousSegment: Segment | null
|
||||
subpathStartSegment: Segment | null
|
||||
protected endPoint: Point
|
||||
|
||||
get end() {
|
||||
return this.endPoint
|
||||
}
|
||||
|
||||
get start() {
|
||||
if (this.previousSegment == null) {
|
||||
throw new Error(
|
||||
'Missing previous segment. (This segment cannot be the ' +
|
||||
'first segment of a path, or segment has not yet been ' +
|
||||
'added to a path.)',
|
||||
)
|
||||
}
|
||||
|
||||
return this.previousSegment.end
|
||||
}
|
||||
|
||||
abstract get type(): string
|
||||
|
||||
abstract bbox(): Rectangle | null
|
||||
|
||||
abstract closestPoint(p: Point.PointLike | Point.PointData): Point
|
||||
|
||||
abstract closestPointLength(p: Point.PointLike | Point.PointData): number
|
||||
|
||||
abstract closestPointNormalizedLength(
|
||||
p: Point.PointLike | Point.PointData,
|
||||
): number
|
||||
|
||||
closestPointT(
|
||||
p: Point.PointLike | Point.PointData,
|
||||
options?: Segment.Options, // eslint-disable-line
|
||||
) {
|
||||
if (this.closestPointNormalizedLength) {
|
||||
return this.closestPointNormalizedLength(p)
|
||||
}
|
||||
|
||||
throw new Error(
|
||||
'Neither `closestPointT` nor `closestPointNormalizedLength` method is implemented.',
|
||||
)
|
||||
}
|
||||
|
||||
abstract closestPointTangent(
|
||||
p: Point.PointLike | Point.PointData,
|
||||
): Line | null
|
||||
|
||||
abstract length(options?: Segment.Options): number
|
||||
|
||||
// eslint-disable-next-line
|
||||
lengthAtT(t: number, options?: Segment.Options) {
|
||||
if (t <= 0) {
|
||||
return 0
|
||||
}
|
||||
|
||||
const length = this.length()
|
||||
if (t >= 1) {
|
||||
return length
|
||||
}
|
||||
|
||||
return length * t
|
||||
}
|
||||
|
||||
abstract divideAt(
|
||||
ratio: number,
|
||||
options?: Segment.Options,
|
||||
): [Segment, Segment]
|
||||
|
||||
abstract divideAtLength(
|
||||
length: number,
|
||||
options?: Segment.Options,
|
||||
): [Segment, Segment]
|
||||
|
||||
divideAtT(t: number) {
|
||||
if (this.divideAt) {
|
||||
return this.divideAt(t)
|
||||
}
|
||||
|
||||
throw new Error('Neither `divideAtT` nor `divideAt` method is implemented.')
|
||||
}
|
||||
|
||||
abstract getSubdivisions(options?: Segment.Options): Segment[]
|
||||
|
||||
abstract pointAt(ratio: number): Point
|
||||
|
||||
abstract pointAtLength(length: number, options?: Segment.Options): Point
|
||||
|
||||
pointAtT(t: number): Point {
|
||||
if (this.pointAt) {
|
||||
return this.pointAt(t)
|
||||
}
|
||||
|
||||
throw new Error('Neither `pointAtT` nor `pointAt` method is implemented.')
|
||||
}
|
||||
|
||||
abstract tangentAt(ratio: number): Line | null
|
||||
|
||||
abstract tangentAtLength(
|
||||
length: number,
|
||||
options?: Segment.Options,
|
||||
): Line | null
|
||||
|
||||
tangentAtT(t: number): Line | null {
|
||||
if (this.tangentAt) {
|
||||
return this.tangentAt(t)
|
||||
}
|
||||
|
||||
throw new Error(
|
||||
'Neither `tangentAtT` nor `tangentAt` method is implemented.',
|
||||
)
|
||||
}
|
||||
|
||||
abstract isDifferentiable(): boolean
|
||||
|
||||
abstract clone(): Segment
|
||||
}
|
||||
|
||||
export namespace Segment {
|
||||
export interface Options {
|
||||
precision?: number
|
||||
subdivisions?: Segment[]
|
||||
}
|
||||
}
|
304
packages/x6-geometry/src/path/util.ts
Normal file
304
packages/x6-geometry/src/path/util.ts
Normal file
@ -0,0 +1,304 @@
|
||||
import { Point } from '../point'
|
||||
|
||||
const regexSupportedData = new RegExp(`^[\\s\\dLMCZz,.]*$`)
|
||||
|
||||
export function isValid(data: any) {
|
||||
if (typeof data !== 'string') {
|
||||
return false
|
||||
}
|
||||
|
||||
return regexSupportedData.test(data)
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the remainder of division of `n` by `m`. You should use this
|
||||
* instead of the built-in operation as the built-in operation does not
|
||||
* properly handle negative numbers.
|
||||
*/
|
||||
function mod(n: number, m: number) {
|
||||
return ((n % m) + m) % m
|
||||
}
|
||||
|
||||
export interface DrawPointsOptions {
|
||||
round?: number
|
||||
initialMove?: boolean
|
||||
close?: boolean
|
||||
exclude?: number[]
|
||||
}
|
||||
|
||||
function draw(
|
||||
points: Point.PointLike[],
|
||||
round?: number,
|
||||
initialMove?: boolean,
|
||||
close?: boolean,
|
||||
exclude?: number[],
|
||||
) {
|
||||
const data: (string | number)[] = []
|
||||
const end = points[points.length - 1]
|
||||
const rounded = round != null && round > 0
|
||||
const arcSize = round || 0
|
||||
|
||||
// Adds virtual waypoint in the center between start and end point
|
||||
if (close && rounded) {
|
||||
points = points.slice() // eslint-disable-line
|
||||
const p0 = points[0]
|
||||
const wp = new Point(end.x + (p0.x - end.x) / 2, end.y + (p0.y - end.y) / 2)
|
||||
points.splice(0, 0, wp)
|
||||
}
|
||||
|
||||
let pt = points[0]
|
||||
let i = 1
|
||||
|
||||
// Draws the line segments
|
||||
if (initialMove) {
|
||||
data.push('M', pt.x, pt.y)
|
||||
} else {
|
||||
data.push('L', pt.x, pt.y)
|
||||
}
|
||||
|
||||
while (i < (close ? points.length : points.length - 1)) {
|
||||
let tmp = points[mod(i, points.length)]
|
||||
let dx = pt.x - tmp.x
|
||||
let dy = pt.y - tmp.y
|
||||
|
||||
if (
|
||||
rounded &&
|
||||
(dx !== 0 || dy !== 0) &&
|
||||
(exclude == null || exclude.indexOf(i - 1) < 0)
|
||||
) {
|
||||
// Draws a line from the last point to the current
|
||||
// point with a spacing of size off the current point
|
||||
// into direction of the last point
|
||||
let dist = Math.sqrt(dx * dx + dy * dy)
|
||||
const nx1 = (dx * Math.min(arcSize, dist / 2)) / dist
|
||||
const ny1 = (dy * Math.min(arcSize, dist / 2)) / dist
|
||||
|
||||
const x1 = tmp.x + nx1
|
||||
const y1 = tmp.y + ny1
|
||||
data.push('L', x1, y1)
|
||||
|
||||
// Draws a curve from the last point to the current
|
||||
// point with a spacing of size off the current point
|
||||
// into direction of the next point
|
||||
let next = points[mod(i + 1, points.length)]
|
||||
|
||||
// Uses next non-overlapping point
|
||||
while (
|
||||
i < points.length - 2 &&
|
||||
Math.round(next.x - tmp.x) === 0 &&
|
||||
Math.round(next.y - tmp.y) === 0
|
||||
) {
|
||||
next = points[mod(i + 2, points.length)]
|
||||
i += 1
|
||||
}
|
||||
|
||||
dx = next.x - tmp.x
|
||||
dy = next.y - tmp.y
|
||||
|
||||
dist = Math.max(1, Math.sqrt(dx * dx + dy * dy))
|
||||
const nx2 = (dx * Math.min(arcSize, dist / 2)) / dist
|
||||
const ny2 = (dy * Math.min(arcSize, dist / 2)) / dist
|
||||
|
||||
const x2 = tmp.x + nx2
|
||||
const y2 = tmp.y + ny2
|
||||
|
||||
data.push('Q', tmp.x, tmp.y, x2, y2)
|
||||
tmp = new Point(x2, y2)
|
||||
} else {
|
||||
data.push('L', tmp.x, tmp.y)
|
||||
}
|
||||
|
||||
pt = tmp
|
||||
i += 1
|
||||
}
|
||||
|
||||
if (close) {
|
||||
data.push('Z')
|
||||
} else {
|
||||
data.push('L', end.x, end.y)
|
||||
}
|
||||
|
||||
return data.map((v) => (typeof v === 'string' ? v : +v.toFixed(3))).join(' ')
|
||||
}
|
||||
|
||||
export function drawPoints(
|
||||
points: (Point.PointLike | Point.PointData)[],
|
||||
options: DrawPointsOptions = {},
|
||||
) {
|
||||
const pts: Point.PointLike[] = []
|
||||
if (points && points.length) {
|
||||
points.forEach((p) => {
|
||||
if (Array.isArray(p)) {
|
||||
pts.push({ x: p[0], y: p[1] })
|
||||
} else {
|
||||
pts.push({ x: p.x, y: p.y })
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
return draw(
|
||||
pts,
|
||||
options.round,
|
||||
options.initialMove == null || options.initialMove,
|
||||
options.close,
|
||||
options.exclude,
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Converts the given arc to a series of curves.
|
||||
*/
|
||||
export function arcToCurves(
|
||||
x0: number,
|
||||
y0: number,
|
||||
r1: number,
|
||||
r2: number,
|
||||
angle = 0,
|
||||
largeArcFlag = 0,
|
||||
sweepFlag = 0,
|
||||
x: number,
|
||||
y: number,
|
||||
) {
|
||||
if (r1 === 0 || r2 === 0) {
|
||||
return []
|
||||
}
|
||||
|
||||
x -= x0 // eslint-disable-line
|
||||
y -= y0 // eslint-disable-line
|
||||
r1 = Math.abs(r1) // eslint-disable-line
|
||||
r2 = Math.abs(r2) // eslint-disable-line
|
||||
|
||||
const ctx = -x / 2
|
||||
const cty = -y / 2
|
||||
const cpsi = Math.cos((angle * Math.PI) / 180)
|
||||
const spsi = Math.sin((angle * Math.PI) / 180)
|
||||
const rxd = cpsi * ctx + spsi * cty
|
||||
const ryd = -1 * spsi * ctx + cpsi * cty
|
||||
const rxdd = rxd * rxd
|
||||
const rydd = ryd * ryd
|
||||
const r1x = r1 * r1
|
||||
const r2y = r2 * r2
|
||||
const lamda = rxdd / r1x + rydd / r2y
|
||||
|
||||
let sds
|
||||
|
||||
if (lamda > 1) {
|
||||
r1 = Math.sqrt(lamda) * r1 // eslint-disable-line
|
||||
r2 = Math.sqrt(lamda) * r2 // eslint-disable-line
|
||||
sds = 0
|
||||
} else {
|
||||
let seif = 1
|
||||
if (largeArcFlag === sweepFlag) {
|
||||
seif = -1
|
||||
}
|
||||
|
||||
sds =
|
||||
seif *
|
||||
Math.sqrt(
|
||||
(r1x * r2y - r1x * rydd - r2y * rxdd) / (r1x * rydd + r2y * rxdd),
|
||||
)
|
||||
}
|
||||
|
||||
const txd = (sds * r1 * ryd) / r2
|
||||
const tyd = (-1 * sds * r2 * rxd) / r1
|
||||
const tx = cpsi * txd - spsi * tyd + x / 2
|
||||
const ty = spsi * txd + cpsi * tyd + y / 2
|
||||
|
||||
let rad = Math.atan2((ryd - tyd) / r2, (rxd - txd) / r1) - Math.atan2(0, 1)
|
||||
let s1 = rad >= 0 ? rad : 2 * Math.PI + rad
|
||||
rad =
|
||||
Math.atan2((-ryd - tyd) / r2, (-rxd - txd) / r1) -
|
||||
Math.atan2((ryd - tyd) / r2, (rxd - txd) / r1)
|
||||
let dr = rad >= 0 ? rad : 2 * Math.PI + rad
|
||||
|
||||
if (sweepFlag === 0 && dr > 0) {
|
||||
dr -= 2 * Math.PI
|
||||
} else if (sweepFlag !== 0 && dr < 0) {
|
||||
dr += 2 * Math.PI
|
||||
}
|
||||
|
||||
const sse = (dr * 2) / Math.PI
|
||||
const seg = Math.ceil(sse < 0 ? -1 * sse : sse)
|
||||
const segr = dr / seg
|
||||
const t =
|
||||
((8 / 3) * Math.sin(segr / 4) * Math.sin(segr / 4)) / Math.sin(segr / 2)
|
||||
const cpsir1 = cpsi * r1
|
||||
const cpsir2 = cpsi * r2
|
||||
const spsir1 = spsi * r1
|
||||
const spsir2 = spsi * r2
|
||||
|
||||
let mc = Math.cos(s1)
|
||||
let ms = Math.sin(s1)
|
||||
let x2 = -t * (cpsir1 * ms + spsir2 * mc)
|
||||
let y2 = -t * (spsir1 * ms - cpsir2 * mc)
|
||||
let x3 = 0
|
||||
let y3 = 0
|
||||
|
||||
const result = []
|
||||
|
||||
for (let n = 0; n < seg; n += 1) {
|
||||
s1 += segr
|
||||
mc = Math.cos(s1)
|
||||
ms = Math.sin(s1)
|
||||
|
||||
x3 = cpsir1 * mc - spsir2 * ms + tx
|
||||
y3 = spsir1 * mc + cpsir2 * ms + ty
|
||||
const dx = -t * (cpsir1 * ms + spsir2 * mc)
|
||||
const dy = -t * (spsir1 * ms - cpsir2 * mc)
|
||||
|
||||
// CurveTo updates x0, y0 so need to restore it
|
||||
const index = n * 6
|
||||
result[index] = Number(x2 + x0)
|
||||
result[index + 1] = Number(y2 + y0)
|
||||
result[index + 2] = Number(x3 - dx + x0)
|
||||
result[index + 3] = Number(y3 - dy + y0)
|
||||
result[index + 4] = Number(x3 + x0)
|
||||
result[index + 5] = Number(y3 + y0)
|
||||
|
||||
x2 = x3 + dx
|
||||
y2 = y3 + dy
|
||||
}
|
||||
|
||||
return result.map((num) => +num.toFixed(2))
|
||||
}
|
||||
|
||||
export function drawArc(
|
||||
startX: number,
|
||||
startY: number,
|
||||
rx: number,
|
||||
ry: number,
|
||||
xAxisRotation = 0,
|
||||
largeArcFlag: 0 | 1 = 0,
|
||||
sweepFlag: 0 | 1 = 0,
|
||||
stopX: number,
|
||||
stopY: number,
|
||||
) {
|
||||
const data: (string | number)[] = []
|
||||
const points = arcToCurves(
|
||||
startX,
|
||||
startY,
|
||||
rx,
|
||||
ry,
|
||||
xAxisRotation,
|
||||
largeArcFlag,
|
||||
sweepFlag,
|
||||
stopX,
|
||||
stopY,
|
||||
)
|
||||
|
||||
if (points != null) {
|
||||
for (let i = 0, ii = points.length; i < ii; i += 6) {
|
||||
data.push(
|
||||
'C',
|
||||
points[i],
|
||||
points[i + 1],
|
||||
points[i + 2],
|
||||
points[i + 3],
|
||||
points[i + 4],
|
||||
points[i + 5],
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
return data.join(' ')
|
||||
}
|
432
packages/x6-geometry/src/point.test.ts
Normal file
432
packages/x6-geometry/src/point.test.ts
Normal file
@ -0,0 +1,432 @@
|
||||
import { Point } from './point'
|
||||
|
||||
describe('point', () => {
|
||||
describe('#constructor', () => {
|
||||
it('should create a point instance', () => {
|
||||
expect(new Point()).toBeInstanceOf(Point)
|
||||
expect(new Point(1)).toBeInstanceOf(Point)
|
||||
expect(new Point(1, 2)).toBeInstanceOf(Point)
|
||||
expect(new Point(1, 2).x).toEqual(1)
|
||||
expect(new Point(1, 2).y).toEqual(2)
|
||||
expect(new Point().equals(new Point(0, 0)))
|
||||
})
|
||||
})
|
||||
|
||||
describe('#Point.random', () => {
|
||||
it('should create random point', () => {
|
||||
const p = Point.random(1, 5, 2, 6)
|
||||
expect(p.x >= 1 && p.x <= 5).toBe(true)
|
||||
expect(p.y >= 2 && p.y <= 6).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('#Point.isPointLike', () => {
|
||||
it('should return true when the given object is a point-like object', () => {
|
||||
expect(Point.isPointLike({ x: 1, y: 2 })).toBeTruthy()
|
||||
expect(Point.isPointLike({ x: 1, y: 2, z: 10 })).toBeTruthy()
|
||||
expect(Point.isPointLike({ x: 1, y: 2, z: 10, s: 's' })).toBeTruthy()
|
||||
})
|
||||
|
||||
it('should return false when the given object is a point-like object', () => {
|
||||
expect(Point.isPointLike({ x: 1 })).toBeFalsy()
|
||||
expect(Point.isPointLike({ y: 2 })).toBeFalsy()
|
||||
expect(Point.isPointLike({})).toBeFalsy()
|
||||
expect(Point.isPointLike(null)).toBeFalsy()
|
||||
expect(Point.isPointLike(false)).toBeFalsy()
|
||||
expect(Point.isPointLike(1)).toBeFalsy()
|
||||
expect(Point.isPointLike('s')).toBeFalsy()
|
||||
})
|
||||
})
|
||||
|
||||
describe('#Point.isPointData', () => {
|
||||
it('should return true when the given object is a point-data array', () => {
|
||||
expect(Point.isPointData([1, 2])).toBeTruthy()
|
||||
})
|
||||
|
||||
it('should return false when the given object is a point-data array', () => {
|
||||
expect(Point.isPointData({ x: 1, y: 2 })).toBeFalsy()
|
||||
expect(Point.isPointData([1])).toBeFalsy()
|
||||
expect(Point.isPointData([1, 2, 3])).toBeFalsy()
|
||||
expect(Point.isPointData(null)).toBeFalsy()
|
||||
expect(Point.isPointData(false)).toBeFalsy()
|
||||
expect(Point.isPointData(1)).toBeFalsy()
|
||||
expect(Point.isPointData('s')).toBeFalsy()
|
||||
})
|
||||
})
|
||||
|
||||
describe('#Point.toJSON', () => {
|
||||
it('should conver the given point to json', () => {
|
||||
expect(Point.toJSON([1, 2])).toEqual({ x: 1, y: 2 })
|
||||
expect(Point.toJSON({ x: 1, y: 2 })).toEqual({ x: 1, y: 2 })
|
||||
expect(Point.toJSON(new Point(1, 2))).toEqual({ x: 1, y: 2 })
|
||||
})
|
||||
})
|
||||
|
||||
describe('#Point.equals', () => {
|
||||
it('should return true when the given two points are equal', () => {
|
||||
const p1 = new Point(1, 2)
|
||||
expect(Point.equals(p1, p1)).toBeTruthy()
|
||||
expect(Point.equals(p1, { x: 1, y: 2 })).toBeTruthy()
|
||||
})
|
||||
|
||||
it('should return false when the given two points are not equal', () => {
|
||||
const p1 = new Point(1, 2)
|
||||
const p2 = new Point(2, 2)
|
||||
expect(Point.equals(p1, p2)).toBeFalsy()
|
||||
expect(Point.equals(p1, null as any)).toBeFalsy()
|
||||
expect(Point.equals(p1, { x: 2, y: 2 })).toBeFalsy()
|
||||
})
|
||||
})
|
||||
|
||||
describe('#Point.equalPoints', () => {
|
||||
it('should return true when the given points array are equal', () => {
|
||||
const p1 = new Point(1, 2)
|
||||
expect(Point.equalPoints([p1], [p1])).toBeTruthy()
|
||||
expect(Point.equalPoints([p1], [{ x: 1, y: 2 }])).toBeTruthy()
|
||||
})
|
||||
|
||||
it('should return false when the given points array are not equal', () => {
|
||||
const p1 = new Point(1, 2)
|
||||
const p2 = new Point(2, 2)
|
||||
expect(Point.equalPoints([p1], [p2])).toBeFalsy()
|
||||
expect(Point.equalPoints(null as any, [p2])).toBeFalsy()
|
||||
expect(Point.equalPoints([p1], null as any)).toBeFalsy()
|
||||
expect(Point.equalPoints([p1, p2], [p2])).toBeFalsy()
|
||||
expect(Point.equalPoints([p1, p2], [p2, p1])).toBeFalsy()
|
||||
expect(Point.equalPoints([p1], [{ x: 2, y: 2 }])).toBeFalsy()
|
||||
})
|
||||
})
|
||||
|
||||
describe('#round', () => {
|
||||
it('should round x and y properties to the given precision', () => {
|
||||
const point = new Point(17.231, 4.01)
|
||||
point.round(2)
|
||||
expect(point.serialize()).toEqual('17.23 4.01')
|
||||
point.round(0)
|
||||
expect(point.serialize()).toEqual('17 4')
|
||||
})
|
||||
})
|
||||
|
||||
describe('#add', () => {
|
||||
it('should add x and y width the given amount', () => {
|
||||
const source = new Point(4, 17)
|
||||
const target = new Point(20, 20)
|
||||
|
||||
expect(source.clone().add(16, 3)).toEqual(target)
|
||||
expect(source.clone().add([16, 3])).toEqual(target)
|
||||
expect(source.clone().add({ x: 16, y: 3 })).toEqual(target)
|
||||
})
|
||||
})
|
||||
|
||||
describe('#update', () => {
|
||||
it('should update the values of x and y', () => {
|
||||
const source = new Point(4, 17)
|
||||
const target = new Point(16, 24)
|
||||
expect(source.clone().update(16, 24)).toEqual(target)
|
||||
expect(source.clone().update([16, 24])).toEqual(target)
|
||||
expect(source.clone().update({ x: 16, y: 24 })).toEqual(target)
|
||||
expect(source.clone().update(target)).toEqual(target)
|
||||
})
|
||||
})
|
||||
|
||||
describe('#translate', () => {
|
||||
it('should translate x and y by adding the given dx and dy values respectively', () => {
|
||||
const point = new Point(0, 0)
|
||||
point.translate(2, 3)
|
||||
expect(point.toJSON()).toEqual({ x: 2, y: 3 })
|
||||
point.translate(new Point(-2, 4))
|
||||
expect(point.toJSON()).toEqual({ x: 0, y: 7 })
|
||||
})
|
||||
})
|
||||
|
||||
describe('#rotate', () => {
|
||||
const rotate = (p: Point, angle: number, center?: Point) =>
|
||||
p.clone().rotate(angle, center).round(3).serialize()
|
||||
|
||||
it('should return a rotated version of self', () => {
|
||||
const point = new Point(5, 5)
|
||||
let angle
|
||||
|
||||
const zeroPoint = new Point(0, 0)
|
||||
const arbitraryPoint = new Point(14, 6)
|
||||
|
||||
angle = 0
|
||||
expect(rotate(point, angle)).toEqual('5 5')
|
||||
expect(rotate(point, angle, zeroPoint)).toEqual('5 5')
|
||||
expect(rotate(point, angle, point)).toEqual('5 5')
|
||||
expect(rotate(point, angle, arbitraryPoint)).toEqual('5 5')
|
||||
|
||||
angle = 154
|
||||
expect(rotate(point, angle)).toEqual('-2.302 -6.686')
|
||||
expect(rotate(point, angle, zeroPoint)).toEqual('-2.302 -6.686')
|
||||
expect(rotate(point, angle, point)).toEqual('5 5')
|
||||
expect(rotate(point, angle, arbitraryPoint)).toEqual('21.651 10.844')
|
||||
})
|
||||
})
|
||||
|
||||
describe('#scale', () => {
|
||||
it('should scale point with the given amount', () => {
|
||||
expect(new Point(20, 30).scale(2, 3)).toEqual(new Point(40, 90))
|
||||
})
|
||||
|
||||
it('should scale point with the given amount and center ', () => {
|
||||
expect(new Point(20, 30).scale(2, 3, new Point(40, 45))).toEqual(
|
||||
new Point(0, 0),
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe('#closest', () => {
|
||||
it('should return the closest point', () => {
|
||||
const a = new Point(10, 10)
|
||||
const b = { x: 20, y: 20 }
|
||||
const c = { x: 30, y: 30 }
|
||||
|
||||
expect(a.closest([])).toBeNull()
|
||||
|
||||
expect(a.closest([b])).toBeInstanceOf(Point)
|
||||
expect(a.closest([b])!.toJSON()).toEqual(b)
|
||||
expect(a.closest([b, c])!.toJSON()).toEqual(b)
|
||||
expect(a.closest([b, c])!.toJSON()).toEqual(b)
|
||||
})
|
||||
})
|
||||
|
||||
describe('#distance', () => {
|
||||
it('should return the distance between me and the given point', () => {
|
||||
const source = new Point(1, 2)
|
||||
const target = new Point(4, 6)
|
||||
|
||||
expect(source.distance(target)).toEqual(5)
|
||||
})
|
||||
})
|
||||
|
||||
describe('#squaredDistance', () => {
|
||||
it('should return the squared distance between me and the given point', () => {
|
||||
const source = new Point(1, 2)
|
||||
const target = new Point(4, 6)
|
||||
|
||||
expect(source.squaredDistance(target)).toEqual(25)
|
||||
})
|
||||
})
|
||||
|
||||
describe('#manhattanDistance', () => {
|
||||
it('should return the manhattan distance between me and the given point', () => {
|
||||
const source = new Point(1, 2)
|
||||
const target = new Point(4, 6)
|
||||
|
||||
expect(source.manhattanDistance(target)).toEqual(7)
|
||||
})
|
||||
})
|
||||
|
||||
describe('#magnitude', () => {
|
||||
it('should return the magnitude of the given point', () => {
|
||||
expect(new Point(3, 4).magnitude()).toEqual(5)
|
||||
})
|
||||
|
||||
it('should return `0.01` when the given point is `{0, 0}`', () => {
|
||||
expect(new Point(0, 0).magnitude()).toEqual(0.01)
|
||||
})
|
||||
})
|
||||
|
||||
describe('#theta', () => {
|
||||
it('should return the angle between me and p and the x-axis.', () => {
|
||||
const me = new Point(1, 1)
|
||||
|
||||
expect(me.theta(me)).toBe(-0)
|
||||
expect(me.theta(new Point(2, 1))).toBe(-0)
|
||||
expect(me.theta(new Point(2, 0))).toBe(45)
|
||||
expect(me.theta(new Point(1, 0))).toBe(90)
|
||||
expect(me.theta(new Point(0, 0))).toBe(135)
|
||||
expect(me.theta(new Point(0, 1))).toBe(180)
|
||||
expect(me.theta(new Point(0, 2))).toBe(225)
|
||||
expect(me.theta(new Point(1, 2))).toBe(270)
|
||||
expect(me.theta(new Point(2, 2))).toBe(315)
|
||||
})
|
||||
})
|
||||
|
||||
describe('#angleBetween', () => {
|
||||
it('should returns the angle between vector from me to `p1` and the vector from me to `p2`.', () => {
|
||||
const me = new Point(1, 2)
|
||||
const p1 = new Point(2, 4)
|
||||
const p2 = new Point(4, 3)
|
||||
|
||||
const PRECISION = 10
|
||||
|
||||
expect(me.angleBetween(me, me)).toBeNaN()
|
||||
expect(me.angleBetween(p1, me)).toBeNaN()
|
||||
expect(me.angleBetween(me, p2)).toBeNaN()
|
||||
expect(me.angleBetween(p1, p2).toFixed(PRECISION)).toBe('45.0000000000')
|
||||
expect(me.angleBetween(p2, p1).toFixed(PRECISION)).toBe('315.0000000000')
|
||||
})
|
||||
})
|
||||
|
||||
describe('#changeInAngle', () => {
|
||||
it('should return the change in angle from my previous position `-dx, -dy` to my new position relative to origin point', () => {
|
||||
const p = new Point(1, 1)
|
||||
expect(p.changeInAngle(1, 0)).toEqual(-45)
|
||||
})
|
||||
|
||||
it('should return the change in angle from my previous position `-dx, -dy` to my new position relative to `ref` point', () => {
|
||||
const p = new Point(2, 2)
|
||||
expect(p.changeInAngle(1, 0, { x: 1, y: 1 })).toEqual(-45)
|
||||
})
|
||||
})
|
||||
|
||||
describe('#adhereToRect', () => {
|
||||
it('should return `p` when `p` is contained in `rect`', () => {
|
||||
const p = new Point(2, 2)
|
||||
const rect = { x: 1, y: 1, width: 4, height: 4 }
|
||||
expect(p.adhereToRect(rect).equals(p)).toBeTruthy()
|
||||
})
|
||||
|
||||
it('should adhere to target `rect` when `p` is outside of `rect`', () => {
|
||||
const p = new Point(2, 8)
|
||||
const rect = { x: 1, y: 1, width: 4, height: 4 }
|
||||
expect(p.adhereToRect(rect).equals({ x: 2, y: 5 })).toBeTruthy()
|
||||
})
|
||||
})
|
||||
|
||||
describe('#bearing', () => {
|
||||
it('should return the bearing between me and the given point.', () => {
|
||||
const p = new Point(2, 8)
|
||||
expect(p.bearing(new Point())).toEqual('S')
|
||||
})
|
||||
})
|
||||
|
||||
describe('#vectorAngle', () => {
|
||||
it('should return the angle between the vector from `0,0` to me and the vector from `0,0` to `p`', () => {
|
||||
const p0 = new Point(1, 2)
|
||||
const p = new Point(3, 1)
|
||||
const zero = new Point(0, 0)
|
||||
|
||||
const PRECISION = 10
|
||||
|
||||
expect(zero.vectorAngle(zero)).toBeNaN()
|
||||
expect(p0.vectorAngle(zero)).toBeNaN()
|
||||
expect(p.vectorAngle(zero)).toBeNaN()
|
||||
expect(zero.vectorAngle(p0)).toBeNaN()
|
||||
expect(zero.vectorAngle(p)).toBeNaN()
|
||||
expect(p0.vectorAngle(p).toFixed(PRECISION)).toBe('45.0000000000')
|
||||
expect(p.vectorAngle(p0).toFixed(PRECISION)).toBe('315.0000000000')
|
||||
})
|
||||
})
|
||||
|
||||
describe('#diff', () => {
|
||||
it('should return the diff as a point with the given point', () => {
|
||||
expect(new Point(0, 10).diff(4, 8)).toEqual(new Point(-4, 2))
|
||||
expect(new Point(5, 8).diff({ x: 5, y: 10 })).toEqual(new Point(0, -2))
|
||||
})
|
||||
})
|
||||
|
||||
describe('#lerp', () => {
|
||||
it('should return an interpolation between me and the given point `p`', () => {
|
||||
expect(new Point(1, 1).lerp({ x: 3, y: 3 }, 0.5)).toEqual(new Point(2, 2))
|
||||
})
|
||||
})
|
||||
|
||||
describe('#normalize', () => {
|
||||
it('should scale x and y such that the distance between the point and the origin (0,0) is equal to the given length', () => {
|
||||
expect(new Point(0, 10).normalize(20).serialize()).toEqual('0 20')
|
||||
expect(new Point(2, 0).normalize(4).serialize()).toEqual('4 0')
|
||||
})
|
||||
})
|
||||
|
||||
describe('#move', () => {
|
||||
it('should move the point on a line that leads to another point `ref` by a certain `distance`.', () => {
|
||||
expect(new Point(1, 1).move({ x: 1, y: 0 }, 5).round(0)).toEqual(
|
||||
new Point(1, 6),
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe('#reflection', () => {
|
||||
it('should return a point that is the reflection of me with the center of inversion in `ref` point.', () => {
|
||||
expect(new Point(1, 0).reflection({ x: 1, y: 1 }).round(0)).toEqual(
|
||||
new Point(1, 2),
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe('#cross', () => {
|
||||
it('should return the cross product of the vector from me to `p1` and the vector from me to `p2`', () => {
|
||||
const p0 = new Point(3, 15)
|
||||
const p1 = new Point(4, 17)
|
||||
const p2 = new Point(2, 10)
|
||||
|
||||
expect(p0.cross(p1, p2)).toBe(3)
|
||||
expect(p0.cross(p2, p1)).toBe(-3)
|
||||
})
|
||||
|
||||
it('shoule return `NAN` when any of the given point is null', () => {
|
||||
expect(new Point().cross(null as any, new Point(1, 2))).toBeNaN()
|
||||
})
|
||||
})
|
||||
|
||||
describe('#dot', () => {
|
||||
it('should return the dot product of `p`', () => {
|
||||
const p1 = new Point(4, 17)
|
||||
const p2 = new Point(2, 10)
|
||||
expect(p1.dot(p2)).toBe(178)
|
||||
expect(p2.dot(p1)).toBe(178)
|
||||
})
|
||||
})
|
||||
|
||||
describe('#equals', () => {
|
||||
it('should return the true when have same coord', () => {
|
||||
const p1 = new Point(4, 17)
|
||||
const p2 = p1.clone()
|
||||
expect(p1.equals(p2)).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('#toJSON', () => {
|
||||
it('should return json-object', () => {
|
||||
const p1 = new Point(4, 17)
|
||||
const p2 = p1.toJSON()
|
||||
expect(p1.equals(p2)).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('#toPolar', () => {
|
||||
it('should convert rectangular to polar coordinates.', () => {
|
||||
const p = new Point(4, 3).toPolar()
|
||||
expect(p.x).toBe(5)
|
||||
})
|
||||
})
|
||||
|
||||
describe('#fromPolar', () => {
|
||||
it('should convert polar to rectangular coordinates.', () => {
|
||||
const p1 = new Point(4, 3).toPolar()
|
||||
const p2 = Point.fromPolar(p1.x, p1.y)
|
||||
expect(Math.round(p2.x)).toBe(4)
|
||||
expect(Math.round(p2.y)).toBe(3)
|
||||
|
||||
const p3 = new Point(-4, 3).toPolar()
|
||||
const p4 = Point.fromPolar(p3.x, p3.y)
|
||||
expect(Math.round(p4.x)).toBe(-4)
|
||||
expect(Math.round(p4.y)).toBe(3)
|
||||
|
||||
const p5 = new Point(4, -3).toPolar()
|
||||
const p6 = Point.fromPolar(p5.x, p5.y)
|
||||
expect(Math.round(p6.x)).toBe(4)
|
||||
expect(Math.round(p6.y)).toBe(-3)
|
||||
|
||||
const p7 = new Point(-4, -3).toPolar()
|
||||
const p8 = Point.fromPolar(p7.x, p7.y)
|
||||
expect(Math.round(p8.x)).toBe(-4)
|
||||
expect(Math.round(p8.y)).toBe(-3)
|
||||
})
|
||||
})
|
||||
|
||||
describe('#snapToGrid', () => {
|
||||
it('should snap to grid', () => {
|
||||
const p1 = new Point(4, 17)
|
||||
const p2 = p1.clone().snapToGrid(10)
|
||||
const p3 = p1.clone().snapToGrid(3, 5)
|
||||
expect(p2.x).toBe(0)
|
||||
expect(p2.y).toBe(20)
|
||||
expect(p3.x).toBe(3)
|
||||
expect(p3.y).toBe(15)
|
||||
})
|
||||
})
|
||||
})
|
595
packages/x6-geometry/src/point.ts
Normal file
595
packages/x6-geometry/src/point.ts
Normal file
@ -0,0 +1,595 @@
|
||||
import { Util } from './util'
|
||||
import { Angle } from './angle'
|
||||
import { Geometry } from './geometry'
|
||||
import { Rectangle } from './rectangle'
|
||||
|
||||
export class Point extends Geometry implements Point.PointLike {
|
||||
public x: number
|
||||
public y: number
|
||||
|
||||
protected get [Symbol.toStringTag]() {
|
||||
return Point.toStringTag
|
||||
}
|
||||
|
||||
constructor(x?: number, y?: number) {
|
||||
super()
|
||||
this.x = x == null ? 0 : x
|
||||
this.y = y == null ? 0 : y
|
||||
}
|
||||
|
||||
/**
|
||||
* Rounds the point to the given precision.
|
||||
*/
|
||||
round(precision = 0) {
|
||||
this.x = Util.round(this.x, precision)
|
||||
this.y = Util.round(this.y, precision)
|
||||
return this
|
||||
}
|
||||
|
||||
add(x: number, y: number): this
|
||||
add(p: Point.PointLike | Point.PointData): this
|
||||
add(x: number | Point.PointLike | Point.PointData, y?: number): this {
|
||||
const p = Point.create(x, y)
|
||||
this.x += p.x
|
||||
this.y += p.y
|
||||
return this
|
||||
}
|
||||
|
||||
/**
|
||||
* Update the point's `x` and `y` coordinates with new values and return the
|
||||
* point itself. Useful for chaining.
|
||||
*/
|
||||
update(x: number, y: number): this
|
||||
update(p: Point.PointLike | Point.PointData): this
|
||||
update(x: number | Point.PointLike | Point.PointData, y?: number): this {
|
||||
const p = Point.create(x, y)
|
||||
this.x = p.x
|
||||
this.y = p.y
|
||||
return this
|
||||
}
|
||||
|
||||
translate(dx: number, dy: number): this
|
||||
translate(p: Point.PointLike | Point.PointData): this
|
||||
translate(dx: number | Point.PointLike | Point.PointData, dy?: number): this {
|
||||
const t = Point.create(dx, dy)
|
||||
this.x += t.x
|
||||
this.y += t.y
|
||||
return this
|
||||
}
|
||||
|
||||
/**
|
||||
* Rotate the point by `degree` around `center`.
|
||||
*/
|
||||
rotate(degree: number, center?: Point.PointLike | Point.PointData): this {
|
||||
const p = Point.rotate(this, degree, center)
|
||||
this.x = p.x
|
||||
this.y = p.y
|
||||
return this
|
||||
}
|
||||
|
||||
/**
|
||||
* Scale point by `sx` and `sy` around the given `origin`. If origin is not
|
||||
* specified, the point is scaled around `0,0`.
|
||||
*/
|
||||
scale(
|
||||
sx: number,
|
||||
sy: number,
|
||||
origin: Point.PointLike | Point.PointData = new Point(),
|
||||
) {
|
||||
const ref = Point.create(origin)
|
||||
this.x = ref.x + sx * (this.x - ref.x)
|
||||
this.y = ref.y + sy * (this.y - ref.y)
|
||||
return this
|
||||
}
|
||||
|
||||
/**
|
||||
* Chooses the point closest to this point from among `points`. If `points`
|
||||
* is an empty array, `null` is returned.
|
||||
*/
|
||||
closest(points: (Point.PointLike | Point.PointData)[]) {
|
||||
if (points.length === 1) {
|
||||
return Point.create(points[0])
|
||||
}
|
||||
|
||||
let ret: Point.PointLike | Point.PointData | null = null
|
||||
let min = Infinity
|
||||
points.forEach((p) => {
|
||||
const dist = this.squaredDistance(p)
|
||||
if (dist < min) {
|
||||
ret = p
|
||||
min = dist
|
||||
}
|
||||
})
|
||||
|
||||
return ret ? Point.create(ret) : null
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the distance between the point and another point `p`.
|
||||
*/
|
||||
distance(p: Point.PointLike | Point.PointData) {
|
||||
return Math.sqrt(this.squaredDistance(p))
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the squared distance between the point and another point `p`.
|
||||
*
|
||||
* Useful for distance comparisons in which real distance is not necessary
|
||||
* (saves one `Math.sqrt()` operation).
|
||||
*/
|
||||
squaredDistance(p: Point.PointLike | Point.PointData) {
|
||||
const ref = Point.create(p)
|
||||
const dx = this.x - ref.x
|
||||
const dy = this.y - ref.y
|
||||
return dx * dx + dy * dy
|
||||
}
|
||||
|
||||
manhattanDistance(p: Point.PointLike | Point.PointData) {
|
||||
const ref = Point.create(p)
|
||||
return Math.abs(ref.x - this.x) + Math.abs(ref.y - this.y)
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the magnitude of the point vector.
|
||||
*
|
||||
* @see http://en.wikipedia.org/wiki/Magnitude_(mathematics)
|
||||
*/
|
||||
magnitude() {
|
||||
return Math.sqrt(this.x * this.x + this.y * this.y) || 0.01
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the angle(in degrees) between vector from this point to `p` and
|
||||
* the x-axis.
|
||||
*/
|
||||
theta(p: Point.PointLike | Point.PointData = new Point()): number {
|
||||
const ref = Point.create(p)
|
||||
const y = -(ref.y - this.y) // invert the y-axis.
|
||||
const x = ref.x - this.x
|
||||
let rad = Math.atan2(y, x)
|
||||
|
||||
// Correction for III. and IV. quadrant.
|
||||
if (rad < 0) {
|
||||
rad = 2 * Math.PI + rad
|
||||
}
|
||||
|
||||
return (180 * rad) / Math.PI
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the angle(in degrees) between vector from this point to `p1` and
|
||||
* the vector from this point to `p2`.
|
||||
*
|
||||
* The ordering of points `p1` and `p2` is important.
|
||||
*
|
||||
* The function returns a value between `0` and `180` when the angle (in the
|
||||
* direction from `p1` to `p2`) is clockwise, and a value between `180` and
|
||||
* `360` when the angle is counterclockwise.
|
||||
*
|
||||
* Returns `NaN` if either of the points `p1` and `p2` is equal with this point.
|
||||
*/
|
||||
angleBetween(
|
||||
p1: Point.PointLike | Point.PointData,
|
||||
p2: Point.PointLike | Point.PointData,
|
||||
) {
|
||||
if (this.equals(p1) || this.equals(p2)) {
|
||||
return NaN
|
||||
}
|
||||
|
||||
let angle = this.theta(p2) - this.theta(p1)
|
||||
if (angle < 0) {
|
||||
angle += 360
|
||||
}
|
||||
|
||||
return angle
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the angle(in degrees) between the line from `(0,0)` and this point
|
||||
* and the line from `(0,0)` to `p`.
|
||||
*
|
||||
* The function returns a value between `0` and `180` when the angle (in the
|
||||
* direction from this point to `p`) is clockwise, and a value between `180`
|
||||
* and `360` when the angle is counterclockwise. Returns `NaN` if called from
|
||||
* point `(0,0)` or if `p` is `(0,0)`.
|
||||
*/
|
||||
vectorAngle(p: Point.PointLike | Point.PointData) {
|
||||
const zero = new Point(0, 0)
|
||||
return zero.angleBetween(this, p)
|
||||
}
|
||||
|
||||
/**
|
||||
* Converts rectangular to polar coordinates.
|
||||
*/
|
||||
toPolar(origin?: Point.PointLike | Point.PointData) {
|
||||
this.update(Point.toPolar(this, origin))
|
||||
return this
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the change in angle(in degrees) that is the result of moving the
|
||||
* point from its previous position to its current position.
|
||||
*
|
||||
* More specifically, this function computes the angle between the line from
|
||||
* the ref point to the previous position of this point(i.e. current position
|
||||
* `-dx`, `-dy`) and the line from the `ref` point to the current position of
|
||||
* this point.
|
||||
*
|
||||
* The function returns a positive value between `0` and `180` when the angle
|
||||
* (in the direction from previous position of this point to its current
|
||||
* position) is clockwise, and a negative value between `0` and `-180` when
|
||||
* the angle is counterclockwise.
|
||||
*
|
||||
* The function returns `0` if the previous and current positions of this
|
||||
* point are the same (i.e. both `dx` and `dy` are `0`).
|
||||
*/
|
||||
changeInAngle(
|
||||
dx: number,
|
||||
dy: number,
|
||||
ref: Point.PointLike | Point.PointData = new Point(),
|
||||
) {
|
||||
// Revert the translation and measure the change in angle around x-axis.
|
||||
return this.clone().translate(-dx, -dy).theta(ref) - this.theta(ref)
|
||||
}
|
||||
|
||||
/**
|
||||
* If the point lies outside the rectangle `rect`, adjust the point so that
|
||||
* it becomes the nearest point on the boundary of `rect`.
|
||||
*/
|
||||
adhereToRect(rect: Rectangle.RectangleLike) {
|
||||
if (!Util.containsPoint(rect, this)) {
|
||||
this.x = Math.min(Math.max(this.x, rect.x), rect.x + rect.width)
|
||||
this.y = Math.min(Math.max(this.y, rect.y), rect.y + rect.height)
|
||||
}
|
||||
return this
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the bearing(cardinal direction) between me and the given point.
|
||||
*
|
||||
* @see https://en.wikipedia.org/wiki/Cardinal_direction
|
||||
*/
|
||||
bearing(p: Point.PointLike | Point.PointData) {
|
||||
const ref = Point.create(p)
|
||||
const lat1 = Angle.toRad(this.y)
|
||||
const lat2 = Angle.toRad(ref.y)
|
||||
const lon1 = this.x
|
||||
const lon2 = ref.x
|
||||
const dLon = Angle.toRad(lon2 - lon1)
|
||||
const y = Math.sin(dLon) * Math.cos(lat2)
|
||||
const x =
|
||||
Math.cos(lat1) * Math.sin(lat2) -
|
||||
Math.sin(lat1) * Math.cos(lat2) * Math.cos(dLon)
|
||||
|
||||
const brng = Angle.toDeg(Math.atan2(y, x))
|
||||
const bearings = ['NE', 'E', 'SE', 'S', 'SW', 'W', 'NW', 'N']
|
||||
|
||||
let index = brng - 22.5
|
||||
if (index < 0) {
|
||||
index += 360
|
||||
}
|
||||
index = parseInt((index / 45) as any, 10)
|
||||
return bearings[index] as Point.Bearing
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the cross product of the vector from me to `p1` and the vector
|
||||
* from me to `p2`.
|
||||
*
|
||||
* The left-hand rule is used because the coordinate system is left-handed.
|
||||
*/
|
||||
cross(
|
||||
p1: Point.PointLike | Point.PointData,
|
||||
p2: Point.PointLike | Point.PointData,
|
||||
) {
|
||||
if (p1 != null && p2 != null) {
|
||||
const a = Point.create(p1)
|
||||
const b = Point.create(p2)
|
||||
return (b.x - this.x) * (a.y - this.y) - (b.y - this.y) * (a.x - this.x)
|
||||
}
|
||||
|
||||
return NaN
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the dot product of this point with given other point.
|
||||
*/
|
||||
dot(p: Point.PointLike | Point.PointData) {
|
||||
const ref = Point.create(p)
|
||||
return this.x * ref.x + this.y * ref.y
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns a point that has coordinates computed as a difference between the
|
||||
* point and another point with coordinates `dx` and `dy`.
|
||||
*
|
||||
* If only `dx` is specified and is a number, `dy` is considered to be zero.
|
||||
* If only `dx` is specified and is an object, it is considered to be another
|
||||
* point or an object in the form `{ x: [number], y: [number] }`
|
||||
*/
|
||||
diff(dx: number, dy: number): Point
|
||||
diff(p: Point.PointLike | Point.PointData): Point
|
||||
diff(dx: number | Point.PointLike | Point.PointData, dy?: number): Point {
|
||||
if (typeof dx === 'number') {
|
||||
return new Point(this.x - dx, this.y - dy!)
|
||||
}
|
||||
|
||||
const p = Point.create(dx)
|
||||
return new Point(this.x - p.x, this.y - p.y)
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns an interpolation between me and point `p` for a parametert in
|
||||
* the closed interval `[0, 1]`.
|
||||
*/
|
||||
lerp(p: Point.PointLike | Point.PointData, t: number) {
|
||||
const ref = Point.create(p)
|
||||
return new Point((1 - t) * this.x + t * ref.x, (1 - t) * this.y + t * ref.y)
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalize the point vector, scale the line segment between `(0, 0)`
|
||||
* and the point in order for it to have the given length. If length is
|
||||
* not specified, it is considered to be `1`; in that case, a unit vector
|
||||
* is computed.
|
||||
*/
|
||||
normalize(length = 1) {
|
||||
const scale = length / this.magnitude()
|
||||
return this.scale(scale, scale)
|
||||
}
|
||||
|
||||
/**
|
||||
* Moves this point along the line starting from `ref` to this point by a
|
||||
* certain `distance`.
|
||||
*/
|
||||
move(ref: Point.PointLike | Point.PointData, distance: number) {
|
||||
const p = Point.create(ref)
|
||||
const rad = Angle.toRad(p.theta(this))
|
||||
return this.translate(Math.cos(rad) * distance, -Math.sin(rad) * distance)
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns a point that is the reflection of me with the center of inversion
|
||||
* in `ref` point.
|
||||
*/
|
||||
reflection(ref: Point.PointLike | Point.PointData) {
|
||||
return Point.create(ref).move(this, this.distance(ref))
|
||||
}
|
||||
|
||||
/**
|
||||
* Snaps the point(change its x and y coordinates) to a grid of size `gridSize`
|
||||
* (or `gridSize` x `gridSizeY` for non-uniform grid).
|
||||
*/
|
||||
snapToGrid(gridSize: number): this
|
||||
snapToGrid(gx: number, gy: number): this
|
||||
snapToGrid(gx: number, gy?: number): this
|
||||
snapToGrid(gx: number, gy?: number): this {
|
||||
this.x = Util.snapToGrid(this.x, gx)
|
||||
this.y = Util.snapToGrid(this.y, gy == null ? gx : gy)
|
||||
return this
|
||||
}
|
||||
|
||||
equals(p: Point.PointLike | Point.PointData) {
|
||||
const ref = Point.create(p)
|
||||
return ref != null && ref.x === this.x && ref.y === this.y
|
||||
}
|
||||
|
||||
clone() {
|
||||
return Point.clone(this)
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the point as a simple JSON object. For example: `{ x: 0, y: 0 }`.
|
||||
*/
|
||||
toJSON() {
|
||||
return Point.toJSON(this)
|
||||
}
|
||||
|
||||
serialize() {
|
||||
return `${this.x} ${this.y}`
|
||||
}
|
||||
}
|
||||
|
||||
export namespace Point {
|
||||
export const toStringTag = `X6.Geometry.${Point.name}`
|
||||
|
||||
export function isPoint(instance: any): instance is Point {
|
||||
if (instance == null) {
|
||||
return false
|
||||
}
|
||||
if (instance instanceof Point) {
|
||||
return true
|
||||
}
|
||||
|
||||
const tag = instance[Symbol.toStringTag]
|
||||
const point = instance as Point
|
||||
|
||||
if (
|
||||
(tag == null || tag === toStringTag) &&
|
||||
typeof point.x === 'number' &&
|
||||
typeof point.y === 'number' &&
|
||||
typeof point.toPolar === 'function'
|
||||
) {
|
||||
return true
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
export namespace Point {
|
||||
export interface PointLike {
|
||||
x: number
|
||||
y: number
|
||||
}
|
||||
|
||||
export type PointData = [number, number]
|
||||
|
||||
export type Bearing = 'NE' | 'E' | 'SE' | 'S' | 'SW' | 'W' | 'NW' | 'N'
|
||||
|
||||
export function isPointLike(p: any): p is PointLike {
|
||||
return (
|
||||
p != null &&
|
||||
typeof p === 'object' &&
|
||||
typeof p.x === 'number' &&
|
||||
typeof p.y === 'number'
|
||||
)
|
||||
}
|
||||
|
||||
export function isPointData(p: any): p is PointData {
|
||||
return (
|
||||
p != null &&
|
||||
Array.isArray(p) &&
|
||||
p.length === 2 &&
|
||||
typeof p[0] === 'number' &&
|
||||
typeof p[1] === 'number'
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
export namespace Point {
|
||||
export function create(
|
||||
x?: number | Point | PointLike | PointData,
|
||||
y?: number,
|
||||
): Point {
|
||||
if (x == null || typeof x === 'number') {
|
||||
return new Point(x, y)
|
||||
}
|
||||
|
||||
return clone(x)
|
||||
}
|
||||
|
||||
export function clone(p: Point | PointLike | PointData) {
|
||||
if (Point.isPoint(p)) {
|
||||
return new Point(p.x, p.y)
|
||||
}
|
||||
|
||||
if (Array.isArray(p)) {
|
||||
return new Point(p[0], p[1])
|
||||
}
|
||||
|
||||
return new Point(p.x, p.y)
|
||||
}
|
||||
|
||||
export function toJSON(p: Point | PointLike | PointData) {
|
||||
if (Point.isPoint(p)) {
|
||||
return { x: p.x, y: p.y }
|
||||
}
|
||||
|
||||
if (Array.isArray(p)) {
|
||||
return { x: p[0], y: p[1] }
|
||||
}
|
||||
|
||||
return { x: p.x, y: p.y }
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns a new Point object from the given polar coordinates.
|
||||
* @see http://en.wikipedia.org/wiki/Polar_coordinate_system
|
||||
*/
|
||||
export function fromPolar(
|
||||
r: number,
|
||||
rad: number,
|
||||
origin: Point | PointLike | PointData = new Point(),
|
||||
) {
|
||||
let x = Math.abs(r * Math.cos(rad))
|
||||
let y = Math.abs(r * Math.sin(rad))
|
||||
const org = clone(origin)
|
||||
const deg = Angle.normalize(Angle.toDeg(rad))
|
||||
|
||||
if (deg < 90) {
|
||||
y = -y
|
||||
} else if (deg < 180) {
|
||||
x = -x
|
||||
y = -y
|
||||
} else if (deg < 270) {
|
||||
x = -x
|
||||
}
|
||||
|
||||
return new Point(org.x + x, org.y + y)
|
||||
}
|
||||
|
||||
/**
|
||||
* Converts rectangular to polar coordinates.
|
||||
*/
|
||||
export function toPolar(
|
||||
point: Point | PointLike | PointData,
|
||||
origin: Point | PointLike | PointData = new Point(),
|
||||
) {
|
||||
const p = clone(point)
|
||||
const o = clone(origin)
|
||||
const dx = p.x - o.x
|
||||
const dy = p.y - o.y
|
||||
return new Point(
|
||||
Math.sqrt(dx * dx + dy * dy), // r
|
||||
Angle.toRad(o.theta(p)),
|
||||
)
|
||||
}
|
||||
|
||||
export function equals(p1?: Point.PointLike, p2?: Point.PointLike) {
|
||||
if (p1 === p2) {
|
||||
return true
|
||||
}
|
||||
|
||||
if (p1 != null && p2 != null) {
|
||||
return p1.x === p2.x && p1.y === p2.y
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
export function equalPoints(p1: Point.PointLike[], p2: Point.PointLike[]) {
|
||||
if (
|
||||
(p1 == null && p2 != null) ||
|
||||
(p1 != null && p2 == null) ||
|
||||
(p1 != null && p2 != null && p1.length !== p2.length)
|
||||
) {
|
||||
return false
|
||||
}
|
||||
|
||||
if (p1 != null && p2 != null) {
|
||||
for (let i = 0, ii = p1.length; i < ii; i += 1) {
|
||||
if (!equals(p1[i], p2[i])) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns a point with random coordinates that fall within the range
|
||||
* `[x1, x2]` and `[y1, y2]`.
|
||||
*/
|
||||
export function random(x1: number, x2: number, y1: number, y2: number) {
|
||||
return new Point(Util.random(x1, x2), Util.random(y1, y2))
|
||||
}
|
||||
|
||||
export function rotate(
|
||||
point: Point | PointLike | PointData,
|
||||
angle: number,
|
||||
center?: Point | PointLike | PointData,
|
||||
) {
|
||||
const rad = Angle.toRad(Angle.normalize(-angle))
|
||||
const sin = Math.sin(rad)
|
||||
const cos = Math.cos(rad)
|
||||
|
||||
return rotateEx(point, cos, sin, center)
|
||||
}
|
||||
|
||||
export function rotateEx(
|
||||
point: Point | PointLike | PointData,
|
||||
cos: number,
|
||||
sin: number,
|
||||
center: Point | PointLike | PointData = new Point(),
|
||||
) {
|
||||
const source = clone(point)
|
||||
const origin = clone(center)
|
||||
const dx = source.x - origin.x
|
||||
const dy = source.y - origin.y
|
||||
const x1 = dx * cos - dy * sin
|
||||
const y1 = dy * cos + dx * sin
|
||||
return new Point(x1 + origin.x, y1 + origin.y)
|
||||
}
|
||||
}
|
670
packages/x6-geometry/src/polyline.ts
Normal file
670
packages/x6-geometry/src/polyline.ts
Normal file
@ -0,0 +1,670 @@
|
||||
import { Line } from './line'
|
||||
import { Point } from './point'
|
||||
import { Rectangle } from './rectangle'
|
||||
import { Geometry } from './geometry'
|
||||
|
||||
export class Polyline extends Geometry {
|
||||
points: Point[]
|
||||
|
||||
protected get [Symbol.toStringTag]() {
|
||||
return Polyline.toStringTag
|
||||
}
|
||||
|
||||
get start() {
|
||||
if (this.points.length === 0) {
|
||||
return null
|
||||
}
|
||||
return this.points[0]
|
||||
}
|
||||
|
||||
get end() {
|
||||
if (this.points.length === 0) {
|
||||
return null
|
||||
}
|
||||
return this.points[this.points.length - 1]
|
||||
}
|
||||
|
||||
constructor(points?: (Point.PointLike | Point.PointData)[] | string) {
|
||||
super()
|
||||
if (points != null) {
|
||||
if (typeof points === 'string') {
|
||||
return Polyline.parse(points)
|
||||
}
|
||||
this.points = points.map((p) => Point.create(p))
|
||||
} else {
|
||||
this.points = []
|
||||
}
|
||||
}
|
||||
|
||||
scale(
|
||||
sx: number,
|
||||
sy: number,
|
||||
origin: Point.PointLike | Point.PointData = new Point(),
|
||||
) {
|
||||
this.points.forEach((p) => p.scale(sx, sy, origin))
|
||||
return this
|
||||
}
|
||||
|
||||
rotate(angle: number, origin?: Point.PointLike | Point.PointData) {
|
||||
this.points.forEach((p) => p.rotate(angle, origin))
|
||||
return this
|
||||
}
|
||||
|
||||
translate(dx: number, dy: number): this
|
||||
translate(p: Point.PointLike | Point.PointData): this
|
||||
translate(dx: number | Point.PointLike | Point.PointData, dy?: number): this {
|
||||
const t = Point.create(dx, dy)
|
||||
this.points.forEach((p) => p.translate(t.x, t.y))
|
||||
return this
|
||||
}
|
||||
|
||||
bbox() {
|
||||
if (this.points.length === 0) {
|
||||
return new Rectangle()
|
||||
}
|
||||
|
||||
let x1 = Infinity
|
||||
let x2 = -Infinity
|
||||
let y1 = Infinity
|
||||
let y2 = -Infinity
|
||||
|
||||
const points = this.points
|
||||
for (let i = 0, ii = points.length; i < ii; i += 1) {
|
||||
const point = points[i]
|
||||
const x = point.x
|
||||
const y = point.y
|
||||
|
||||
if (x < x1) x1 = x
|
||||
if (x > x2) x2 = x
|
||||
if (y < y1) y1 = y
|
||||
if (y > y2) y2 = y
|
||||
}
|
||||
|
||||
return new Rectangle(x1, y1, x2 - x1, y2 - y1)
|
||||
}
|
||||
|
||||
closestPoint(p: Point.PointLike | Point.PointData) {
|
||||
const cpLength = this.closestPointLength(p)
|
||||
return this.pointAtLength(cpLength)
|
||||
}
|
||||
|
||||
closestPointLength(p: Point.PointLike | Point.PointData) {
|
||||
const points = this.points
|
||||
const count = points.length
|
||||
if (count === 0 || count === 1) {
|
||||
return 0
|
||||
}
|
||||
|
||||
let length = 0
|
||||
let cpLength = 0
|
||||
let minSqrDistance = Infinity
|
||||
for (let i = 0, ii = count - 1; i < ii; i += 1) {
|
||||
const line = new Line(points[i], points[i + 1])
|
||||
const lineLength = line.length()
|
||||
const cpNormalizedLength = line.closestPointNormalizedLength(p)
|
||||
const cp = line.pointAt(cpNormalizedLength)
|
||||
|
||||
const sqrDistance = cp.squaredDistance(p)
|
||||
if (sqrDistance < minSqrDistance) {
|
||||
minSqrDistance = sqrDistance
|
||||
cpLength = length + cpNormalizedLength * lineLength
|
||||
}
|
||||
|
||||
length += lineLength
|
||||
}
|
||||
|
||||
return cpLength
|
||||
}
|
||||
|
||||
closestPointNormalizedLength(p: Point.PointLike | Point.PointData) {
|
||||
const cpLength = this.closestPointLength(p)
|
||||
if (cpLength === 0) {
|
||||
return 0
|
||||
}
|
||||
|
||||
const length = this.length()
|
||||
if (length === 0) {
|
||||
return 0
|
||||
}
|
||||
|
||||
return cpLength / length
|
||||
}
|
||||
|
||||
closestPointTangent(p: Point.PointLike | Point.PointData) {
|
||||
const cpLength = this.closestPointLength(p)
|
||||
return this.tangentAtLength(cpLength)
|
||||
}
|
||||
|
||||
containsPoint(p: Point.PointLike | Point.PointData) {
|
||||
if (this.points.length === 0) {
|
||||
return false
|
||||
}
|
||||
|
||||
const ref = Point.clone(p)
|
||||
const x = ref.x
|
||||
const y = ref.y
|
||||
const points = this.points
|
||||
const count = points.length
|
||||
|
||||
let startIndex = count - 1
|
||||
let intersectionCount = 0
|
||||
for (let endIndex = 0; endIndex < count; endIndex += 1) {
|
||||
const start = points[startIndex]
|
||||
const end = points[endIndex]
|
||||
if (ref.equals(start)) {
|
||||
return true
|
||||
}
|
||||
|
||||
const segment = new Line(start, end)
|
||||
if (segment.containsPoint(p)) {
|
||||
return true
|
||||
}
|
||||
|
||||
// do we have an intersection?
|
||||
if ((y <= start.y && y > end.y) || (y > start.y && y <= end.y)) {
|
||||
// this conditional branch IS NOT entered when `segment` is collinear/coincident with `ray`
|
||||
// (when `y === start.y === end.y`)
|
||||
// this conditional branch IS entered when `segment` touches `ray` at only one point
|
||||
// (e.g. when `y === start.y !== end.y`)
|
||||
// since this branch is entered again for the following segment, the two touches cancel out
|
||||
|
||||
const xDifference = start.x - x > end.x - x ? start.x - x : end.x - x
|
||||
if (xDifference >= 0) {
|
||||
// segment lies at least partially to the right of `p`
|
||||
const rayEnd = new Point(x + xDifference, y) // right
|
||||
const ray = new Line(p, rayEnd)
|
||||
|
||||
if (segment.intersectsWithLine(ray)) {
|
||||
// an intersection was detected to the right of `p`
|
||||
intersectionCount += 1
|
||||
}
|
||||
} // else: `segment` lies completely to the left of `p` (i.e. no intersection to the right)
|
||||
}
|
||||
|
||||
// move to check the next polyline segment
|
||||
startIndex = endIndex
|
||||
}
|
||||
|
||||
// returns `true` for odd numbers of intersections (even-odd algorithm)
|
||||
return intersectionCount % 2 === 1
|
||||
}
|
||||
|
||||
intersectsWithLine(line: Line) {
|
||||
const intersections = []
|
||||
for (let i = 0, n = this.points.length - 1; i < n; i += 1) {
|
||||
const a = this.points[i]
|
||||
const b = this.points[i + 1]
|
||||
const int = line.intersectsWithLine(new Line(a, b))
|
||||
if (int) {
|
||||
intersections.push(int)
|
||||
}
|
||||
}
|
||||
return intersections.length > 0 ? intersections : null
|
||||
}
|
||||
|
||||
isDifferentiable() {
|
||||
for (let i = 0, ii = this.points.length - 1; i < ii; i += 1) {
|
||||
const a = this.points[i]
|
||||
const b = this.points[i + 1]
|
||||
const line = new Line(a, b)
|
||||
if (line.isDifferentiable()) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
length() {
|
||||
let len = 0
|
||||
for (let i = 0, ii = this.points.length - 1; i < ii; i += 1) {
|
||||
const a = this.points[i]
|
||||
const b = this.points[i + 1]
|
||||
len += a.distance(b)
|
||||
}
|
||||
return len
|
||||
}
|
||||
|
||||
pointAt(ratio: number) {
|
||||
const points = this.points
|
||||
const count = points.length
|
||||
if (count === 0) {
|
||||
return null
|
||||
}
|
||||
|
||||
if (count === 1) {
|
||||
return points[0].clone()
|
||||
}
|
||||
|
||||
if (ratio <= 0) {
|
||||
return points[0].clone()
|
||||
}
|
||||
|
||||
if (ratio >= 1) {
|
||||
return points[count - 1].clone()
|
||||
}
|
||||
|
||||
const total = this.length()
|
||||
const length = total * ratio
|
||||
return this.pointAtLength(length)
|
||||
}
|
||||
|
||||
pointAtLength(length: number) {
|
||||
const points = this.points
|
||||
const count = points.length
|
||||
if (count === 0) {
|
||||
return null
|
||||
}
|
||||
|
||||
if (count === 1) {
|
||||
return points[0].clone()
|
||||
}
|
||||
|
||||
let fromStart = true
|
||||
if (length < 0) {
|
||||
fromStart = false
|
||||
length = -length // eslint-disable-line
|
||||
}
|
||||
|
||||
let tmp = 0
|
||||
for (let i = 0, ii = count - 1; i < ii; i += 1) {
|
||||
const index = fromStart ? i : ii - 1 - i
|
||||
const a = points[index]
|
||||
const b = points[index + 1]
|
||||
const l = new Line(a, b)
|
||||
const d = a.distance(b)
|
||||
|
||||
if (length <= tmp + d) {
|
||||
return l.pointAtLength((fromStart ? 1 : -1) * (length - tmp))
|
||||
}
|
||||
|
||||
tmp += d
|
||||
}
|
||||
|
||||
const lastPoint = fromStart ? points[count - 1] : points[0]
|
||||
return lastPoint.clone()
|
||||
}
|
||||
|
||||
tangentAt(ratio: number) {
|
||||
const points = this.points
|
||||
const count = points.length
|
||||
if (count === 0 || count === 1) {
|
||||
return null
|
||||
}
|
||||
|
||||
if (ratio < 0) {
|
||||
ratio = 0 // eslint-disable-line
|
||||
}
|
||||
|
||||
if (ratio > 1) {
|
||||
ratio = 1 // eslint-disable-line
|
||||
}
|
||||
|
||||
const total = this.length()
|
||||
const length = total * ratio
|
||||
|
||||
return this.tangentAtLength(length)
|
||||
}
|
||||
|
||||
tangentAtLength(length: number) {
|
||||
const points = this.points
|
||||
const count = points.length
|
||||
if (count === 0 || count === 1) {
|
||||
return null
|
||||
}
|
||||
|
||||
let fromStart = true
|
||||
if (length < 0) {
|
||||
fromStart = false
|
||||
length = -length // eslint-disable-line
|
||||
}
|
||||
|
||||
let lastValidLine
|
||||
let tmp = 0
|
||||
for (let i = 0, ii = count - 1; i < ii; i += 1) {
|
||||
const index = fromStart ? i : ii - 1 - i
|
||||
const a = points[index]
|
||||
const b = points[index + 1]
|
||||
const l = new Line(a, b)
|
||||
const d = a.distance(b)
|
||||
|
||||
if (l.isDifferentiable()) {
|
||||
// has a tangent line (line length is not 0)
|
||||
if (length <= tmp + d) {
|
||||
return l.tangentAtLength((fromStart ? 1 : -1) * (length - tmp))
|
||||
}
|
||||
|
||||
lastValidLine = l
|
||||
}
|
||||
|
||||
tmp += d
|
||||
}
|
||||
|
||||
if (lastValidLine) {
|
||||
const ratio = fromStart ? 1 : 0
|
||||
return lastValidLine.tangentAt(ratio)
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
simplify(
|
||||
// TODO: Accept startIndex and endIndex to specify where to start and end simplification
|
||||
options: {
|
||||
/**
|
||||
* The max distance of middle point from chord to be simplified.
|
||||
*/
|
||||
threshold?: number
|
||||
} = {},
|
||||
) {
|
||||
const points = this.points
|
||||
// we need at least 3 points
|
||||
if (points.length < 3) {
|
||||
return this
|
||||
}
|
||||
|
||||
const threshold = options.threshold || 0
|
||||
|
||||
// start at the beginning of the polyline and go forward
|
||||
let currentIndex = 0
|
||||
// we need at least one intermediate point (3 points) in every iteration
|
||||
// as soon as that stops being true, we know we reached the end of the polyline
|
||||
while (points[currentIndex + 2]) {
|
||||
const firstIndex = currentIndex
|
||||
const middleIndex = currentIndex + 1
|
||||
const lastIndex = currentIndex + 2
|
||||
|
||||
const firstPoint = points[firstIndex]
|
||||
const middlePoint = points[middleIndex]
|
||||
const lastPoint = points[lastIndex]
|
||||
|
||||
const chord = new Line(firstPoint, lastPoint) // = connection between first and last point
|
||||
const closestPoint = chord.closestPoint(middlePoint) // = closest point on chord from middle point
|
||||
const closestPointDistance = closestPoint.distance(middlePoint)
|
||||
if (closestPointDistance <= threshold) {
|
||||
// middle point is close enough to the chord = simplify
|
||||
// 1) remove middle point:
|
||||
points.splice(middleIndex, 1)
|
||||
// 2) in next iteration, investigate the newly-created triplet of points
|
||||
// - do not change `currentIndex`
|
||||
// = (first point stays, point after removed point becomes middle point)
|
||||
} else {
|
||||
// middle point is far from the chord
|
||||
// 1) preserve middle point
|
||||
// 2) in next iteration, move `currentIndex` by one step:
|
||||
currentIndex += 1
|
||||
// = (point after first point becomes first point)
|
||||
}
|
||||
}
|
||||
|
||||
// `points` array was modified in-place
|
||||
return this
|
||||
}
|
||||
|
||||
toHull() {
|
||||
const points = this.points
|
||||
const count = points.length
|
||||
if (count === 0) {
|
||||
return new Polyline()
|
||||
}
|
||||
|
||||
// Step 1: find the starting point -- point with
|
||||
// the lowest y (if equality, highest x).
|
||||
let startPoint: Point = points[0]
|
||||
for (let i = 1; i < count; i += 1) {
|
||||
if (points[i].y < startPoint.y) {
|
||||
startPoint = points[i]
|
||||
} else if (points[i].y === startPoint.y && points[i].x > startPoint.x) {
|
||||
startPoint = points[i]
|
||||
}
|
||||
}
|
||||
|
||||
// Step 2: sort the list of points by angle between line
|
||||
// from start point to current point and the x-axis (theta).
|
||||
|
||||
// Step 2a: create the point records = [point, originalIndex, angle]
|
||||
const sortedRecords: Types.HullRecord[] = []
|
||||
for (let i = 0; i < count; i += 1) {
|
||||
let angle = startPoint.theta(points[i])
|
||||
if (angle === 0) {
|
||||
// Give highest angle to start point.
|
||||
// The start point will end up at end of sorted list.
|
||||
// The start point will end up at beginning of hull points list.
|
||||
angle = 360
|
||||
}
|
||||
|
||||
sortedRecords.push([points[i], i, angle])
|
||||
}
|
||||
|
||||
// Step 2b: sort the list in place
|
||||
sortedRecords.sort((record1, record2) => {
|
||||
let ret = record1[2] - record2[2]
|
||||
if (ret === 0) {
|
||||
ret = record2[1] - record1[1]
|
||||
}
|
||||
|
||||
return ret
|
||||
})
|
||||
|
||||
// Step 2c: duplicate start record from the top of
|
||||
// the stack to the bottom of the stack.
|
||||
if (sortedRecords.length > 2) {
|
||||
const startPoint = sortedRecords[sortedRecords.length - 1]
|
||||
sortedRecords.unshift(startPoint)
|
||||
}
|
||||
|
||||
// Step 3
|
||||
// ------
|
||||
|
||||
// Step 3a: go through sorted points in order and find those with
|
||||
// right turns, and we want to get our results in clockwise order.
|
||||
|
||||
// Dictionary of points with left turns - cannot be on the hull.
|
||||
const insidePoints: { [key: string]: Point } = {}
|
||||
// Stack of records with right turns - hull point candidates.
|
||||
const hullRecords: Types.HullRecord[] = []
|
||||
const getKey = (record: Types.HullRecord) =>
|
||||
`${record[0].toString()}@${record[1]}`
|
||||
|
||||
while (sortedRecords.length !== 0) {
|
||||
const currentRecord = sortedRecords.pop()!
|
||||
const currentPoint = currentRecord[0]
|
||||
|
||||
// Check if point has already been discarded.
|
||||
if (insidePoints[getKey(currentRecord)]) {
|
||||
continue
|
||||
}
|
||||
|
||||
let correctTurnFound = false
|
||||
while (!correctTurnFound) {
|
||||
if (hullRecords.length < 2) {
|
||||
// Not enough points for comparison, just add current point.
|
||||
hullRecords.push(currentRecord)
|
||||
correctTurnFound = true
|
||||
} else {
|
||||
const lastHullRecord = hullRecords.pop()!
|
||||
const lastHullPoint = lastHullRecord[0]
|
||||
const secondLastHullRecord = hullRecords.pop()!
|
||||
const secondLastHullPoint = secondLastHullRecord[0]
|
||||
|
||||
const crossProduct = secondLastHullPoint.cross(
|
||||
lastHullPoint,
|
||||
currentPoint,
|
||||
)
|
||||
|
||||
if (crossProduct < 0) {
|
||||
// Found a right turn.
|
||||
hullRecords.push(secondLastHullRecord)
|
||||
hullRecords.push(lastHullRecord)
|
||||
hullRecords.push(currentRecord)
|
||||
correctTurnFound = true
|
||||
} else if (crossProduct === 0) {
|
||||
// the three points are collinear
|
||||
// three options:
|
||||
// there may be a 180 or 0 degree angle at lastHullPoint
|
||||
// or two of the three points are coincident
|
||||
|
||||
// we have to take rounding errors into account
|
||||
const THRESHOLD = 1e-10
|
||||
const angleBetween = lastHullPoint.angleBetween(
|
||||
secondLastHullPoint,
|
||||
currentPoint,
|
||||
)
|
||||
|
||||
if (Math.abs(angleBetween - 180) < THRESHOLD) {
|
||||
// rouding around 180 to 180
|
||||
// if the cross product is 0 because the angle is 180 degrees
|
||||
// discard last hull point (add to insidePoints)
|
||||
// insidePoints.unshift(lastHullPoint);
|
||||
insidePoints[getKey(lastHullRecord)] = lastHullPoint
|
||||
// reenter second-to-last hull point (will be last at next iter)
|
||||
hullRecords.push(secondLastHullRecord)
|
||||
// do not do anything with current point
|
||||
// correct turn not found
|
||||
} else if (
|
||||
lastHullPoint.equals(currentPoint) ||
|
||||
secondLastHullPoint.equals(lastHullPoint)
|
||||
) {
|
||||
// if the cross product is 0 because two points are the same
|
||||
// discard last hull point (add to insidePoints)
|
||||
// insidePoints.unshift(lastHullPoint);
|
||||
insidePoints[getKey(lastHullRecord)] = lastHullPoint
|
||||
// reenter second-to-last hull point (will be last at next iter)
|
||||
hullRecords.push(secondLastHullRecord)
|
||||
// do not do anything with current point
|
||||
// correct turn not found
|
||||
} else if (Math.abs(((angleBetween + 1) % 360) - 1) < THRESHOLD) {
|
||||
// rounding around 0 and 360 to 0
|
||||
// if the cross product is 0 because the angle is 0 degrees
|
||||
// remove last hull point from hull BUT do not discard it
|
||||
// reenter second-to-last hull point (will be last at next iter)
|
||||
hullRecords.push(secondLastHullRecord)
|
||||
// put last hull point back into the sorted point records list
|
||||
sortedRecords.push(lastHullRecord)
|
||||
// we are switching the order of the 0deg and 180deg points
|
||||
// correct turn not found
|
||||
}
|
||||
} else {
|
||||
// found a left turn
|
||||
// discard last hull point (add to insidePoints)
|
||||
// insidePoints.unshift(lastHullPoint);
|
||||
insidePoints[getKey(lastHullRecord)] = lastHullPoint
|
||||
// reenter second-to-last hull point (will be last at next iter of loop)
|
||||
hullRecords.push(secondLastHullRecord)
|
||||
// do not do anything with current point
|
||||
// correct turn not found
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// At this point, hullPointRecords contains the output points in clockwise order
|
||||
// the points start with lowest-y,highest-x startPoint, and end at the same point
|
||||
|
||||
// Step 3b: remove duplicated startPointRecord from the end of the array
|
||||
if (hullRecords.length > 2) {
|
||||
hullRecords.pop()
|
||||
}
|
||||
|
||||
// Step 4: find the lowest originalIndex record and put it at the beginning of hull
|
||||
let lowestHullIndex // the lowest originalIndex on the hull
|
||||
let indexOfLowestHullIndexRecord = -1 // the index of the record with lowestHullIndex
|
||||
for (let i = 0, n = hullRecords.length; i < n; i += 1) {
|
||||
const currentHullIndex = hullRecords[i][1]
|
||||
|
||||
if (lowestHullIndex === undefined || currentHullIndex < lowestHullIndex) {
|
||||
lowestHullIndex = currentHullIndex
|
||||
indexOfLowestHullIndexRecord = i
|
||||
}
|
||||
}
|
||||
|
||||
let hullPointRecordsReordered = []
|
||||
if (indexOfLowestHullIndexRecord > 0) {
|
||||
const newFirstChunk = hullRecords.slice(indexOfLowestHullIndexRecord)
|
||||
const newSecondChunk = hullRecords.slice(0, indexOfLowestHullIndexRecord)
|
||||
hullPointRecordsReordered = newFirstChunk.concat(newSecondChunk)
|
||||
} else {
|
||||
hullPointRecordsReordered = hullRecords
|
||||
}
|
||||
|
||||
const hullPoints = []
|
||||
for (let i = 0, n = hullPointRecordsReordered.length; i < n; i += 1) {
|
||||
hullPoints.push(hullPointRecordsReordered[i][0])
|
||||
}
|
||||
|
||||
return new Polyline(hullPoints)
|
||||
}
|
||||
|
||||
equals(p: Polyline) {
|
||||
if (p == null) {
|
||||
return false
|
||||
}
|
||||
|
||||
if (p.points.length !== this.points.length) {
|
||||
return false
|
||||
}
|
||||
|
||||
return p.points.every((a, i) => a.equals(this.points[i]))
|
||||
}
|
||||
|
||||
clone() {
|
||||
return new Polyline(this.points.map((p) => p.clone()))
|
||||
}
|
||||
|
||||
toJSON() {
|
||||
return this.points.map((p) => p.toJSON())
|
||||
}
|
||||
|
||||
serialize() {
|
||||
return this.points.map((p) => `${p.x}, ${p.y}`).join(' ')
|
||||
}
|
||||
}
|
||||
|
||||
export namespace Polyline {
|
||||
export const toStringTag = `X6.Geometry.${Polyline.name}`
|
||||
|
||||
export function isPolyline(instance: any): instance is Polyline {
|
||||
if (instance == null) {
|
||||
return false
|
||||
}
|
||||
|
||||
if (instance instanceof Polyline) {
|
||||
return true
|
||||
}
|
||||
|
||||
const tag = instance[Symbol.toStringTag]
|
||||
const polyline = instance as Polyline
|
||||
|
||||
if (
|
||||
(tag == null || tag === toStringTag) &&
|
||||
typeof polyline.toHull === 'function' &&
|
||||
typeof polyline.simplify === 'function'
|
||||
) {
|
||||
return true
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
export namespace Polyline {
|
||||
export function parse(svgString: string) {
|
||||
const str = svgString.trim()
|
||||
if (str === '') {
|
||||
return new Polyline()
|
||||
}
|
||||
|
||||
const points = []
|
||||
|
||||
const coords = str.split(/\s*,\s*|\s+/)
|
||||
for (let i = 0, ii = coords.length; i < ii; i += 2) {
|
||||
points.push({ x: +coords[i], y: +coords[i + 1] })
|
||||
}
|
||||
|
||||
return new Polyline(points)
|
||||
}
|
||||
}
|
||||
|
||||
namespace Types {
|
||||
export type HullRecord = [Point, number, number]
|
||||
}
|
545
packages/x6-geometry/src/rectangle.test.ts
Normal file
545
packages/x6-geometry/src/rectangle.test.ts
Normal file
@ -0,0 +1,545 @@
|
||||
import { Ellipse } from './ellipse'
|
||||
import { Line } from './line'
|
||||
import { Point } from './point'
|
||||
import { Rectangle } from './rectangle'
|
||||
|
||||
describe('rectangle', () => {
|
||||
describe('#constructor', () => {
|
||||
it('should create a rectangle instance', () => {
|
||||
expect(new Rectangle()).toBeInstanceOf(Rectangle)
|
||||
expect(new Rectangle(1)).toBeInstanceOf(Rectangle)
|
||||
expect(new Rectangle(1, 2)).toBeInstanceOf(Rectangle)
|
||||
expect(new Rectangle(1, 2).x).toEqual(1)
|
||||
expect(new Rectangle(1, 2).y).toEqual(2)
|
||||
expect(new Rectangle(1, 2).width).toEqual(0)
|
||||
expect(new Rectangle(1, 2).height).toEqual(0)
|
||||
expect(new Rectangle().equals(new Rectangle(0, 0, 0, 0)))
|
||||
})
|
||||
|
||||
it('should work with key points', () => {
|
||||
const rect = new Rectangle(1, 2, 3, 4)
|
||||
expect(rect.origin.equals({ x: 1, y: 2 })).toBeTruthy()
|
||||
expect(rect.topLeft.equals({ x: 1, y: 2 })).toBeTruthy()
|
||||
expect(rect.topCenter.equals({ x: 2.5, y: 2 })).toBeTruthy()
|
||||
expect(rect.topRight.equals({ x: 4, y: 2 })).toBeTruthy()
|
||||
expect(rect.center.equals({ x: 2.5, y: 4 })).toBeTruthy()
|
||||
expect(rect.bottomLeft.equals({ x: 1, y: 6 })).toBeTruthy()
|
||||
expect(rect.bottomCenter.equals({ x: 2.5, y: 6 })).toBeTruthy()
|
||||
expect(rect.bottomRight.equals({ x: 4, y: 6 })).toBeTruthy()
|
||||
expect(rect.corner.equals({ x: 4, y: 6 })).toBeTruthy()
|
||||
expect(rect.leftMiddle.equals({ x: 1, y: 4 })).toBeTruthy()
|
||||
expect(rect.rightMiddle.equals({ x: 4, y: 4 })).toBeTruthy()
|
||||
})
|
||||
|
||||
it('should return the key points', () => {
|
||||
const rect = new Rectangle(1, 2, 3, 4)
|
||||
expect(rect.getOrigin().equals({ x: 1, y: 2 })).toBeTruthy()
|
||||
expect(rect.getTopLeft().equals({ x: 1, y: 2 })).toBeTruthy()
|
||||
expect(rect.getTopCenter().equals({ x: 2.5, y: 2 })).toBeTruthy()
|
||||
expect(rect.getTopRight().equals({ x: 4, y: 2 })).toBeTruthy()
|
||||
expect(rect.getCenter().equals({ x: 2.5, y: 4 })).toBeTruthy()
|
||||
expect(rect.getBottomLeft().equals({ x: 1, y: 6 })).toBeTruthy()
|
||||
expect(rect.getBottomCenter().equals({ x: 2.5, y: 6 })).toBeTruthy()
|
||||
expect(rect.getBottomRight().equals({ x: 4, y: 6 })).toBeTruthy()
|
||||
expect(rect.getCorner().equals({ x: 4, y: 6 })).toBeTruthy()
|
||||
expect(rect.getLeftMiddle().equals({ x: 1, y: 4 })).toBeTruthy()
|
||||
expect(rect.getRightMiddle().equals({ x: 4, y: 4 })).toBeTruthy()
|
||||
|
||||
expect(rect.getCenterX()).toEqual(2.5)
|
||||
expect(rect.getCenterY()).toEqual(4)
|
||||
})
|
||||
|
||||
it('should work with key lines', () => {
|
||||
const rect = new Rectangle(1, 2, 3, 4)
|
||||
expect(rect.topLine.equals(new Line(1, 2, 4, 2)))
|
||||
expect(rect.rightLine.equals(new Line(4, 2, 4, 6)))
|
||||
expect(rect.bottomLine.equals(new Line(1, 6, 4, 6)))
|
||||
expect(rect.leftLine.equals(new Line(1, 2, 1, 6)))
|
||||
})
|
||||
|
||||
it('should return the key lines', () => {
|
||||
const rect = new Rectangle(1, 2, 3, 4)
|
||||
expect(rect.getTopLine().equals(new Line(1, 2, 4, 2)))
|
||||
expect(rect.getRightLine().equals(new Line(4, 2, 4, 6)))
|
||||
expect(rect.getBottomLine().equals(new Line(1, 6, 4, 6)))
|
||||
expect(rect.getLeftLine().equals(new Line(1, 2, 1, 6)))
|
||||
})
|
||||
})
|
||||
|
||||
describe('#Rectangle.clone', () => {
|
||||
it('should clone rectangle', () => {
|
||||
const obj = { x: 1, y: 2, width: 3, height: 4 }
|
||||
expect(Rectangle.clone(new Rectangle(1, 2, 3, 4)).toJSON()).toEqual(obj)
|
||||
expect(Rectangle.clone(obj).toJSON()).toEqual(obj)
|
||||
expect(Rectangle.clone([1, 2, 3, 4]).toJSON()).toEqual(obj)
|
||||
})
|
||||
})
|
||||
|
||||
describe('#Rectangle.isRectangleLike', () => {
|
||||
it('should return true if the given object is a rectangle-like object', () => {
|
||||
const obj = { x: 1, y: 2, width: 3, height: 4 }
|
||||
expect(Rectangle.isRectangleLike(obj)).toBeTruthy()
|
||||
expect(Rectangle.isRectangleLike({ ...obj, z: 10 })).toBeTruthy()
|
||||
expect(Rectangle.isRectangleLike({ ...obj, z: 10, s: 's' })).toBeTruthy()
|
||||
})
|
||||
|
||||
it('should return false if the given object is a rectangle-like object', () => {
|
||||
expect(Rectangle.isRectangleLike({ x: 1 })).toBeFalsy()
|
||||
expect(Rectangle.isRectangleLike({ y: 2 })).toBeFalsy()
|
||||
expect(Rectangle.isRectangleLike({})).toBeFalsy()
|
||||
expect(Rectangle.isRectangleLike(null)).toBeFalsy()
|
||||
expect(Rectangle.isRectangleLike(false)).toBeFalsy()
|
||||
expect(Rectangle.isRectangleLike(1)).toBeFalsy()
|
||||
expect(Rectangle.isRectangleLike('s')).toBeFalsy()
|
||||
})
|
||||
})
|
||||
|
||||
describe('#Rectangle.fromSize', () => {
|
||||
it('should create a rectangle from the given size', () => {
|
||||
expect(Rectangle.fromSize({ width: 10, height: 8 }).toJSON()).toEqual({
|
||||
x: 0,
|
||||
y: 0,
|
||||
width: 10,
|
||||
height: 8,
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('#Rectangle.fromPositionAndSize', () => {
|
||||
it('should create a rectangle from the given position and size', () => {
|
||||
expect(
|
||||
Rectangle.fromPositionAndSize(
|
||||
{ x: 2, y: 5 },
|
||||
{ width: 10, height: 8 },
|
||||
).toJSON(),
|
||||
).toEqual({
|
||||
x: 2,
|
||||
y: 5,
|
||||
width: 10,
|
||||
height: 8,
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('#Rectangle.fromEllipse', () => {
|
||||
it('should create a rectangle from the given ellipse', () => {
|
||||
expect(Rectangle.fromEllipse(new Ellipse(1, 2, 3, 4)).toJSON()).toEqual({
|
||||
x: -2,
|
||||
y: -2,
|
||||
width: 6,
|
||||
height: 8,
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('#valueOf', () => {
|
||||
it('should return JSON object', () => {
|
||||
const obj = { x: 1, y: 2, width: 3, height: 4 }
|
||||
const rect = Rectangle.create(obj)
|
||||
expect(rect.valueOf()).toEqual(obj)
|
||||
})
|
||||
})
|
||||
|
||||
describe('#toString', () => {
|
||||
it('should return JSON string', () => {
|
||||
const obj = { x: 1, y: 2, width: 3, height: 4 }
|
||||
const rect = Rectangle.create(obj)
|
||||
expect(rect.toString()).toEqual(JSON.stringify(obj))
|
||||
})
|
||||
})
|
||||
|
||||
describe('#bbox', () => {
|
||||
it('should return a rectangle that is the bounding box of the rectangle.', () => {
|
||||
const rect = new Rectangle(1, 2, 3, 4)
|
||||
expect(rect.bbox().equals(rect)).toBeTruthy()
|
||||
})
|
||||
|
||||
it('should rotate the rectangle if angle specified.', () => {
|
||||
const rect = new Rectangle(0, 0, 2, 4)
|
||||
expect(
|
||||
rect.bbox(90).round().equals(new Rectangle(-1, 1, 4, 2)),
|
||||
).toBeTruthy()
|
||||
})
|
||||
})
|
||||
|
||||
describe('#add', () => {
|
||||
it('should add the given `rect` to me', () => {
|
||||
const rect = new Rectangle(1, 2, 3, 4)
|
||||
expect(rect.add(0, 0, 0, 0)).toEqual(new Rectangle(0, 0, 4, 6))
|
||||
})
|
||||
})
|
||||
|
||||
describe('#update', () => {
|
||||
it('should update me with the given `rect`', () => {
|
||||
const rect = new Rectangle(1, 2, 3, 4)
|
||||
expect(rect.update(0, 0, 1, 1)).toEqual(new Rectangle(0, 0, 1, 1))
|
||||
})
|
||||
})
|
||||
|
||||
describe('#inflate', () => {
|
||||
it('should inflate me with the given `amount`', () => {
|
||||
const rect = new Rectangle(1, 2, 3, 4)
|
||||
expect(rect.inflate(2)).toEqual(new Rectangle(-1, 0, 7, 8))
|
||||
})
|
||||
|
||||
it('should inflate me with the given `dx` and `dy`', () => {
|
||||
const rect = new Rectangle(1, 2, 3, 4)
|
||||
expect(rect.inflate(2, 1)).toEqual(new Rectangle(-1, 1, 7, 6))
|
||||
})
|
||||
})
|
||||
|
||||
describe('#snapToGrid', () => {
|
||||
it('should snap to grid', () => {
|
||||
const rect1 = new Rectangle(2, 6, 33, 44)
|
||||
const rect2 = rect1.clone().snapToGrid(10)
|
||||
const rect3 = rect1.clone().snapToGrid(3, 5)
|
||||
expect(rect2.equals({ x: 0, y: 10, width: 40, height: 40 })).toBeTruthy()
|
||||
expect(rect3.equals({ x: 3, y: 5, width: 33, height: 45 })).toBeTruthy()
|
||||
})
|
||||
})
|
||||
|
||||
describe('#translate', () => {
|
||||
it('should translate x and y by adding the given `dx` and `dy` values respectively', () => {
|
||||
const rect = new Rectangle(1, 2, 3, 4)
|
||||
|
||||
expect(rect.clone().translate(2, 3).toJSON()).toEqual({
|
||||
x: 3,
|
||||
y: 5,
|
||||
width: 3,
|
||||
height: 4,
|
||||
})
|
||||
|
||||
expect(rect.clone().translate(new Point(-2, 4)).toJSON()).toEqual({
|
||||
x: -1,
|
||||
y: 6,
|
||||
width: 3,
|
||||
height: 4,
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('#scale', () => {
|
||||
it('should scale point with the given amount', () => {
|
||||
expect(new Rectangle(1, 2, 3, 4).scale(2, 3).toJSON()).toEqual({
|
||||
x: 2,
|
||||
y: 6,
|
||||
width: 6,
|
||||
height: 12,
|
||||
})
|
||||
})
|
||||
|
||||
it('should scale point with the given amount and center ', () => {
|
||||
expect(
|
||||
new Rectangle(20, 30, 10, 20).scale(2, 3, new Point(40, 45)).toJSON(),
|
||||
).toEqual({ x: 0, y: 0, width: 20, height: 60 })
|
||||
})
|
||||
})
|
||||
|
||||
describe('#rotate', () => {
|
||||
it('should rorate the rect by the given angle', () => {
|
||||
expect(new Rectangle(1, 2, 3, 4).rotate(180).round().toJSON()).toEqual({
|
||||
x: 1,
|
||||
y: 2,
|
||||
width: 3,
|
||||
height: 4,
|
||||
})
|
||||
})
|
||||
|
||||
it('should keep the same when the given angle is `0`', () => {
|
||||
expect(new Rectangle(1, 2, 3, 4).rotate(0).toJSON()).toEqual({
|
||||
x: 1,
|
||||
y: 2,
|
||||
width: 3,
|
||||
height: 4,
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('#rotate90', () => {
|
||||
it("should rorate the rect by 90deg around it's center", () => {
|
||||
expect(new Rectangle(1, 2, 3, 4).rotate90().toJSON()).toEqual({
|
||||
x: 0.5,
|
||||
y: 2.5,
|
||||
width: 4,
|
||||
height: 3,
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('#getMaxScaleToFit', () => {
|
||||
it('should return the scale amount', () => {
|
||||
const scale1 = new Rectangle(1, 2, 3, 4).getMaxScaleToFit([5, 6, 7, 8])
|
||||
const scale2 = new Rectangle(1, 2, 3, 4).getMaxScaleToFit([0, 0, 7, 8])
|
||||
expect(scale1.sx.toFixed(2)).toEqual('0.16')
|
||||
expect(scale1.sy.toFixed(2)).toEqual('0.20')
|
||||
expect(scale2.sx.toFixed(2)).toEqual('0.33')
|
||||
expect(scale2.sy.toFixed(2)).toEqual('0.50')
|
||||
})
|
||||
})
|
||||
|
||||
describe('#getMaxUniformScaleToFit', () => {
|
||||
it('should return the scale amount', () => {
|
||||
const s1 = new Rectangle(1, 2, 3, 4).getMaxUniformScaleToFit([5, 6, 7, 8])
|
||||
const s2 = new Rectangle(1, 2, 3, 4).getMaxUniformScaleToFit([0, 0, 7, 8])
|
||||
expect(s1.toFixed(2)).toEqual('0.16')
|
||||
expect(s2.toFixed(2)).toEqual('0.33')
|
||||
})
|
||||
})
|
||||
|
||||
describe('#moveAndExpand', () => {
|
||||
it('should translate and expand me by the given `rect`', () => {
|
||||
expect(
|
||||
new Rectangle(1, 2, 3, 4)
|
||||
.moveAndExpand(new Rectangle(1, 2, 3, 4))
|
||||
.toJSON(),
|
||||
).toEqual({ x: 2, y: 4, width: 6, height: 8 })
|
||||
|
||||
expect(
|
||||
new Rectangle(1, 2, 3, 4).moveAndExpand(new Rectangle()).toJSON(),
|
||||
).toEqual({ x: 1, y: 2, width: 3, height: 4 })
|
||||
})
|
||||
})
|
||||
|
||||
describe('#containsPoint', () => {
|
||||
it('should return true when rect contains the given point', () => {
|
||||
expect(new Rectangle(50, 50, 100, 100).containsPoint(60, 60)).toBeTruthy()
|
||||
})
|
||||
})
|
||||
|
||||
describe('#containsRect', () => {
|
||||
it('should return true when rect is completely inside the other rect', () => {
|
||||
expect(
|
||||
new Rectangle(50, 50, 100, 100).containsRect(60, 60, 80, 80),
|
||||
).toBeTruthy()
|
||||
})
|
||||
|
||||
it('should return true when rect is equal the other rect', () => {
|
||||
expect(
|
||||
new Rectangle(50, 50, 100, 100).containsRect({
|
||||
x: 50,
|
||||
y: 50,
|
||||
width: 100,
|
||||
height: 100,
|
||||
}),
|
||||
).toBeTruthy()
|
||||
})
|
||||
|
||||
it('should return false when rect is not inside the other rect', () => {
|
||||
expect(
|
||||
new Rectangle(50, 50, 100, 100).containsRect(20, 20, 200, 200),
|
||||
).toBeFalsy()
|
||||
expect(
|
||||
new Rectangle(50, 50, 100, 100).containsRect(40, 40, 100, 100),
|
||||
).toBeFalsy()
|
||||
expect(
|
||||
new Rectangle(50, 50, 100, 100).containsRect(60, 60, 100, 40),
|
||||
).toBeFalsy()
|
||||
expect(
|
||||
new Rectangle(50, 50, 100, 100).containsRect(60, 60, 100, 100),
|
||||
).toBeFalsy()
|
||||
expect(
|
||||
new Rectangle(50, 50, 100, 100).containsRect(60, 60, 40, 100),
|
||||
).toBeFalsy()
|
||||
})
|
||||
|
||||
it('should return false when one of the dimensions is `0`', () => {
|
||||
expect(
|
||||
new Rectangle(50, 50, 100, 100).containsRect(75, 75, 0, 0),
|
||||
).toBeFalsy()
|
||||
expect(new Rectangle(50, 50, 0, 0).containsRect(50, 50, 0, 0)).toBeFalsy()
|
||||
})
|
||||
})
|
||||
|
||||
describe('#intersectsWithRect', () => {
|
||||
it('should return the intersection', () => {
|
||||
// inside
|
||||
expect(
|
||||
new Rectangle(20, 20, 100, 100)
|
||||
.intersectsWithRect([40, 40, 20, 20])
|
||||
?.toJSON(),
|
||||
).toEqual({ x: 40, y: 40, width: 20, height: 20 })
|
||||
|
||||
expect(
|
||||
new Rectangle(20, 20, 100, 100)
|
||||
.intersectsWithRect([0, 0, 100, 100])
|
||||
?.toJSON(),
|
||||
).toEqual({ x: 20, y: 20, width: 80, height: 80 })
|
||||
|
||||
expect(
|
||||
new Rectangle(20, 20, 100, 100)
|
||||
.intersectsWithRect([40, 40, 100, 100])
|
||||
?.toJSON(),
|
||||
).toEqual({ x: 40, y: 40, width: 80, height: 80 })
|
||||
})
|
||||
|
||||
it('should return null when no intersection', () => {
|
||||
expect(
|
||||
new Rectangle(20, 20, 100, 100).intersectsWithRect([140, 140, 20, 20]),
|
||||
).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('#intersectsWithLine', () => {
|
||||
it('should return the intersection points', () => {
|
||||
const points1 = new Rectangle(0, 0, 4, 4).intersectsWithLine(
|
||||
new Line(2, 2, 2, 8),
|
||||
)
|
||||
const points2 = new Rectangle(0, 0, 4, 4).intersectsWithLine(
|
||||
new Line(2, -2, 2, 8),
|
||||
)
|
||||
expect(Point.equalPoints(points1!, [{ x: 2, y: 4 }]))
|
||||
expect(
|
||||
Point.equalPoints(points2!, [
|
||||
{ x: 2, y: 0 },
|
||||
{ x: 2, y: 4 },
|
||||
]),
|
||||
)
|
||||
})
|
||||
|
||||
it('should return null when no intersection exists', () => {
|
||||
expect(
|
||||
new Rectangle(0, 0, 4, 4).intersectsWithLine(new Line(-2, -2, -2, -8)),
|
||||
).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('#intersectsWithLineFromCenterToPoint', () => {
|
||||
it('should return the intersection point', () => {
|
||||
expect(
|
||||
new Rectangle(0, 0, 4, 4)
|
||||
.intersectsWithLineFromCenterToPoint([2, 8])
|
||||
?.equals([2, 4]),
|
||||
).toBeTruthy()
|
||||
|
||||
expect(
|
||||
new Rectangle(0, 0, 4, 4)
|
||||
.intersectsWithLineFromCenterToPoint([2, 8], 90)
|
||||
?.round()
|
||||
.equals([2, 4]),
|
||||
).toBeTruthy()
|
||||
})
|
||||
|
||||
it('should return null when no intersection exists', () => {
|
||||
expect(
|
||||
new Rectangle(0, 0, 4, 4).intersectsWithLineFromCenterToPoint([3, 3]),
|
||||
).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('#normalize', () => {
|
||||
it('should keep the same when width and height is positive', () => {
|
||||
expect(new Rectangle(1, 2, 3, 4).normalize().toJSON()).toEqual({
|
||||
x: 1,
|
||||
y: 2,
|
||||
width: 3,
|
||||
height: 4,
|
||||
})
|
||||
})
|
||||
|
||||
it('should make the width positive', () => {
|
||||
expect(new Rectangle(1, 2, -3, 4).normalize().toJSON()).toEqual({
|
||||
x: -2,
|
||||
y: 2,
|
||||
width: 3,
|
||||
height: 4,
|
||||
})
|
||||
})
|
||||
|
||||
it('should make the height positive', () => {
|
||||
expect(new Rectangle(1, 2, 3, -4).normalize().toJSON()).toEqual({
|
||||
x: 1,
|
||||
y: -2,
|
||||
width: 3,
|
||||
height: 4,
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('#union', () => {})
|
||||
|
||||
describe('#getNearestSideToPoint', () => {
|
||||
it('should return the nearest side to point when point is on the side', () => {
|
||||
const rect = new Rectangle(0, 0, 4, 4)
|
||||
expect(rect.getNearestSideToPoint({ x: 0, y: 0 })).toEqual('left')
|
||||
expect(rect.getNearestSideToPoint({ x: 4, y: 4 })).toEqual('right')
|
||||
expect(rect.getNearestSideToPoint({ x: 0, y: 4 })).toEqual('left')
|
||||
expect(rect.getNearestSideToPoint({ x: 4, y: 0 })).toEqual('right')
|
||||
expect(rect.getNearestSideToPoint({ x: 2, y: 0 })).toEqual('top')
|
||||
expect(rect.getNearestSideToPoint({ x: 0, y: 2 })).toEqual('left')
|
||||
expect(rect.getNearestSideToPoint({ x: 4, y: 2 })).toEqual('right')
|
||||
expect(rect.getNearestSideToPoint({ x: 2, y: 4 })).toEqual('bottom')
|
||||
})
|
||||
|
||||
it('should return the nearest side to point when point is outside', () => {
|
||||
const rect = new Rectangle(0, 0, 4, 4)
|
||||
expect(rect.getNearestSideToPoint({ x: 5, y: 5 })).toEqual('right')
|
||||
expect(rect.getNearestSideToPoint({ x: 5, y: 4 })).toEqual('right')
|
||||
expect(rect.getNearestSideToPoint({ x: 5, y: 2 })).toEqual('right')
|
||||
expect(rect.getNearestSideToPoint({ x: 5, y: 0 })).toEqual('right')
|
||||
expect(rect.getNearestSideToPoint({ x: 5, y: -1 })).toEqual('right')
|
||||
expect(rect.getNearestSideToPoint({ x: -1, y: 5 })).toEqual('left')
|
||||
expect(rect.getNearestSideToPoint({ x: -1, y: 4 })).toEqual('left')
|
||||
expect(rect.getNearestSideToPoint({ x: -1, y: 2 })).toEqual('left')
|
||||
expect(rect.getNearestSideToPoint({ x: -1, y: 0 })).toEqual('left')
|
||||
expect(rect.getNearestSideToPoint({ x: -1, y: -1 })).toEqual('left')
|
||||
expect(rect.getNearestSideToPoint({ x: 0, y: 5 })).toEqual('bottom')
|
||||
expect(rect.getNearestSideToPoint({ x: 2, y: 5 })).toEqual('bottom')
|
||||
expect(rect.getNearestSideToPoint({ x: 4, y: 5 })).toEqual('bottom')
|
||||
expect(rect.getNearestSideToPoint({ x: 0, y: -1 })).toEqual('top')
|
||||
expect(rect.getNearestSideToPoint({ x: 2, y: -1 })).toEqual('top')
|
||||
expect(rect.getNearestSideToPoint({ x: 4, y: -1 })).toEqual('top')
|
||||
})
|
||||
|
||||
it('should return the nearest side to point when point is inside', () => {
|
||||
const rect = new Rectangle(0, 0, 4, 4)
|
||||
expect(rect.getNearestSideToPoint({ x: 2, y: 1 })).toEqual('top')
|
||||
expect(rect.getNearestSideToPoint({ x: 3, y: 2 })).toEqual('right')
|
||||
expect(rect.getNearestSideToPoint({ x: 2, y: 3 })).toEqual('bottom')
|
||||
expect(rect.getNearestSideToPoint({ x: 1, y: 2 })).toEqual('left')
|
||||
})
|
||||
})
|
||||
|
||||
describe('#getNearestPointToPoint', () => {
|
||||
it('should return the nearest point to point when point is inside the rect', () => {
|
||||
const rect = new Rectangle(0, 0, 4, 4)
|
||||
// left
|
||||
expect(rect.getNearestPointToPoint({ x: 1, y: 2 }).toJSON()).toEqual({
|
||||
x: 0,
|
||||
y: 2,
|
||||
})
|
||||
// right
|
||||
expect(rect.getNearestPointToPoint({ x: 3, y: 2 }).toJSON()).toEqual({
|
||||
x: 4,
|
||||
y: 2,
|
||||
})
|
||||
// top
|
||||
expect(rect.getNearestPointToPoint({ x: 2, y: 1 }).toJSON()).toEqual({
|
||||
x: 2,
|
||||
y: 0,
|
||||
})
|
||||
// bottom
|
||||
expect(rect.getNearestPointToPoint({ x: 2, y: 3 }).toJSON()).toEqual({
|
||||
x: 2,
|
||||
y: 4,
|
||||
})
|
||||
})
|
||||
|
||||
it('should return the nearest point to point when point is outside the rect', () => {
|
||||
const rect = new Rectangle(0, 0, 4, 4)
|
||||
expect(rect.getNearestPointToPoint({ x: 5, y: 5 }).toJSON()).toEqual({
|
||||
x: 4,
|
||||
y: 4,
|
||||
})
|
||||
expect(rect.getNearestPointToPoint({ x: -1, y: -1 }).toJSON()).toEqual({
|
||||
x: 0,
|
||||
y: 0,
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('#serialize', () => {
|
||||
it('should return the serialized string', () => {
|
||||
expect(new Rectangle(1, 2, 3, 4).serialize()).toEqual('1 2 3 4')
|
||||
})
|
||||
})
|
||||
})
|
855
packages/x6-geometry/src/rectangle.ts
Normal file
855
packages/x6-geometry/src/rectangle.ts
Normal file
@ -0,0 +1,855 @@
|
||||
import { Util } from './util'
|
||||
import { Angle } from './angle'
|
||||
import { Line } from './line'
|
||||
import { Point } from './point'
|
||||
import { Ellipse } from './ellipse'
|
||||
import { Geometry } from './geometry'
|
||||
|
||||
export class Rectangle extends Geometry implements Rectangle.RectangleLike {
|
||||
x: number
|
||||
y: number
|
||||
width: number
|
||||
height: number
|
||||
|
||||
protected get [Symbol.toStringTag]() {
|
||||
return Rectangle.toStringTag
|
||||
}
|
||||
|
||||
get left() {
|
||||
return this.x
|
||||
}
|
||||
|
||||
get top() {
|
||||
return this.y
|
||||
}
|
||||
|
||||
get right() {
|
||||
return this.x + this.width
|
||||
}
|
||||
|
||||
get bottom() {
|
||||
return this.y + this.height
|
||||
}
|
||||
|
||||
get origin() {
|
||||
return new Point(this.x, this.y)
|
||||
}
|
||||
|
||||
get topLeft() {
|
||||
return new Point(this.x, this.y)
|
||||
}
|
||||
|
||||
get topCenter() {
|
||||
return new Point(this.x + this.width / 2, this.y)
|
||||
}
|
||||
|
||||
get topRight() {
|
||||
return new Point(this.x + this.width, this.y)
|
||||
}
|
||||
|
||||
get center() {
|
||||
return new Point(this.x + this.width / 2, this.y + this.height / 2)
|
||||
}
|
||||
|
||||
get bottomLeft() {
|
||||
return new Point(this.x, this.y + this.height)
|
||||
}
|
||||
|
||||
get bottomCenter() {
|
||||
return new Point(this.x + this.width / 2, this.y + this.height)
|
||||
}
|
||||
|
||||
get bottomRight() {
|
||||
return new Point(this.x + this.width, this.y + this.height)
|
||||
}
|
||||
|
||||
get corner() {
|
||||
return new Point(this.x + this.width, this.y + this.height)
|
||||
}
|
||||
|
||||
get rightMiddle() {
|
||||
return new Point(this.x + this.width, this.y + this.height / 2)
|
||||
}
|
||||
|
||||
get leftMiddle() {
|
||||
return new Point(this.x, this.y + this.height / 2)
|
||||
}
|
||||
|
||||
get topLine() {
|
||||
return new Line(this.topLeft, this.topRight)
|
||||
}
|
||||
|
||||
get rightLine() {
|
||||
return new Line(this.topRight, this.bottomRight)
|
||||
}
|
||||
|
||||
get bottomLine() {
|
||||
return new Line(this.bottomLeft, this.bottomRight)
|
||||
}
|
||||
|
||||
get leftLine() {
|
||||
return new Line(this.topLeft, this.bottomLeft)
|
||||
}
|
||||
|
||||
constructor(x?: number, y?: number, width?: number, height?: number) {
|
||||
super()
|
||||
this.x = x == null ? 0 : x
|
||||
this.y = y == null ? 0 : y
|
||||
this.width = width == null ? 0 : width
|
||||
this.height = height == null ? 0 : height
|
||||
}
|
||||
|
||||
getOrigin() {
|
||||
return this.origin
|
||||
}
|
||||
|
||||
getTopLeft() {
|
||||
return this.topLeft
|
||||
}
|
||||
|
||||
getTopCenter() {
|
||||
return this.topCenter
|
||||
}
|
||||
|
||||
getTopRight() {
|
||||
return this.topRight
|
||||
}
|
||||
|
||||
getCenter() {
|
||||
return this.center
|
||||
}
|
||||
|
||||
getCenterX() {
|
||||
return this.x + this.width / 2
|
||||
}
|
||||
|
||||
getCenterY() {
|
||||
return this.y + this.height / 2
|
||||
}
|
||||
|
||||
getBottomLeft() {
|
||||
return this.bottomLeft
|
||||
}
|
||||
|
||||
getBottomCenter() {
|
||||
return this.bottomCenter
|
||||
}
|
||||
|
||||
getBottomRight() {
|
||||
return this.bottomRight
|
||||
}
|
||||
|
||||
getCorner() {
|
||||
return this.corner
|
||||
}
|
||||
|
||||
getRightMiddle() {
|
||||
return this.rightMiddle
|
||||
}
|
||||
|
||||
getLeftMiddle() {
|
||||
return this.leftMiddle
|
||||
}
|
||||
|
||||
getTopLine() {
|
||||
return this.topLine
|
||||
}
|
||||
|
||||
getRightLine() {
|
||||
return this.rightLine
|
||||
}
|
||||
|
||||
getBottomLine() {
|
||||
return this.bottomLine
|
||||
}
|
||||
|
||||
getLeftLine() {
|
||||
return this.leftLine
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns a rectangle that is the bounding box of the rectangle.
|
||||
*
|
||||
* If `angle` is specified, the bounding box calculation will take into
|
||||
* account the rotation of the rectangle by angle degrees around its center.
|
||||
*/
|
||||
bbox(angle?: number) {
|
||||
if (!angle) {
|
||||
return this.clone()
|
||||
}
|
||||
|
||||
const rad = Angle.toRad(angle)
|
||||
const st = Math.abs(Math.sin(rad))
|
||||
const ct = Math.abs(Math.cos(rad))
|
||||
const w = this.width * ct + this.height * st
|
||||
const h = this.width * st + this.height * ct
|
||||
return new Rectangle(
|
||||
this.x + (this.width - w) / 2,
|
||||
this.y + (this.height - h) / 2,
|
||||
w,
|
||||
h,
|
||||
)
|
||||
}
|
||||
|
||||
round(precision = 0) {
|
||||
this.x = Util.round(this.x, precision)
|
||||
this.y = Util.round(this.y, precision)
|
||||
this.width = Util.round(this.width, precision)
|
||||
this.height = Util.round(this.height, precision)
|
||||
return this
|
||||
}
|
||||
|
||||
add(x: number, y: number, width: number, height: number): this
|
||||
add(rect: Rectangle.RectangleLike | Rectangle.RectangleData): this
|
||||
add(
|
||||
x: number | Rectangle.RectangleLike | Rectangle.RectangleData,
|
||||
y?: number,
|
||||
width?: number,
|
||||
height?: number,
|
||||
): this {
|
||||
const rect = Rectangle.create(x, y, width, height)
|
||||
const minX = Math.min(this.x, rect.x)
|
||||
const minY = Math.min(this.y, rect.y)
|
||||
const maxX = Math.max(this.x + this.width, rect.x + rect.width)
|
||||
const maxY = Math.max(this.y + this.height, rect.y + rect.height)
|
||||
|
||||
this.x = minX
|
||||
this.y = minY
|
||||
this.width = maxX - minX
|
||||
this.height = maxY - minY
|
||||
|
||||
return this
|
||||
}
|
||||
|
||||
update(x: number, y: number, width: number, height: number): this
|
||||
update(rect: Rectangle.RectangleLike | Rectangle.RectangleData): this
|
||||
update(
|
||||
x: number | Rectangle.RectangleLike | Rectangle.RectangleData,
|
||||
y?: number,
|
||||
width?: number,
|
||||
height?: number,
|
||||
): this {
|
||||
const rect = Rectangle.create(x, y, width, height)
|
||||
this.x = rect.x
|
||||
this.y = rect.y
|
||||
this.width = rect.width
|
||||
this.height = rect.height
|
||||
return this
|
||||
}
|
||||
|
||||
inflate(amount: number): this
|
||||
/**
|
||||
* Returns a rectangle inflated in axis-x by `2*dx` and in axis-y by `2*dy`.
|
||||
*/
|
||||
inflate(dx: number, dy: number): this
|
||||
inflate(dx: number, dy?: number): this {
|
||||
const w = dx
|
||||
const h = dy != null ? dy : dx
|
||||
this.x -= w
|
||||
this.y -= h
|
||||
this.width += 2 * w
|
||||
this.height += 2 * h
|
||||
|
||||
return this
|
||||
}
|
||||
|
||||
/**
|
||||
* Adjust the position and dimensions of the rectangle such that its edges
|
||||
* are on the nearest increment of `gx` on the x-axis and `gy` on the y-axis.
|
||||
*/
|
||||
snapToGrid(gridSize: number): this
|
||||
snapToGrid(gx: number, gy: number): this
|
||||
snapToGrid(gx: number, gy?: number): this
|
||||
snapToGrid(gx: number, gy?: number): this {
|
||||
const origin = this.origin.snapToGrid(gx, gy)
|
||||
const corner = this.corner.snapToGrid(gx, gy)
|
||||
this.x = origin.x
|
||||
this.y = origin.y
|
||||
this.width = corner.x - origin.x
|
||||
this.height = corner.y - origin.y
|
||||
return this
|
||||
}
|
||||
|
||||
translate(tx: number, ty: number): this
|
||||
translate(p: Point.PointLike | Point.PointData): this
|
||||
translate(tx: number | Point.PointLike | Point.PointData, ty?: number): this {
|
||||
const p = Point.create(tx, ty)
|
||||
this.x += p.x
|
||||
this.y += p.y
|
||||
return this
|
||||
}
|
||||
|
||||
scale(
|
||||
sx: number,
|
||||
sy: number,
|
||||
origin: Point.PointLike | Point.PointData = new Point(),
|
||||
) {
|
||||
const pos = this.origin.scale(sx, sy, origin)
|
||||
this.x = pos.x
|
||||
this.y = pos.y
|
||||
this.width *= sx
|
||||
this.height *= sy
|
||||
return this
|
||||
}
|
||||
|
||||
rotate(
|
||||
degree: number,
|
||||
center: Point.PointLike | Point.PointData = this.getCenter(),
|
||||
) {
|
||||
if (degree !== 0) {
|
||||
const rad = Angle.toRad(degree)
|
||||
const cos = Math.cos(rad)
|
||||
const sin = Math.sin(rad)
|
||||
|
||||
let p1 = this.getOrigin()
|
||||
let p2 = this.getTopRight()
|
||||
let p3 = this.getBottomRight()
|
||||
let p4 = this.getBottomLeft()
|
||||
|
||||
p1 = Point.rotateEx(p1, cos, sin, center)
|
||||
p2 = Point.rotateEx(p2, cos, sin, center)
|
||||
p3 = Point.rotateEx(p3, cos, sin, center)
|
||||
p4 = Point.rotateEx(p4, cos, sin, center)
|
||||
|
||||
const rect = new Rectangle(p1.x, p1.y, 0, 0)
|
||||
rect.add(p2.x, p2.y, 0, 0)
|
||||
rect.add(p3.x, p3.y, 0, 0)
|
||||
rect.add(p4.x, p4.y, 0, 0)
|
||||
|
||||
this.update(rect)
|
||||
}
|
||||
return this
|
||||
}
|
||||
|
||||
rotate90() {
|
||||
const t = (this.width - this.height) / 2
|
||||
this.x += t
|
||||
this.y -= t
|
||||
const tmp = this.width
|
||||
this.width = this.height
|
||||
this.height = tmp
|
||||
|
||||
return this
|
||||
}
|
||||
|
||||
/**
|
||||
* Translates the rectangle by `rect.x` and `rect.y` and expand it by
|
||||
* `rect.width` and `rect.height`.
|
||||
*/
|
||||
moveAndExpand(rect: Rectangle.RectangleLike | Rectangle.RectangleData) {
|
||||
const ref = Rectangle.clone(rect)
|
||||
this.x += ref.x || 0
|
||||
this.y += ref.y || 0
|
||||
this.width += ref.width || 0
|
||||
this.height += ref.height || 0
|
||||
return this
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns an object where `sx` and `sy` give the maximum scaling that can be
|
||||
* applied to the rectangle so that it would still fit into `limit`. If
|
||||
* `origin` is specified, the rectangle is scaled around it; otherwise, it is
|
||||
* scaled around its center.
|
||||
*/
|
||||
getMaxScaleToFit(
|
||||
limit: Rectangle.RectangleLike | Rectangle.RectangleData,
|
||||
origin: Point = this.center,
|
||||
) {
|
||||
const rect = Rectangle.clone(limit)
|
||||
const ox = origin.x
|
||||
const oy = origin.y
|
||||
|
||||
// Find the maximal possible scale for all corners, so when the scale
|
||||
// is applied the point is still inside the rectangle.
|
||||
let sx1 = Infinity
|
||||
let sx2 = Infinity
|
||||
let sx3 = Infinity
|
||||
let sx4 = Infinity
|
||||
let sy1 = Infinity
|
||||
let sy2 = Infinity
|
||||
let sy3 = Infinity
|
||||
let sy4 = Infinity
|
||||
|
||||
// Top Left
|
||||
const p1 = rect.topLeft
|
||||
if (p1.x < ox) {
|
||||
sx1 = (this.x - ox) / (p1.x - ox)
|
||||
}
|
||||
if (p1.y < oy) {
|
||||
sy1 = (this.y - oy) / (p1.y - oy)
|
||||
}
|
||||
|
||||
// Bottom Right
|
||||
const p2 = rect.bottomRight
|
||||
if (p2.x > ox) {
|
||||
sx2 = (this.x + this.width - ox) / (p2.x - ox)
|
||||
}
|
||||
if (p2.y > oy) {
|
||||
sy2 = (this.y + this.height - oy) / (p2.y - oy)
|
||||
}
|
||||
|
||||
// Top Right
|
||||
const p3 = rect.topRight
|
||||
if (p3.x > ox) {
|
||||
sx3 = (this.x + this.width - ox) / (p3.x - ox)
|
||||
}
|
||||
if (p3.y < oy) {
|
||||
sy3 = (this.y - oy) / (p3.y - oy)
|
||||
}
|
||||
|
||||
// Bottom Left
|
||||
const p4 = rect.bottomLeft
|
||||
if (p4.x < ox) {
|
||||
sx4 = (this.x - ox) / (p4.x - ox)
|
||||
}
|
||||
if (p4.y > oy) {
|
||||
sy4 = (this.y + this.height - oy) / (p4.y - oy)
|
||||
}
|
||||
|
||||
return {
|
||||
sx: Math.min(sx1, sx2, sx3, sx4),
|
||||
sy: Math.min(sy1, sy2, sy3, sy4),
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns a number that specifies the maximum scaling that can be applied to
|
||||
* the rectangle along both axes so that it would still fit into `limit`. If
|
||||
* `origin` is specified, the rectangle is scaled around it; otherwise, it is
|
||||
* scaled around its center.
|
||||
*/
|
||||
getMaxUniformScaleToFit(
|
||||
limit: Rectangle.RectangleLike | Rectangle.RectangleData,
|
||||
origin: Point = this.center,
|
||||
) {
|
||||
const scale = this.getMaxScaleToFit(limit, origin)
|
||||
return Math.min(scale.sx, scale.sy)
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns `true` if the point is inside the rectangle (inclusive).
|
||||
* Returns `false` otherwise.
|
||||
*/
|
||||
containsPoint(x: number, y: number): boolean
|
||||
containsPoint(point: Point.PointLike | Point.PointData): boolean
|
||||
containsPoint(
|
||||
x: number | Point.PointLike | Point.PointData,
|
||||
y?: number,
|
||||
): boolean {
|
||||
return Util.containsPoint(this, Point.create(x, y))
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns `true` if the rectangle is (completely) inside the
|
||||
* rectangle (inclusive). Returns `false` otherwise.
|
||||
*/
|
||||
containsRect(x: number, y: number, w: number, h: number): boolean
|
||||
containsRect(rect: Rectangle.RectangleLike | Rectangle.RectangleData): boolean
|
||||
containsRect(
|
||||
x: number | Rectangle.RectangleLike | Rectangle.RectangleData,
|
||||
y?: number,
|
||||
width?: number,
|
||||
height?: number,
|
||||
) {
|
||||
const b = Rectangle.create(x, y, width, height)
|
||||
const x1 = this.x
|
||||
const y1 = this.y
|
||||
const w1 = this.width
|
||||
const h1 = this.height
|
||||
|
||||
const x2 = b.x
|
||||
const y2 = b.y
|
||||
const w2 = b.width
|
||||
const h2 = b.height
|
||||
|
||||
// one of the dimensions is 0
|
||||
if (w1 === 0 || h1 === 0 || w2 === 0 || h2 === 0) {
|
||||
return false
|
||||
}
|
||||
|
||||
return x2 >= x1 && y2 >= y1 && x2 + w2 <= x1 + w1 && y2 + h2 <= y1 + h1
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns an array of the intersection points of the rectangle and the line.
|
||||
* Return `null` if no intersection exists.
|
||||
*/
|
||||
intersectsWithLine(line: Line) {
|
||||
const rectLines = [
|
||||
this.topLine,
|
||||
this.rightLine,
|
||||
this.bottomLine,
|
||||
this.leftLine,
|
||||
]
|
||||
const points: Point[] = []
|
||||
const dedupeArr: string[] = []
|
||||
rectLines.forEach((l) => {
|
||||
const p = line.intersectsWithLine(l)
|
||||
if (p !== null && dedupeArr.indexOf(p.toString()) < 0) {
|
||||
points.push(p)
|
||||
dedupeArr.push(p.toString())
|
||||
}
|
||||
})
|
||||
|
||||
return points.length > 0 ? points : null
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the point on the boundary of the rectangle that is the intersection
|
||||
* of the rectangle with a line starting in the center the rectangle ending in
|
||||
* the point `p`.
|
||||
*
|
||||
* If `angle` is specified, the intersection will take into account the
|
||||
* rotation of the rectangle by `angle` degrees around its center.
|
||||
*/
|
||||
intersectsWithLineFromCenterToPoint(
|
||||
p: Point.PointLike | Point.PointData,
|
||||
angle?: number,
|
||||
) {
|
||||
const ref = Point.clone(p)
|
||||
const center = this.center
|
||||
let result: Point | null = null
|
||||
|
||||
if (angle != null && angle !== 0) {
|
||||
ref.rotate(angle, center)
|
||||
}
|
||||
|
||||
const sides = [this.topLine, this.rightLine, this.bottomLine, this.leftLine]
|
||||
const connector = new Line(center, ref)
|
||||
|
||||
for (let i = sides.length - 1; i >= 0; i -= 1) {
|
||||
const intersection = sides[i].intersectsWithLine(connector)
|
||||
if (intersection !== null) {
|
||||
result = intersection
|
||||
break
|
||||
}
|
||||
}
|
||||
if (result && angle != null && angle !== 0) {
|
||||
result.rotate(-angle, center)
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns a rectangle that is a subtraction of the two rectangles if such an
|
||||
* object exists (the two rectangles intersect). Returns `null` otherwise.
|
||||
*/
|
||||
intersectsWithRect(
|
||||
x: number,
|
||||
y: number,
|
||||
w: number,
|
||||
h: number,
|
||||
): Rectangle | null
|
||||
intersectsWithRect(
|
||||
rect: Rectangle.RectangleLike | Rectangle.RectangleData,
|
||||
): Rectangle | null
|
||||
intersectsWithRect(
|
||||
x: number | Rectangle.RectangleLike | Rectangle.RectangleData,
|
||||
y?: number,
|
||||
width?: number,
|
||||
height?: number,
|
||||
) {
|
||||
const ref = Rectangle.create(x, y, width, height)
|
||||
|
||||
// no intersection
|
||||
if (!this.isIntersectWithRect(ref)) {
|
||||
return null
|
||||
}
|
||||
|
||||
const myOrigin = this.origin
|
||||
const myCorner = this.corner
|
||||
const rOrigin = ref.origin
|
||||
const rCorner = ref.corner
|
||||
|
||||
const xx = Math.max(myOrigin.x, rOrigin.x)
|
||||
const yy = Math.max(myOrigin.y, rOrigin.y)
|
||||
|
||||
return new Rectangle(
|
||||
xx,
|
||||
yy,
|
||||
Math.min(myCorner.x, rCorner.x) - xx,
|
||||
Math.min(myCorner.y, rCorner.y) - yy,
|
||||
)
|
||||
}
|
||||
|
||||
isIntersectWithRect(x: number, y: number, w: number, h: number): boolean
|
||||
isIntersectWithRect(
|
||||
rect: Rectangle.RectangleLike | Rectangle.RectangleData,
|
||||
): boolean
|
||||
isIntersectWithRect(
|
||||
x: number | Rectangle.RectangleLike | Rectangle.RectangleData,
|
||||
y?: number,
|
||||
width?: number,
|
||||
height?: number,
|
||||
) {
|
||||
const ref = Rectangle.create(x, y, width, height)
|
||||
const myOrigin = this.origin
|
||||
const myCorner = this.corner
|
||||
const rOrigin = ref.origin
|
||||
const rCorner = ref.corner
|
||||
|
||||
if (
|
||||
rCorner.x <= myOrigin.x ||
|
||||
rCorner.y <= myOrigin.y ||
|
||||
rOrigin.x >= myCorner.x ||
|
||||
rOrigin.y >= myCorner.y
|
||||
) {
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalize the rectangle, i.e. make it so that it has non-negative
|
||||
* width and height. If width is less than `0`, the function swaps left and
|
||||
* right corners and if height is less than `0`, the top and bottom corners
|
||||
* are swapped.
|
||||
*/
|
||||
normalize() {
|
||||
let newx = this.x
|
||||
let newy = this.y
|
||||
let newwidth = this.width
|
||||
let newheight = this.height
|
||||
if (this.width < 0) {
|
||||
newx = this.x + this.width
|
||||
newwidth = -this.width
|
||||
}
|
||||
if (this.height < 0) {
|
||||
newy = this.y + this.height
|
||||
newheight = -this.height
|
||||
}
|
||||
this.x = newx
|
||||
this.y = newy
|
||||
this.width = newwidth
|
||||
this.height = newheight
|
||||
return this
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns a rectangle that is a union of this rectangle and rectangle `rect`.
|
||||
*/
|
||||
union(rect: Rectangle.RectangleLike | Rectangle.RectangleData) {
|
||||
const ref = Rectangle.clone(rect)
|
||||
const myOrigin = this.origin
|
||||
const myCorner = this.corner
|
||||
const rOrigin = ref.origin
|
||||
const rCorner = ref.corner
|
||||
|
||||
const originX = Math.min(myOrigin.x, rOrigin.x)
|
||||
const originY = Math.min(myOrigin.y, rOrigin.y)
|
||||
const cornerX = Math.max(myCorner.x, rCorner.x)
|
||||
const cornerY = Math.max(myCorner.y, rCorner.y)
|
||||
|
||||
return new Rectangle(originX, originY, cornerX - originX, cornerY - originY)
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns a string ("top", "left", "right" or "bottom") denoting the side of
|
||||
* the rectangle which is nearest to the point `p`.
|
||||
*/
|
||||
getNearestSideToPoint(p: Point.PointLike | Point.PointData): Rectangle.Side {
|
||||
const ref = Point.clone(p)
|
||||
const distLeft = ref.x - this.x
|
||||
const distRight = this.x + this.width - ref.x
|
||||
const distTop = ref.y - this.y
|
||||
const distBottom = this.y + this.height - ref.y
|
||||
let closest = distLeft
|
||||
let side: Rectangle.Side = 'left'
|
||||
|
||||
if (distRight < closest) {
|
||||
closest = distRight
|
||||
side = 'right'
|
||||
}
|
||||
|
||||
if (distTop < closest) {
|
||||
closest = distTop
|
||||
side = 'top'
|
||||
}
|
||||
|
||||
if (distBottom < closest) {
|
||||
side = 'bottom'
|
||||
}
|
||||
|
||||
return side
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns a point on the boundary of the rectangle nearest to the point `p`.
|
||||
*/
|
||||
getNearestPointToPoint(p: Point.PointLike | Point.PointData) {
|
||||
const ref = Point.clone(p)
|
||||
if (this.containsPoint(ref)) {
|
||||
const side = this.getNearestSideToPoint(ref)
|
||||
switch (side) {
|
||||
case 'right':
|
||||
return new Point(this.x + this.width, ref.y)
|
||||
case 'left':
|
||||
return new Point(this.x, ref.y)
|
||||
case 'bottom':
|
||||
return new Point(ref.x, this.y + this.height)
|
||||
case 'top':
|
||||
return new Point(ref.x, this.y)
|
||||
default:
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
return ref.adhereToRect(this)
|
||||
}
|
||||
|
||||
equals(rect: Rectangle.RectangleLike) {
|
||||
return (
|
||||
rect != null &&
|
||||
rect.x === this.x &&
|
||||
rect.y === this.y &&
|
||||
rect.width === this.width &&
|
||||
rect.height === this.height
|
||||
)
|
||||
}
|
||||
|
||||
clone() {
|
||||
return new Rectangle(this.x, this.y, this.width, this.height)
|
||||
}
|
||||
|
||||
toJSON() {
|
||||
return { x: this.x, y: this.y, width: this.width, height: this.height }
|
||||
}
|
||||
|
||||
serialize() {
|
||||
return `${this.x} ${this.y} ${this.width} ${this.height}`
|
||||
}
|
||||
}
|
||||
|
||||
export namespace Rectangle {
|
||||
export const toStringTag = `X6.Geometry.${Rectangle.name}`
|
||||
|
||||
export function isRectangle(instance: any): instance is Rectangle {
|
||||
if (instance == null) {
|
||||
return false
|
||||
}
|
||||
|
||||
if (instance instanceof Rectangle) {
|
||||
return true
|
||||
}
|
||||
|
||||
const tag = instance[Symbol.toStringTag]
|
||||
const rect = instance as Rectangle
|
||||
|
||||
if (
|
||||
(tag == null || tag === toStringTag) &&
|
||||
typeof rect.x === 'number' &&
|
||||
typeof rect.y === 'number' &&
|
||||
typeof rect.width === 'number' &&
|
||||
typeof rect.height === 'number' &&
|
||||
typeof rect.inflate === 'function' &&
|
||||
typeof rect.moveAndExpand === 'function'
|
||||
) {
|
||||
return true
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
export namespace Rectangle {
|
||||
export type RectangleData = [number, number, number, number]
|
||||
|
||||
export interface RectangleLike extends Point.PointLike {
|
||||
x: number
|
||||
y: number
|
||||
width: number
|
||||
height: number
|
||||
}
|
||||
|
||||
export function isRectangleLike(o: any): o is RectangleLike {
|
||||
return (
|
||||
o != null &&
|
||||
typeof o === 'object' &&
|
||||
typeof o.x === 'number' &&
|
||||
typeof o.y === 'number' &&
|
||||
typeof o.width === 'number' &&
|
||||
typeof o.height === 'number'
|
||||
)
|
||||
}
|
||||
|
||||
export type Side = 'left' | 'right' | 'top' | 'bottom'
|
||||
|
||||
export type KeyPoint =
|
||||
| 'center'
|
||||
| 'origin'
|
||||
| 'corner'
|
||||
| 'topLeft'
|
||||
| 'topCenter'
|
||||
| 'topRight'
|
||||
| 'bottomLeft'
|
||||
| 'bottomCenter'
|
||||
| 'bottomRight'
|
||||
| 'rightMiddle'
|
||||
| 'leftMiddle'
|
||||
}
|
||||
|
||||
export namespace Rectangle {
|
||||
export function create(rect: RectangleLike | RectangleData): Rectangle
|
||||
export function create(
|
||||
x?: number,
|
||||
y?: number,
|
||||
width?: number,
|
||||
height?: number,
|
||||
): Rectangle
|
||||
export function create(
|
||||
x?: number | RectangleLike | RectangleData,
|
||||
y?: number,
|
||||
width?: number,
|
||||
height?: number,
|
||||
): Rectangle
|
||||
export function create(
|
||||
x?: number | RectangleLike | RectangleData,
|
||||
y?: number,
|
||||
width?: number,
|
||||
height?: number,
|
||||
): Rectangle {
|
||||
if (x == null || typeof x === 'number') {
|
||||
return new Rectangle(x, y, width, height)
|
||||
}
|
||||
|
||||
return clone(x)
|
||||
}
|
||||
|
||||
export function clone(rect: RectangleLike | RectangleData) {
|
||||
if (Rectangle.isRectangle(rect)) {
|
||||
return rect.clone()
|
||||
}
|
||||
|
||||
if (Array.isArray(rect)) {
|
||||
return new Rectangle(rect[0], rect[1], rect[2], rect[3])
|
||||
}
|
||||
|
||||
return new Rectangle(rect.x, rect.y, rect.width, rect.height)
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns a new rectangle from the given ellipse.
|
||||
*/
|
||||
export function fromEllipse(ellipse: Ellipse) {
|
||||
return new Rectangle(
|
||||
ellipse.x - ellipse.a,
|
||||
ellipse.y - ellipse.b,
|
||||
2 * ellipse.a,
|
||||
2 * ellipse.b,
|
||||
)
|
||||
}
|
||||
|
||||
interface Size {
|
||||
width: number
|
||||
height: number
|
||||
}
|
||||
|
||||
export function fromSize(size: Size) {
|
||||
return new Rectangle(0, 0, size.width, size.height)
|
||||
}
|
||||
|
||||
export function fromPositionAndSize(pos: Point.PointLike, size: Size) {
|
||||
return new Rectangle(pos.x, pos.y, size.width, size.height)
|
||||
}
|
||||
}
|
21
packages/x6-geometry/src/types.ts
Normal file
21
packages/x6-geometry/src/types.ts
Normal file
@ -0,0 +1,21 @@
|
||||
/**
|
||||
* A type alias for a JSON primitive.
|
||||
*/
|
||||
export type JSONPrimitive = boolean | number | string | null | undefined
|
||||
|
||||
/**
|
||||
* A type alias for a JSON value.
|
||||
*/
|
||||
export type JSONValue = JSONPrimitive | JSONObject | JSONArray
|
||||
|
||||
/**
|
||||
* A type definition for a JSON object.
|
||||
*/
|
||||
export interface JSONObject {
|
||||
[key: string]: JSONValue
|
||||
}
|
||||
|
||||
/**
|
||||
* A type definition for a JSON array.
|
||||
*/
|
||||
export interface JSONArray extends Array<JSONValue> {}
|
78
packages/x6-geometry/src/util.ts
Normal file
78
packages/x6-geometry/src/util.ts
Normal file
@ -0,0 +1,78 @@
|
||||
import type { Point } from './point'
|
||||
import type { Rectangle } from './rectangle'
|
||||
|
||||
export namespace Util {
|
||||
export function round(num: number, precision = 0) {
|
||||
return Number.isInteger(num) ? num : +num.toFixed(precision)
|
||||
}
|
||||
|
||||
export function random(): number
|
||||
export function random(max: number): number
|
||||
export function random(min: number, max: number): number
|
||||
export function random(min?: number, max?: number): number {
|
||||
let mmin
|
||||
let mmax
|
||||
|
||||
if (max == null) {
|
||||
mmax = min == null ? 1 : min
|
||||
mmin = 0
|
||||
} else {
|
||||
mmax = max
|
||||
mmin = min == null ? 0 : min
|
||||
}
|
||||
|
||||
if (mmax < mmin) {
|
||||
const temp = mmin
|
||||
mmin = mmax
|
||||
mmax = temp
|
||||
}
|
||||
|
||||
return Math.floor(Math.random() * (mmax - mmin + 1) + mmin)
|
||||
}
|
||||
|
||||
export function clamp(value: number, min: number, max: number) {
|
||||
if (Number.isNaN(value)) {
|
||||
return NaN
|
||||
}
|
||||
|
||||
if (Number.isNaN(min) || Number.isNaN(max)) {
|
||||
return 0
|
||||
}
|
||||
|
||||
return min < max
|
||||
? value < min
|
||||
? min
|
||||
: value > max
|
||||
? max
|
||||
: value
|
||||
: value < max
|
||||
? max
|
||||
: value > min
|
||||
? min
|
||||
: value
|
||||
}
|
||||
|
||||
export function snapToGrid(value: number, gridSize: number) {
|
||||
return gridSize * Math.round(value / gridSize)
|
||||
}
|
||||
|
||||
export function containsPoint(
|
||||
rect: Rectangle.RectangleLike,
|
||||
point: Point.PointLike,
|
||||
) {
|
||||
return (
|
||||
point != null &&
|
||||
rect != null &&
|
||||
point.x >= rect.x &&
|
||||
point.x <= rect.x + rect.width &&
|
||||
point.y >= rect.y &&
|
||||
point.y <= rect.y + rect.height
|
||||
)
|
||||
}
|
||||
|
||||
export function squaredLength(p1: Point.PointLike, p2: Point.PointLike) {
|
||||
const dx = p1.x - p2.x
|
||||
const dy = p1.y - p2.y
|
||||
return dx * dx + dy * dy
|
||||
}
|
||||
}
|
9
packages/x6-geometry/src/version.test.ts
Normal file
9
packages/x6-geometry/src/version.test.ts
Normal file
@ -0,0 +1,9 @@
|
||||
import { version } from './version'
|
||||
|
||||
describe('version', () => {
|
||||
it('should match the `version` field of package.json', () => {
|
||||
// eslint-disable-next-line
|
||||
const expected = require('../package.json').version
|
||||
expect(version).toBe(expected)
|
||||
})
|
||||
})
|
7
packages/x6-geometry/src/version.ts
Normal file
7
packages/x6-geometry/src/version.ts
Normal file
@ -0,0 +1,7 @@
|
||||
/* eslint-disable */
|
||||
|
||||
/**
|
||||
* Auto generated version file, do not modify it!
|
||||
*/
|
||||
const version = '1.0.0'
|
||||
export { version }
|
3
packages/x6-geometry/tsconfig.json
Normal file
3
packages/x6-geometry/tsconfig.json
Normal file
@ -0,0 +1,3 @@
|
||||
{
|
||||
"extends": "../../tsconfig.json"
|
||||
}
|
Reference in New Issue
Block a user