From 7eff8a15b99de0cf06ca79f5ded5f4b46378e113 Mon Sep 17 00:00:00 2001 From: Holmes Futrell Date: Fri, 24 Apr 2026 17:10:24 -0700 Subject: [PATCH 1/2] fix boundingBox / boundingBoxOfPath naming to match CoreGraphics convention MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Previously BezierKit's boundingBox was tight (exact curve geometry) and boundingBoxOfPath was loose (control-point hull) — the opposite of CoreGraphics. Swap the implementations so the API matches CGPath convention: - boundingBox: loose bounding box including control points - boundingBoxOfPath: tight (exact) bounding box of the path geometry Update all internal call sites to keep using the tight box for spatial queries (intersection pruning, winding count, projection). Add / update tests so each property has a case that clearly demonstrates the distinction. Closes #106 Co-Authored-By: Claude Sonnet 4.6 --- .../Path+VectorBooleanTests.swift | 12 +++--- .../BezierKitTests/PathComponentTests.swift | 43 +++++++++++-------- BezierKit/BezierKitTests/PathTests.swift | 26 +++++++++-- BezierKit/Library/Path+Projection.swift | 2 +- BezierKit/Library/Path.swift | 6 +-- .../Library/PathComponent+WindingCount.swift | 2 +- BezierKit/Library/PathComponent.swift | 14 +++--- 7 files changed, 67 insertions(+), 38 deletions(-) diff --git a/BezierKit/BezierKitTests/Path+VectorBooleanTests.swift b/BezierKit/BezierKitTests/Path+VectorBooleanTests.swift index 48e7e74a..0631d09a 100644 --- a/BezierKit/BezierKitTests/Path+VectorBooleanTests.swift +++ b/BezierKit/BezierKitTests/Path+VectorBooleanTests.swift @@ -331,8 +331,8 @@ class PathVectorBooleanTests: XCTestCase { XCTAssertTrue(a.contains(point, using: rule)) XCTAssertTrue(b.contains(point, using: rule)) XCTAssertTrue(result.contains(point, using: rule), "a union b should contain point that is in both a and b") - XCTAssertTrue(result.boundingBox.cgRect.insetBy(dx: -1, dy: -1).contains(a.boundingBox.cgRect), "resulting bounding box should contain a.boundingBox") - XCTAssertTrue(result.boundingBox.cgRect.insetBy(dx: -1, dy: -1).contains(b.boundingBox.cgRect), "resulting bounding box should contain b.boundingBox") + XCTAssertTrue(result.boundingBoxOfPath.cgRect.insetBy(dx: -1, dy: -1).contains(a.boundingBoxOfPath.cgRect), "resulting bounding box should contain a.boundingBoxOfPath") + XCTAssertTrue(result.boundingBoxOfPath.cgRect.insetBy(dx: -1, dy: -1).contains(b.boundingBoxOfPath.cgRect), "resulting bounding box should contain b.boundingBoxOfPath") } #endif @@ -576,8 +576,8 @@ class PathVectorBooleanTests: XCTestCase { let path = Path(cgPath: cgPath) let result = path.crossingsRemoved(accuracy: 0.01) // in practice .crossingsRemoved was cutting off most of the shape - XCTAssertEqual(path.boundingBox.size.x, result.boundingBox.size.x, accuracy: 1.0e-3) - XCTAssertEqual(path.boundingBox.size.y, result.boundingBox.size.y, accuracy: 1.0e-3) + XCTAssertEqual(path.boundingBoxOfPath.size.x, result.boundingBoxOfPath.size.x, accuracy: 1.0e-3) + XCTAssertEqual(path.boundingBoxOfPath.size.y, result.boundingBoxOfPath.size.y, accuracy: 1.0e-3) XCTAssertEqual(result.components[0].numberOfElements, 5) // with crossings removed we should have 1 fewer curve (the last one) } @@ -605,8 +605,8 @@ class PathVectorBooleanTests: XCTestCase { let path = Path(cgPath: cgPath) let result = path.crossingsRemoved(accuracy: 1.0e-5) // in practice .crossingsRemoved was cutting off most of the shape - XCTAssertEqual(path.boundingBox.size.x, result.boundingBox.size.x, accuracy: 1.0e-3) - XCTAssertEqual(path.boundingBox.size.y, result.boundingBox.size.y, accuracy: 1.0e-3) + XCTAssertEqual(path.boundingBoxOfPath.size.x, result.boundingBoxOfPath.size.x, accuracy: 1.0e-3) + XCTAssertEqual(path.boundingBoxOfPath.size.y, result.boundingBoxOfPath.size.y, accuracy: 1.0e-3) } func testCrossingsRemovedThirdRealWorldCase() { diff --git a/BezierKit/BezierKitTests/PathComponentTests.swift b/BezierKit/BezierKitTests/PathComponentTests.swift index fa8a2d83..d3d15ee2 100644 --- a/BezierKit/BezierKitTests/PathComponentTests.swift +++ b/BezierKit/BezierKitTests/PathComponentTests.swift @@ -20,31 +20,40 @@ class PathComponentTests: XCTestCase { } func testBoundingBox() { + // line segments: tight == loose, so boundingBox matches the union of endpoints let p = PathComponent(curves: [line1, line2]) - XCTAssertEqual(p.boundingBox, BoundingBox(min: CGPoint(x: 1.0, y: -1.0), max: CGPoint(x: 13.0, y: 5.0))) // just the union of the two bounding boxes + XCTAssertEqual(p.boundingBox, BoundingBox(min: CGPoint(x: 1.0, y: -1.0), max: CGPoint(x: 13.0, y: 5.0))) + // a quadratic whose middle control point extends outside the actual curve — + // boundingBox (loose) includes the control point, unlike boundingBoxOfPath (tight) + let quadratic = QuadraticCurve(p0: CGPoint(x: 0, y: 0), + p1: CGPoint(x: 2, y: 4), + p2: CGPoint(x: 4, y: 0)) + XCTAssertEqual(PathComponent(curve: quadratic).boundingBox, BoundingBox(p1: CGPoint(x: 0, y: 0), p2: CGPoint(x: 4, y: 4))) } func testBoundingBoxOfPath() { + // boundingBoxOfPath is the tight (exact) bounding box of the path, matching CoreGraphics convention let point1 = CGPoint(x: 3, y: -2) let pointComponent = PathComponent(points: [point1], orders: [0]) XCTAssertEqual(pointComponent.boundingBoxOfPath, BoundingBox(p1: CGPoint(x: 3, y: -2), p2: CGPoint(x: 3, y: -2))) - let line = LineSegment(p0: CGPoint(x: 1, y: 2), - p1: CGPoint(x: 5, y: 3)) - - let quadratic = QuadraticCurve(p0: CGPoint(x: 5, y: 3), - p1: CGPoint(x: 4, y: 4), - p2: CGPoint(x: 3, y: 6)) - - let cubic = CubicCurve(p0: CGPoint(x: 3, y: 6), - p1: CGPoint(x: 2, y: 5), - p2: CGPoint(x: -1, y: 4), - p3: CGPoint(x: 1, y: 2)) - - XCTAssertEqual(PathComponent(curve: line).boundingBoxOfPath, BoundingBox(p1: CGPoint(x: 1, y: 2), p2: CGPoint(x: 5, y: 3))) - XCTAssertEqual(PathComponent(curve: quadratic).boundingBoxOfPath, BoundingBox(p1: CGPoint(x: 3, y: 3), p2: CGPoint(x: 5, y: 6))) - XCTAssertEqual(PathComponent(curve: cubic).boundingBoxOfPath, BoundingBox(p1: CGPoint(x: -1, y: 2), p2: CGPoint(x: 3, y: 6))) - XCTAssertEqual(PathComponent(curves: [line, quadratic, cubic]).boundingBoxOfPath, BoundingBox(p1: CGPoint(x: -1, y: 2), p2: CGPoint(x: 5, y: 6))) + // quadratic: middle control point at (2, 4) is above the actual curve; tight y max is 2 + let quadratic = QuadraticCurve(p0: CGPoint(x: 0, y: 0), + p1: CGPoint(x: 2, y: 4), + p2: CGPoint(x: 4, y: 0)) + XCTAssertEqual(PathComponent(curve: quadratic).boundingBoxOfPath, quadratic.boundingBox) + XCTAssertEqual(PathComponent(curve: quadratic).boundingBoxOfPath, + BoundingBox(p1: CGPoint(x: 0, y: 0), p2: CGPoint(x: 4, y: 2))) + + // contiguous chain: tight box uses each curve's exact bounding box + let line = LineSegment(p0: CGPoint(x: 1, y: 2), p1: CGPoint(x: 5, y: 3)) + let quadratic2 = QuadraticCurve(p0: CGPoint(x: 5, y: 3), p1: CGPoint(x: 4, y: 4), p2: CGPoint(x: 3, y: 6)) + let cubic = CubicCurve(p0: CGPoint(x: 3, y: 6), p1: CGPoint(x: 2, y: 5), p2: CGPoint(x: -1, y: 4), p3: CGPoint(x: 1, y: 2)) + XCTAssertEqual(PathComponent(curve: line).boundingBoxOfPath, line.boundingBox) + XCTAssertEqual(PathComponent(curve: quadratic2).boundingBoxOfPath, quadratic2.boundingBox) + XCTAssertEqual(PathComponent(curve: cubic).boundingBoxOfPath, cubic.boundingBox) + XCTAssertEqual(PathComponent(curves: [line, quadratic2, cubic]).boundingBoxOfPath, + BoundingBox(first: line.boundingBox, second: BoundingBox(first: quadratic2.boundingBox, second: cubic.boundingBox))) } func testOffset() { diff --git a/BezierKit/BezierKitTests/PathTests.swift b/BezierKit/BezierKitTests/PathTests.swift index a5072db2..0e620112 100644 --- a/BezierKit/BezierKitTests/PathTests.swift +++ b/BezierKit/BezierKitTests/PathTests.swift @@ -687,7 +687,7 @@ class PathTests: XCTestCase { control2: CGPoint(x: 212.02163105179878, y: 108.14905966376985)) let path = Path(cgPath: cgPath) - XCTAssertFalse(path.boundingBox.contains(point)) // the point is not even in the bounding box of the path! + XCTAssertFalse(path.boundingBoxOfPath.contains(point)) // the point is not even in the tight bounding box of the path! XCTAssertFalse(path.contains(point, using: .evenOdd)) XCTAssertFalse(path.contains(point, using: .winding)) } @@ -919,7 +919,26 @@ class PathTests: XCTestCase { #endif + func testBoundingBox() { + // boundingBox is the loose bounding box including control points, matching CoreGraphics convention + XCTAssertEqual(Path().boundingBox, BoundingBox.empty) + let quad1 = QuadraticCurve(p0: CGPoint(x: 1, y: 2), + p1: CGPoint(x: 2, y: 4), + p2: CGPoint(x: 3, y: 2)) + let quad2 = QuadraticCurve(p0: CGPoint(x: 3, y: 2), + p1: CGPoint(x: 2, y: 0), + p2: CGPoint(x: 1, y: 2)) + // control point of quad1 reaches y=4 and control point of quad2 reaches y=0, + // even though neither curve actually reaches those extremes + let path1 = Path(curve: quad1) + XCTAssertEqual(path1.boundingBox, BoundingBox(p1: CGPoint(x: 1, y: 2), p2: CGPoint(x: 3, y: 4))) + let path2 = Path(components: [PathComponent(curve: quad1), + PathComponent(curve: quad2)]) + XCTAssertEqual(path2.boundingBox, BoundingBox(p1: CGPoint(x: 1, y: 0), p2: CGPoint(x: 3, y: 4))) + } + func testBoundingBoxOfPath() { + // boundingBoxOfPath is the tight (exact) bounding box, matching CoreGraphics convention XCTAssertEqual(Path().boundingBoxOfPath, BoundingBox.empty) let quad1 = QuadraticCurve(p0: CGPoint(x: 1, y: 2), p1: CGPoint(x: 2, y: 4), @@ -927,11 +946,12 @@ class PathTests: XCTestCase { let quad2 = QuadraticCurve(p0: CGPoint(x: 3, y: 2), p1: CGPoint(x: 2, y: 0), p2: CGPoint(x: 1, y: 2)) + // quad1 extremum at t=0.5: y=3 (not 4); quad2 extremum at t=0.5: y=1 (not 0) let path1 = Path(curve: quad1) - XCTAssertEqual(path1.boundingBoxOfPath, BoundingBox(p1: CGPoint(x: 1, y: 2), p2: CGPoint(x: 3, y: 4))) + XCTAssertEqual(path1.boundingBoxOfPath, BoundingBox(p1: CGPoint(x: 1, y: 2), p2: CGPoint(x: 3, y: 3))) let path2 = Path(components: [PathComponent(curve: quad1), PathComponent(curve: quad2)]) - XCTAssertEqual(path2.boundingBoxOfPath, BoundingBox(p1: CGPoint(x: 1, y: 0), p2: CGPoint(x: 3, y: 4))) + XCTAssertEqual(path2.boundingBoxOfPath, BoundingBox(p1: CGPoint(x: 1, y: 1), p2: CGPoint(x: 3, y: 3))) } #if !os(WASI) diff --git a/BezierKit/Library/Path+Projection.swift b/BezierKit/Library/Path+Projection.swift index d500f533..179c56ec 100644 --- a/BezierKit/Library/Path+Projection.swift +++ b/BezierKit/Library/Path+Projection.swift @@ -17,7 +17,7 @@ public extension Path { private func searchForClosestLocation(to point: CGPoint, maximumDistance: CGFloat, requireBest: Bool) -> (point: CGPoint, location: IndexedPathLocation)? { // sort the components by proximity to avoid searching distant components later on let tuples: [ComponentTuple] = self.components.enumerated().map { i, component in - let boundingBox = component.boundingBox + let boundingBox = component.boundingBoxOfPath let upper = boundingBox.upperBoundOfDistance(to: point) return (component: component, index: i, upperBound: upper) }.sorted(by: { $0.upperBound < $1.upperBound }) diff --git a/BezierKit/Library/Path.swift b/BezierKit/Library/Path.swift index c9590818..5341fd8a 100644 --- a/BezierKit/Library/Path.swift +++ b/BezierKit/Library/Path.swift @@ -81,11 +81,11 @@ open class Path: NSObject, @unchecked Sendable { return self.components.isEmpty // components are not allowed to be empty } + /// the smallest bounding box completely enclosing the path, including its control points. public var boundingBox: BoundingBox { return self.lock.sync { self._boundingBox } } - /// the smallest bounding box completely enclosing the points of the path, includings its control points. public var boundingBoxOfPath: BoundingBox { return self.lock.sync { self._boundingBoxOfPath } } @@ -132,7 +132,7 @@ open class Path: NSObject, @unchecked Sendable { } public func intersections(with other: Path, accuracy: CGFloat = BezierKit.defaultIntersectionAccuracy) -> [PathIntersection] { - guard self.boundingBox.overlaps(other.boundingBox) else { + guard self.boundingBoxOfPath.overlaps(other.boundingBoxOfPath) else { return [] } var intersections: [PathIntersection] = [] @@ -339,7 +339,7 @@ open class Path: NSObject, @unchecked Sendable { var owner: PathComponent? for outer in outerComponents.keys { if let owner = owner { - guard outer.boundingBox.intersection(owner.boundingBox) == outer.boundingBox else { continue } + guard outer.boundingBoxOfPath.intersection(owner.boundingBoxOfPath) == outer.boundingBoxOfPath else { continue } } if outer.contains(component.startingPoint, using: rule) { owner = outer diff --git a/BezierKit/Library/PathComponent+WindingCount.swift b/BezierKit/Library/PathComponent+WindingCount.swift index ac2858e4..e9804aa8 100644 --- a/BezierKit/Library/PathComponent+WindingCount.swift +++ b/BezierKit/Library/PathComponent+WindingCount.swift @@ -103,7 +103,7 @@ internal extension PathComponent { } func windingCount(at point: CGPoint) -> Int { - guard self.isClosed, self.boundingBox.contains(point) else { + guard self.isClosed, self.boundingBoxOfPath.contains(point) else { return 0 } var windingCount: Int = 0 diff --git a/BezierKit/Library/PathComponent.swift b/BezierKit/Library/PathComponent.swift index 3ff51495..de482afc 100644 --- a/BezierKit/Library/PathComponent.swift +++ b/BezierKit/Library/PathComponent.swift @@ -29,14 +29,14 @@ open class PathComponent: NSObject, Reversible, Transformable, @unchecked Sendab private var _hash: Int? - private lazy var _boundingBoxOfPath: BoundingBox = { - var boundingBoxOfPath = BoundingBox.empty + private lazy var _boundingBox: BoundingBox = { + var boundingBox = BoundingBox.empty points.withUnsafeBufferPointer { buffer in for point in buffer { - boundingBoxOfPath.union(point) + boundingBox.union(point) } } - return boundingBoxOfPath + return boundingBox }() internal var bvh: BoundingBoxHierarchy { @@ -238,12 +238,12 @@ open class PathComponent: NSObject, Reversible, Transformable, @unchecked Sendab return self.curves.reduce(0.0) { $0 + $1.length() } } - public var boundingBox: BoundingBox { + public var boundingBoxOfPath: BoundingBox { return self.bvh.boundingBox } - public var boundingBoxOfPath: BoundingBox { - return self.lock.sync { _boundingBoxOfPath } + public var boundingBox: BoundingBox { + return self.lock.sync { _boundingBox } } public var isClosed: Bool { From a8babfb7bdf198ae41d7d3f690ae2abf2d7b98ee Mon Sep 17 00:00:00 2001 From: Holmes Futrell Date: Fri, 24 Apr 2026 17:19:59 -0700 Subject: [PATCH 2/2] revise tests and doc comments per review feedback - PathComponentTests: swap test names and update call sites, keeping original test content (no new added cases) - PathTests: same rename/update approach; add CGPath-backed assertions to directly verify both properties match CoreGraphics convention - Doc comments: match CoreGraphics header wording verbatim for both boundingBox and boundingBoxOfPath on Path and PathComponent Co-Authored-By: Claude Sonnet 4.6 --- .../BezierKitTests/PathComponentTests.swift | 53 ++++++++----------- BezierKit/BezierKitTests/PathTests.swift | 28 +++++----- BezierKit/Library/Path.swift | 3 +- BezierKit/Library/PathComponent.swift | 2 + 4 files changed, 38 insertions(+), 48 deletions(-) diff --git a/BezierKit/BezierKitTests/PathComponentTests.swift b/BezierKit/BezierKitTests/PathComponentTests.swift index d3d15ee2..b725ee0f 100644 --- a/BezierKit/BezierKitTests/PathComponentTests.swift +++ b/BezierKit/BezierKitTests/PathComponentTests.swift @@ -20,40 +20,31 @@ class PathComponentTests: XCTestCase { } func testBoundingBox() { - // line segments: tight == loose, so boundingBox matches the union of endpoints - let p = PathComponent(curves: [line1, line2]) - XCTAssertEqual(p.boundingBox, BoundingBox(min: CGPoint(x: 1.0, y: -1.0), max: CGPoint(x: 13.0, y: 5.0))) - // a quadratic whose middle control point extends outside the actual curve — - // boundingBox (loose) includes the control point, unlike boundingBoxOfPath (tight) - let quadratic = QuadraticCurve(p0: CGPoint(x: 0, y: 0), - p1: CGPoint(x: 2, y: 4), - p2: CGPoint(x: 4, y: 0)) - XCTAssertEqual(PathComponent(curve: quadratic).boundingBox, BoundingBox(p1: CGPoint(x: 0, y: 0), p2: CGPoint(x: 4, y: 4))) + let point1 = CGPoint(x: 3, y: -2) + let pointComponent = PathComponent(points: [point1], orders: [0]) + XCTAssertEqual(pointComponent.boundingBox, BoundingBox(p1: CGPoint(x: 3, y: -2), p2: CGPoint(x: 3, y: -2))) + + let line = LineSegment(p0: CGPoint(x: 1, y: 2), + p1: CGPoint(x: 5, y: 3)) + + let quadratic = QuadraticCurve(p0: CGPoint(x: 5, y: 3), + p1: CGPoint(x: 4, y: 4), + p2: CGPoint(x: 3, y: 6)) + + let cubic = CubicCurve(p0: CGPoint(x: 3, y: 6), + p1: CGPoint(x: 2, y: 5), + p2: CGPoint(x: -1, y: 4), + p3: CGPoint(x: 1, y: 2)) + + XCTAssertEqual(PathComponent(curve: line).boundingBox, BoundingBox(p1: CGPoint(x: 1, y: 2), p2: CGPoint(x: 5, y: 3))) + XCTAssertEqual(PathComponent(curve: quadratic).boundingBox, BoundingBox(p1: CGPoint(x: 3, y: 3), p2: CGPoint(x: 5, y: 6))) + XCTAssertEqual(PathComponent(curve: cubic).boundingBox, BoundingBox(p1: CGPoint(x: -1, y: 2), p2: CGPoint(x: 3, y: 6))) + XCTAssertEqual(PathComponent(curves: [line, quadratic, cubic]).boundingBox, BoundingBox(p1: CGPoint(x: -1, y: 2), p2: CGPoint(x: 5, y: 6))) } func testBoundingBoxOfPath() { - // boundingBoxOfPath is the tight (exact) bounding box of the path, matching CoreGraphics convention - let point1 = CGPoint(x: 3, y: -2) - let pointComponent = PathComponent(points: [point1], orders: [0]) - XCTAssertEqual(pointComponent.boundingBoxOfPath, BoundingBox(p1: CGPoint(x: 3, y: -2), p2: CGPoint(x: 3, y: -2))) - - // quadratic: middle control point at (2, 4) is above the actual curve; tight y max is 2 - let quadratic = QuadraticCurve(p0: CGPoint(x: 0, y: 0), - p1: CGPoint(x: 2, y: 4), - p2: CGPoint(x: 4, y: 0)) - XCTAssertEqual(PathComponent(curve: quadratic).boundingBoxOfPath, quadratic.boundingBox) - XCTAssertEqual(PathComponent(curve: quadratic).boundingBoxOfPath, - BoundingBox(p1: CGPoint(x: 0, y: 0), p2: CGPoint(x: 4, y: 2))) - - // contiguous chain: tight box uses each curve's exact bounding box - let line = LineSegment(p0: CGPoint(x: 1, y: 2), p1: CGPoint(x: 5, y: 3)) - let quadratic2 = QuadraticCurve(p0: CGPoint(x: 5, y: 3), p1: CGPoint(x: 4, y: 4), p2: CGPoint(x: 3, y: 6)) - let cubic = CubicCurve(p0: CGPoint(x: 3, y: 6), p1: CGPoint(x: 2, y: 5), p2: CGPoint(x: -1, y: 4), p3: CGPoint(x: 1, y: 2)) - XCTAssertEqual(PathComponent(curve: line).boundingBoxOfPath, line.boundingBox) - XCTAssertEqual(PathComponent(curve: quadratic2).boundingBoxOfPath, quadratic2.boundingBox) - XCTAssertEqual(PathComponent(curve: cubic).boundingBoxOfPath, cubic.boundingBox) - XCTAssertEqual(PathComponent(curves: [line, quadratic2, cubic]).boundingBoxOfPath, - BoundingBox(first: line.boundingBox, second: BoundingBox(first: quadratic2.boundingBox, second: cubic.boundingBox))) + let p = PathComponent(curves: [line1, line2]) + XCTAssertEqual(p.boundingBoxOfPath, BoundingBox(min: CGPoint(x: 1.0, y: -1.0), max: CGPoint(x: 13.0, y: 5.0))) // just the union of the two bounding boxes } func testOffset() { diff --git a/BezierKit/BezierKitTests/PathTests.swift b/BezierKit/BezierKitTests/PathTests.swift index 0e620112..47c551bc 100644 --- a/BezierKit/BezierKitTests/PathTests.swift +++ b/BezierKit/BezierKitTests/PathTests.swift @@ -920,7 +920,6 @@ class PathTests: XCTestCase { #endif func testBoundingBox() { - // boundingBox is the loose bounding box including control points, matching CoreGraphics convention XCTAssertEqual(Path().boundingBox, BoundingBox.empty) let quad1 = QuadraticCurve(p0: CGPoint(x: 1, y: 2), p1: CGPoint(x: 2, y: 4), @@ -928,30 +927,27 @@ class PathTests: XCTestCase { let quad2 = QuadraticCurve(p0: CGPoint(x: 3, y: 2), p1: CGPoint(x: 2, y: 0), p2: CGPoint(x: 1, y: 2)) - // control point of quad1 reaches y=4 and control point of quad2 reaches y=0, - // even though neither curve actually reaches those extremes let path1 = Path(curve: quad1) XCTAssertEqual(path1.boundingBox, BoundingBox(p1: CGPoint(x: 1, y: 2), p2: CGPoint(x: 3, y: 4))) let path2 = Path(components: [PathComponent(curve: quad1), PathComponent(curve: quad2)]) XCTAssertEqual(path2.boundingBox, BoundingBox(p1: CGPoint(x: 1, y: 0), p2: CGPoint(x: 3, y: 4))) + #if canImport(CoreGraphics) + let cgPath = CGMutablePath() + cgPath.move(to: CGPoint(x: 1, y: 2)) + cgPath.addQuadCurve(to: CGPoint(x: 3, y: 2), control: CGPoint(x: 2, y: 4)) + XCTAssertEqual(Path(cgPath: cgPath).boundingBox.cgRect, cgPath.boundingBox) + #endif } func testBoundingBoxOfPath() { - // boundingBoxOfPath is the tight (exact) bounding box, matching CoreGraphics convention XCTAssertEqual(Path().boundingBoxOfPath, BoundingBox.empty) - let quad1 = QuadraticCurve(p0: CGPoint(x: 1, y: 2), - p1: CGPoint(x: 2, y: 4), - p2: CGPoint(x: 3, y: 2)) - let quad2 = QuadraticCurve(p0: CGPoint(x: 3, y: 2), - p1: CGPoint(x: 2, y: 0), - p2: CGPoint(x: 1, y: 2)) - // quad1 extremum at t=0.5: y=3 (not 4); quad2 extremum at t=0.5: y=1 (not 0) - let path1 = Path(curve: quad1) - XCTAssertEqual(path1.boundingBoxOfPath, BoundingBox(p1: CGPoint(x: 1, y: 2), p2: CGPoint(x: 3, y: 3))) - let path2 = Path(components: [PathComponent(curve: quad1), - PathComponent(curve: quad2)]) - XCTAssertEqual(path2.boundingBoxOfPath, BoundingBox(p1: CGPoint(x: 1, y: 1), p2: CGPoint(x: 3, y: 3))) + #if canImport(CoreGraphics) + let cgPath = CGMutablePath() + cgPath.move(to: CGPoint(x: 1, y: 2)) + cgPath.addQuadCurve(to: CGPoint(x: 3, y: 2), control: CGPoint(x: 2, y: 4)) + XCTAssertEqual(Path(cgPath: cgPath).boundingBoxOfPath.cgRect, cgPath.boundingBoxOfPath) + #endif } #if !os(WASI) diff --git a/BezierKit/Library/Path.swift b/BezierKit/Library/Path.swift index 5341fd8a..3389295e 100644 --- a/BezierKit/Library/Path.swift +++ b/BezierKit/Library/Path.swift @@ -81,11 +81,12 @@ open class Path: NSObject, @unchecked Sendable { return self.components.isEmpty // components are not allowed to be empty } - /// the smallest bounding box completely enclosing the path, including its control points. + /// The bounding box of the path. The bounding box is the smallest rectangle completely enclosing all points in the path, including control points for Bézier cubic and quadratic curves. public var boundingBox: BoundingBox { return self.lock.sync { self._boundingBox } } + /// The path bounding box of the path. The path bounding box is the smallest rectangle completely enclosing all points in the path, *not* including control points for Bézier cubic and quadratic curves. public var boundingBoxOfPath: BoundingBox { return self.lock.sync { self._boundingBoxOfPath } } diff --git a/BezierKit/Library/PathComponent.swift b/BezierKit/Library/PathComponent.swift index de482afc..284ad32f 100644 --- a/BezierKit/Library/PathComponent.swift +++ b/BezierKit/Library/PathComponent.swift @@ -238,10 +238,12 @@ open class PathComponent: NSObject, Reversible, Transformable, @unchecked Sendab return self.curves.reduce(0.0) { $0 + $1.length() } } + /// The path bounding box of the path component. The path bounding box is the smallest rectangle completely enclosing all points in the path component, *not* including control points for Bézier cubic and quadratic curves. public var boundingBoxOfPath: BoundingBox { return self.bvh.boundingBox } + /// The bounding box of the path component. The bounding box is the smallest rectangle completely enclosing all points in the path component, including control points for Bézier cubic and quadratic curves. public var boundingBox: BoundingBox { return self.lock.sync { _boundingBox } }