Skip to content

Commit 15ca5b0

Browse files
committed
Computing the bounding box of a BezierPath as bounds
1 parent 0dd50fa commit 15ca5b0

6 files changed

Lines changed: 188 additions & 0 deletions

File tree

Sources/BaseKit/Geometry/Bezier.swift

Lines changed: 82 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -71,9 +71,91 @@ public enum Bezier {
7171
end,
7272
]
7373
}
74+
75+
public static func computeBoundingBoxForLine(start: Point, end: Point) -> Rect {
76+
var topLeft = start
77+
var bottomRight = topLeft
78+
end.expandBounds(topLeft: &topLeft, bottomRight: &bottomRight)
79+
return Rect(
80+
x: topLeft.x,
81+
y: topLeft.y,
82+
width: bottomRight.x - topLeft.x,
83+
height: bottomRight.y - topLeft.y
84+
)
85+
}
86+
87+
public static func computeBoundingBoxForCubicBezier(_ bezierPoints: [Point]) -> Rect {
88+
// Start with the end points
89+
var (topLeft, _, _) = splitBezier(bezierPoints, ofDegree: 3, at: 0)
90+
var bottomRight = topLeft
91+
let (lastPoint, _, _) = splitBezier(bezierPoints, ofDegree: 3, at: 1)
92+
93+
lastPoint.expandBounds(topLeft: &topLeft, bottomRight: &bottomRight)
94+
95+
// Find the roots, which should be the extremities
96+
let xRoots = computeCubicFirstDerivativeRoots(
97+
a: bezierPoints[0].x,
98+
b: bezierPoints[1].x,
99+
c: bezierPoints[2].x,
100+
d: bezierPoints[3].x
101+
)
102+
103+
for t in xRoots {
104+
if t < 0 || t > 1 {
105+
continue
106+
}
107+
let (location, _, _) = splitBezier(bezierPoints, ofDegree: 3, at: t)
108+
location.expandBounds(topLeft: &topLeft, bottomRight: &bottomRight)
109+
}
110+
111+
let yRoots = computeCubicFirstDerivativeRoots(
112+
a: bezierPoints[0].y,
113+
b: bezierPoints[1].y,
114+
c: bezierPoints[2].y,
115+
d: bezierPoints[3].y
116+
)
117+
for t in yRoots {
118+
if t < 0 || t > 1 {
119+
continue
120+
}
121+
let (location, _, _) = splitBezier(bezierPoints, ofDegree: 3, at: t)
122+
location.expandBounds(topLeft: &topLeft, bottomRight: &bottomRight)
123+
}
124+
125+
return Rect(
126+
x: topLeft.x,
127+
y: topLeft.y,
128+
width: bottomRight.x - topLeft.x,
129+
height: bottomRight.y - topLeft.y
130+
)
131+
}
74132
}
75133

