Skip to content

Commit bf8690f

Browse files
committed
Add some basic bezier math
1 parent 6f334c2 commit bf8690f

4 files changed

Lines changed: 281 additions & 0 deletions

File tree

Lines changed: 197 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,197 @@
1+
import Foundation
2+
3+
public enum Bezier {
4+
5+
/// Calculate a point on the bezier curve passed in, specifically the point at parameter.
6+
/// We're using De Casteljau's algorithm, which not only calculates the point at parameter
7+
/// in a numerically stable way, it also computes the two resulting bezier curves that
8+
/// would be formed if the original were split at the parameter specified.
9+
///
10+
/// See: http://www.cs.mtu.edu/~shene/COURSES/cs3621/NOTES/spline/Bezier/de-casteljau.html
11+
/// for an explaination of De Casteljau's algorithm.
12+
///
13+
/// bezierPoints, leftCurve, rightCurve will have a length of degree + 1.
14+
/// degree is the order of the bezier path, which will be cubic (3) most of the time.
15+
static func splitBezier(_ bezierPoints: [Point], ofDegree degree: Int, at parameter: Real) -> (point: Point, leftCurve: [Point], rightCurve: [Point]) {
16+
// With this algorithm we start out with the points in the bezier path.
17+
var points = Array(bezierPoints[0...degree])
18+
var leftArray = Array(repeating: Point.zero, count: degree + 1)
19+
var rightArray = Array(repeating: Point.zero, count: degree + 1)
20+
21+
// If the caller is asking for the resulting bezier curves, start filling those in
22+
leftArray[0] = points[0]
23+
rightArray[degree] = points[degree]
24+
25+
for k in 1...degree {
26+
for i in 0...(degree - k) {
27+
points[i].x = (1.0 - parameter) * points[i].x + parameter * points[i + 1].x
28+
points[i].y = (1.0 - parameter) * points[i].y + parameter * points[i + 1].y
29+
}
30+
leftArray[k] = points[0]
31+
rightArray[degree - k] = points[degree - k]
32+
}
33+
34+
// The point in the curve at parameter ends up in points[0]
35+
return (point: points[0], leftCurve: leftArray, rightCurve: rightArray)
36+
}
37+
38+
static func findRoots(for bezierPoints: [Point], ofDegree degree: Int) -> [Real] {
39+
var results = [Real]()
40+
findRoots(for: bezierPoints, ofDegree: degree, atDepth: 0, into: &results)
41+
return results
42+
}
43+
44+
static func closestLocation(on bezierPoints: [Point], to point: Point) -> BezierCurveLocation {
45+
let relatedBezier = convertBezier(bezierPoints, relativeTo: point)
46+
47+
let locations = [
48+
BezierCurveLocation(parameter: 0, distance: bezierPoints[0].distance(to: point)),
49+
BezierCurveLocation(parameter: 1, distance: bezierPoints[3].distance(to: point)),
50+
] + findRoots(for: relatedBezier, ofDegree: 5)
51+
.map { root in
52+
let split = splitBezier(bezierPoints, ofDegree: 3, at: root)
53+
return BezierCurveLocation(parameter: root, distance: split.point.distance(to: point))
54+
}
55+
.sorted { $0.distance < $1.distance }
56+
57+
return locations[0]
58+
}
59+
}
60+
61+
private extension Bezier {
62+
static func convertBezier(_ bezierPoints: [Point], relativeTo point: Point) -> [Point] {
63+
// c[i] in the paper
64+
let distanceFromPoint = [
65+
bezierPoints[0] - point,
66+
bezierPoints[1] - point,
67+
bezierPoints[2] - point,
68+
bezierPoints[3] - point
69+
]
70+
71+
// d[i] in the paper
72+
let weightedDelta = [
73+
(bezierPoints[1] - bezierPoints[0]) * 3,
74+
(bezierPoints[2] - bezierPoints[1]) * 3,
75+
(bezierPoints[3] - bezierPoints[2]) * 3
76+
]
77+
78+
// Precompute the dot product of distanceFromPoint and weightedDelta in order to speed things up
79+
var precomputedTable: [[Real]] = [
80+
[0, 0, 0, 0],
81+
[0, 0, 0, 0],
82+
[0, 0, 0, 0]
83+
]
84+
for row in 0 ..< 3 {
85+
for column in 0 ..< 4 {
86+
precomputedTable[row][column] = weightedDelta[row].dotMultiply(distanceFromPoint[column])
87+
}
88+
}
89+
90+
// Precompute some of the values to speed things up
91+
let z: [[Real]] = [
92+
[1.0, 0.6, 0.3, 0.1],
93+
[0.4, 0.6, 0.6, 0.4],
94+
[0.1, 0.3, 0.6, 1.0]
95+
]
96+
97+
// create our output array
98+
var results = Array(repeating: Point.zero, count: 6)
99+
100+
// Set the x values of the bezier points
101+
for i in 0 ..< 6 {
102+
results[i] = Point(x: Real(i) / 5.0, y: 0)
103+
}
104+
105+
// Finally set the y values of the bezier points
106+
let n = 3
107+
let m = n - 1
108+
for k in 0...(n + m) {
109+
let lowerBound = max(0, k - m)
110+
let upperBound = min(k, n)
111+
for i in lowerBound...upperBound {
112+
let j = k - i
113+
results[i + j].y += Real(precomputedTable[j][i] * z[j][i])
114+
}
115+
}
116+
117+
return results
118+
}
119+
120+
static let findBezierRootsMaximumDepth = 64
121+
122+
static func crossings(_ bezierPoints: [Point], degree: Int) -> Int {
123+
var count = 0
124+
var sign = bezierPoints[0].y.sign
125+
126+
var previousSign = sign
127+
for i in 1...degree {
128+
sign = bezierPoints[i].y.sign
129+
if sign != previousSign {
130+
count += 1
131+
}
132+
previousSign = sign
133+
}
134+
return count
135+
}
136+
137+
static func isControlPolygonFlatEnough(_ bezierPoints: [Point], degree: Int, intersectionPoint: inout Point) -> Bool {
138+
// 2^-63
139+
let findBezierRootsErrorThreshold = pow(Real(2), Real(-1 * (Bezier.findBezierRootsMaximumDepth - 1)))
140+
141+
let line = NormalizedLine(point1: bezierPoints[0], point2: bezierPoints[degree])
142+
143+
// Find the bounds around the line
144+
var belowDistance = 0.0
145+
var aboveDistance = 0.0
146+
for i in 1..<degree {
147+
let distance = line.distance(to: bezierPoints[i])
148+
if distance > aboveDistance {
149+
aboveDistance = distance
150+
}
151+
152+
if distance < belowDistance {
153+
belowDistance = distance
154+
}
155+
}
156+
157+
let zeroLine = NormalizedLine(a: 0.0, b: 1.0, c: 0.0)
158+
let aboveLine = line.offset(-aboveDistance)
159+
let intersect1 = zeroLine.intersectionWith(aboveLine)
160+
161+
let belowLine = line.offset(-belowDistance)
162+
let intersect2 = zeroLine.intersectionWith(belowLine)
163+
164+
let error = max(intersect1.x, intersect2.x) - min(intersect1.x, intersect2.x)
165+
if error < findBezierRootsErrorThreshold {
166+
intersectionPoint = zeroLine.intersectionWith(line)
167+
return true
168+
}
169+
170+
return false
171+
}
172+
173+
static func findRoots(for bezierPoints: [Point], ofDegree degree: Int, atDepth depth: Int, into results: inout [Real]) {
174+
let crossingCount = crossings(bezierPoints, degree: degree)
175+
guard crossingCount != 0 else {
176+
return
177+
}
178+
179+
if crossingCount == 1 {
180+
if depth >= findBezierRootsMaximumDepth {
181+
let root = bezierPoints[0].x + bezierPoints[degree].x / 2.0
182+
results.append(root)
183+
return
184+
}
185+
var intersectionPoint = Point.zero
186+
if isControlPolygonFlatEnough(bezierPoints, degree: degree, intersectionPoint: &intersectionPoint) {
187+
results.append(intersectionPoint.x)
188+
return
189+
}
190+
}
191+
192+
// Subdivide and try again
193+
let splitResult = splitBezier(bezierPoints, ofDegree: degree, at: 0.5)
194+
findRoots(for: splitResult.leftCurve, ofDegree: degree, atDepth: depth + 1, into: &results)
195+
findRoots(for: splitResult.rightCurve, ofDegree: degree, atDepth: depth + 1, into: &results)
196+
}
197+
}
Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
public struct BezierCurveLocation: Hashable, Sendable {
2+
public let parameter: Real
3+
public let distance: Real
4+
5+
public init(parameter: Real, distance: Real) {
6+
self.parameter = parameter
7+
self.distance = distance
8+
}
9+
}

Sources/BaseKit/Geometry/BezierPathRepresentable.swift

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,5 +30,30 @@ public extension BezierPathRepresentable {
3030
}
3131
}
3232
}
33+
34+
func distance(to point: Point) -> Real {
35+
var currentPoint = Point.zero
36+
var distances = [Real]()
37+
enumerate { element in
38+
switch element {
39+
case let .move(to: endPoint):
40+
currentPoint = endPoint
41+
case let .line(to: endPoint):
42+
let line = NormalizedLine(point1: currentPoint, point2: endPoint)
43+
distances.append(line.distance(to: point))
44+
currentPoint = endPoint
45+
46+
case let .curve(to: endPoint2, control1: control1, control2: control2):
47+
let location = Bezier.closestLocation(on: [currentPoint, control1, control2, endPoint2], to: point)
48+
distances.append(location.distance)
49+
currentPoint = endPoint2
50+
51+
case .closeSubpath:
52+
currentPoint = .zero
53+
}
54+
}
55+
return distances.min() ?? .greatestFiniteMagnitude
56+
}
57+
3358
}
3459

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,50 @@
1+
import Foundation
2+
3+
public struct NormalizedLine: Hashable, Sendable {
4+
public let a: Real // * x +
5+
public let b: Real // * y +
6+
public let c: Real // constant
7+
8+
public init(a: Real, b: Real, c: Real) {
9+
self.a = a
10+
self.b = b
11+
self.c = c
12+
}
13+
14+
public func offset(_ offset: Real) -> NormalizedLine {
15+
.init(a: a, b: b, c: c + offset)
16+
}
17+
18+
public func distance(to point: Point) -> Real {
19+
a * Double(point.x) + b * Double(point.y) + c
20+
}
21+
22+
public func intersectionWith(_ other: NormalizedLine) -> Point {
23+
let denominator = (a * other.b) - (other.a * b)
24+
25+
return Point(
26+
x: (b * other.c - other.b * c) / denominator,
27+
y: (a * other.c - other.a * c) / denominator)
28+
}
29+
}
30+
31+
public extension NormalizedLine {
32+
/// Create a normalized line such that computing the distance from it is quick.
33+
/// See: http://softsurfer.com/Archive/algorithm_0102/algorithm_0102.htm#Distance%20to%20an%20Infinite%20Line
34+
/// http://www.cs.mtu.edu/~shene/COURSES/cs3621/NOTES/geometry/basic.html
35+
init(point1: Point, point2: Point) {
36+
let a = point1.y - point2.y
37+
let b = point2.x - point1.x
38+
let c = point1.x * point2.y - point2.x * point1.y
39+
40+
let distance = sqrt(b * b + a * a)
41+
42+
// GPC: prevent divide-by-zero from putting NaNs into the values which cause trouble further on. I'm not sure
43+
// what cases trigger this, but sometimes point1 == point2 so distance is 0.
44+
if distance != 0.0 {
45+
self.init(a: a / distance, b: b / distance, c: c / distance)
46+
} else {
47+
self.init(a: 0.0, b: 0.0, c: 0.0)
48+
}
49+
}
50+
}

0 commit comments

Comments
 (0)