76134
private extension Bezier {
135+
static func computeCubicFirstDerivativeRoots(a: Real, b: Real, c: Real, d: Real) -> [Real] {
136+
// See http://processingjs.nihongoresources.com/bezierinfo/#bounds for where the formulas come from
137+
138+
let denominator = -a + 3.0 * b - 3.0 * c + d
139+
140+
// If denominator == 0, fall back to
141+
if denominator.isClose(to: 0.0, threshold: 1e-9) {
142+
let t = (a - b) / (2.0 * (a - 2.0 * b + c))
143+
return [t]
144+
} else {
145+
let numeratorLeft = -a + 2.0 * b - c
146+
147+
let v1 = -a * (c - d)
148+
let v2 = b * b
149+
let v3 = b * (c + d)
150+
let v4 = c * c
151+
let numeratorRight = -1.0 * sqrt(v1 + v2 - v3 + v4)
152+
153+
let t1 = (numeratorLeft + numeratorRight) / denominator
154+
let t2 = (numeratorLeft - numeratorRight) / denominator
155+
return [t1, t2]
156+
}
157+
}
158+
77159
static func convertBezier(_ bezierPoints: [Point], relativeTo point: Point) -> [Point] {
78160
// c[i] in the paper
79161
let distanceFromPoint = [

Sources/BaseKit/Geometry/BezierPath.swift

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -168,6 +168,45 @@ public struct BezierPath: Hashable, BezierPathRepresentable, Codable, Sendable {
168168
}
169169
}
170170

171+
public var bounds: Rect {
172+
var rect: Rect? = nil
173+
var currentPoint: Point? = nil
174+
var startPoint: Point? = nil
175+
176+
for element in elements {
177+
switch element {
178+
case let .move(to: point):
179+
startPoint = point
180+
currentPoint = point
181+
case let .line(to: point):
182+
if let currentPoint {
183+
let bounds = Bezier.computeBoundingBoxForLine(start: currentPoint, end: point)
184+
rect = rect.union(bounds)
185+
}
186+
currentPoint = point
187+
case let .curve(to: point, control1: control1, control2: control2):
188+
if let currentPoint {
189+
let bounds = Bezier.computeBoundingBoxForCubicBezier([
190+
currentPoint, control1, control2, point
191+
])
192+
rect = rect.union(bounds)
193+
}
194+
currentPoint = point
195+
196+
case .closeSubpath:
197+
if let startPoint, let currentPoint, startPoint != currentPoint {
198+
// Draw a line between ending point and starting point
199+
let bounds = Bezier.computeBoundingBoxForLine(start: currentPoint, end: startPoint)
200+
rect = rect.union(bounds)
201+
}
202+
currentPoint = nil
203+
startPoint = nil
204+
}
205+
}
206+
207+
return rect ?? .zero
208+
}
209+
171210
public func reversed() -> BezierPath {
172211
var subpaths = [[BezierPath.Element]]()
173212
var currentSubpath = [BezierPath.Element]()

Sources/BaseKit/Geometry/Rect.swift

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -46,4 +46,15 @@ public struct Rect: Hashable, Codable, Sendable {
4646
public func contains(_ point: Point) -> Bool {
4747
point.x >= minX && point.x <= maxX && point.y >= minY && point.y <= maxY
4848
}
49+
50+
public func isClose(to other: Rect, threshold: Real) -> Bool {
51+
origin.isClose(to: other.origin, threshold: threshold)
52+
&& size.isClose(to: other.size, threshold: threshold)
53+
}
54+
}
55+
56+
public extension Optional where Wrapped == Rect {
57+
func union(_ other: Rect) -> Rect {
58+
map { $0.union(other) } ?? other
59+
}
4960
}

Sources/BaseKit/Geometry/Size.swift

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,4 +16,9 @@ public struct Size: Hashable, Codable, Sendable {
1616
let heightDelta = size.height - height
1717
return sqrt(widthDelta * widthDelta + heightDelta * heightDelta)
1818
}
19+
20+
public func isClose(to other: Size, threshold: Real) -> Bool {
21+
width.isClose(to: other.width, threshold: threshold)
22+
&& height.isClose(to: other.height, threshold: threshold)
23+
}
1924
}

Sources/BaseKit/Geometry/Transform.swift

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -50,6 +50,12 @@ public extension Transform {
5050
)
5151
}
5252

53+
init(rotate angle: Real, anchor: Point) {
54+
self = Transform(translateX: anchor.x, translateY: anchor.y)
55+
.concatenating(Transform(rotate: angle))
56+
.concatenating(Transform(translateX: -anchor.x, translateY: -anchor.y))
57+
}
58+
5359
init(skewX angle: Real) {
5460
self.init(c: tan(angle))
5561
}
Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
import Testing
2+
@testable import BaseKit
3+
4+
struct BezierPathTests {
5+
@Test
6+
func testBoundsWithEllipse() {
7+
let ellipse = BezierPath(ellipseIn: Rect(x: 25, y: 50, width: 50, height: 75))
8+
let bounds = ellipse.bounds
9+
let expected = Rect(x: 25, y: 50, width: 50, height: 75)
10+
#expect(bounds == expected)
11+
}
12+
13+
@Test
14+
func testBoundsWithRectangle() {
15+
let rect = BezierPath(rect: Rect(x: 25, y: 50, width: 50, height: 75))
16+
let bounds = rect.bounds
17+
let expected = Rect(x: 25, y: 50, width: 50, height: 75)
18+
#expect(bounds == expected)
19+
}
20+
21+
@Test
22+
func testBoundsWithRotatedCircle() {
23+
var circle = BezierPath(ellipseIn: Rect(x: 25, y: 50, width: 50, height: 50))
24+
circle.transform(Transform(rotate: Real.pi / 4.0, anchor: Point(x: 50, y: 75)))
25+
let bounds = circle.bounds
26+
let expected = Rect(x: 25, y: 50, width: 50, height: 50)
27+
#expect(bounds.isClose(to: expected, threshold: 1e-6))
28+
}
29+
30+
@Test
31+
func testBoundsWithRotatedSquare() {
32+
let center = Point(x: 50, y: 75)
33+
var square = BezierPath(rect: Rect(x: 25, y: 50, width: 50, height: 50))
34+
square.transform(Transform(rotate: Real.pi / 4.0, anchor: center))
35+
let bounds = square.bounds
36+
let radius: Real = 35.35533906
37+
let expected = Rect(
38+
x: center.x - radius,
39+
y: center.y - radius,
40+
width: radius * 2,
41+
height: radius * 2
42+
)
43+
#expect(bounds.isClose(to: expected, threshold: 1e-6))
44+
}
45+
}

0 commit comments

Comments
 (0)