diff --git a/BezierKit/BezierKitTests/AugmentedGraphTests.swift b/BezierKit/BezierKitTests/AugmentedGraphTests.swift index d7e5ac5e..c154d98b 100644 --- a/BezierKit/BezierKitTests/AugmentedGraphTests.swift +++ b/BezierKit/BezierKitTests/AugmentedGraphTests.swift @@ -185,7 +185,7 @@ class AugmentedGraphTests: XCTestCase { } // func testMultipleIntersectionsSameElement() { -// +// // } } diff --git a/BezierKit/BezierKitTests/PathTests.swift b/BezierKit/BezierKitTests/PathTests.swift index 74da4451..ec9aa4df 100644 --- a/BezierKit/BezierKitTests/PathTests.swift +++ b/BezierKit/BezierKitTests/PathTests.swift @@ -7,6 +7,7 @@ // import XCTest +import CoreGraphics @testable import BezierKit class PathTests: XCTestCase { @@ -116,20 +117,15 @@ class PathTests: XCTestCase { } func testIntersects() { - - // TODO: improved unit tests ... currently this test is very lax and allows duplicated intersections - let circleCGPath = CGMutablePath() - circleCGPath.addEllipse(in: CGRect(origin: CGPoint(x: 2.0, y: 3.0), size: CGSize(width: 2.0, height: 2.0))) - + let circleCGPath = CGPath(ellipseIn: CGRect(x: 2.0, y: 3.0, width: 2.0, height: 2.0), transform: nil) let circlePath = Path(cgPath: circleCGPath) // a circle centered at (3, 4) with radius 2 - let rectangleCGPath = CGMutablePath() - rectangleCGPath.addRect(CGRect(origin: CGPoint(x: 3.0, y: 4.0), size: CGSize(width: 2.0, height: 2.0))) - + let rectangleCGPath = CGPath(rect: CGRect(x: 3.0, y: 4.0, width: 2.0, height: 2.0), transform: nil) let rectanglePath = Path(cgPath: rectangleCGPath) let intersections = rectanglePath.intersects(path: circlePath).map { rectanglePath.point(at: $0.indexedPathLocation1 ) } + XCTAssertEqual(intersections.count, 2) XCTAssert(intersections.contains(CGPoint(x: 4.0, y: 4.0))) XCTAssert(intersections.contains(CGPoint(x: 3.0, y: 5.0))) } @@ -410,6 +406,50 @@ class PathTests: XCTestCase { XCTAssertEqual(circle.subtracting(biggerCircle).subpaths.count, 0) } + func testSubtractingEdgeCase1() { + // this is a specific edge case test of `subtracting`. There was an issue where if a path element intersected at the exact border between + // two elements on the other path it would count as two intersections. The winding count would then be incremented twice on the way in + // but only once on the way out. So the entrance would be recognized but the exit not recognized. + + let rectangle = Path(cgPath: CGPath(rect: CGRect(x: -1, y: -1, width: 4, height: 3), transform: nil)) + let circle = Path(cgPath: CGPath(ellipseIn: CGRect(x: 0, y: 0, width: 4, height: 4), transform: nil)) + + // the circle intersects the rect at (0,2) and (3, 0.26792) ... the last number being exactly 2 - sqrt(3) + let difference = rectangle.subtracting(circle) + XCTAssertEqual(difference.subpaths.count, 1) + XCTAssertFalse(difference.contains(CGPoint(x: 2.0, y: 2.0))) + } + + func testSubtractingEdgeCase2() { + + // this unit test demosntrates an issue that came up in development where the logic for the winding direction + // when corners intersect was not quite correct. + + let square1 = Path(cgPath: CGPath(rect: CGRect(x: 0.0, y: 0.0, width: 2.0, height: 2.0), transform: nil)) + let square2CGPath = CGMutablePath() + square2CGPath.move(to: CGPoint.zero) + square2CGPath.addLine(to: CGPoint(x: 1.0, y: -1.0)) + square2CGPath.addLine(to: CGPoint(x: 2.0, y: 0.0)) + square2CGPath.addLine(to: CGPoint(x: 1.0, y: 1.0)) + square2CGPath.closeSubpath() + + let square2 = Path(cgPath: square2CGPath) + let result = square1.subtracting(square2) + + let expectedResultCGPath = CGMutablePath() + expectedResultCGPath.move(to: CGPoint.zero) + expectedResultCGPath.addLine(to: CGPoint(x: 1.0, y: 1.0)) + expectedResultCGPath.addLine(to: CGPoint(x: 2.0, y: 0.0)) + expectedResultCGPath.addLine(to: CGPoint(x: 2.0, y: 2.0)) + expectedResultCGPath.addLine(to: CGPoint(x: 0.0, y: 2.0)) + expectedResultCGPath.closeSubpath() + + let expectedResult = Path(cgPath: expectedResultCGPath) + + XCTAssertEqual(result.subpaths.count, expectedResult.subpaths.count) + XCTAssertTrue(componentsEqualAsideFromElementOrdering(result.subpaths[0], expectedResult.subpaths[0])) + } + func testCrossingsRemoved() { let points: [CGPoint] = [ CGPoint(x: 0, y: 0), @@ -456,4 +496,32 @@ class PathTests: XCTestCase { XCTAssertTrue(componentsEqualAsideFromElementOrdering(result.subpaths[0], square.subpaths[0])) } + func testCrossingsRemovedEdgeCase() { + // this is an edge cases which caused difficulty in practice + // the contour, which intersects at (1,1) creates two squares, one with -1 winding count + // the other with +1 winding count + // incorrect implementation of this algorithm previously interpretted + // the crossing as an entry / exit, which would completely cull off the square with +1 count + + let points = [CGPoint(x: 0, y: 1), + CGPoint(x: 2, y: 1), + CGPoint(x: 2, y: 2), + CGPoint(x: 1, y: 2), + CGPoint(x: 1, y: 0), + CGPoint(x: 0, y: 0)] + + let cgPath = CGMutablePath() + cgPath.addLines(between: points) + cgPath.closeSubpath() + + let contour = Path(cgPath: cgPath) + XCTAssertEqual(contour.windingCount(CGPoint(x: 0.5, y: 0.5)), -1) // winding count at center of one square region + XCTAssertEqual( contour.windingCount(CGPoint(x: 1.5, y: 1.5)), 1) // winding count at center of other square region + + let crossingsRemoved = contour.crossingsRemoved() + + XCTAssertEqual(crossingsRemoved.subpaths.count, 1) + XCTAssertTrue(componentsEqualAsideFromElementOrdering(crossingsRemoved.subpaths[0], contour.subpaths[0])) + } + } diff --git a/BezierKit/Library/AugmentedGraph.swift b/BezierKit/Library/AugmentedGraph.swift index 8f161952..ffa2e59b 100644 --- a/BezierKit/Library/AugmentedGraph.swift +++ b/BezierKit/Library/AugmentedGraph.swift @@ -75,11 +75,9 @@ internal class PathLinkedListRepresentation { var list = self.lists[location.componentIndex] - if location.t == 0 { - // this vertex needs to replace the start vertex of the element - insertIntersectionVertex(v, replacingVertexAtStartOfElementIndex: location.elementIndex, inList: &list) - } - else if location.t == 1 { + assert(location.t != 0, "intersects are assumed pre-processed to have a t=1 intersection at the previous path element instead!") + + if location.t == 1 { // this vertex needs to replace the end vertex of the element insertIntersectionVertex(v, replacingVertexAtStartOfElementIndex: Utils.mod(location.elementIndex+1, list.count), inList: &list) } @@ -134,7 +132,8 @@ internal class PathLinkedListRepresentation { } fileprivate func markEntryExit(_ path: Path, _ nonCrossingComponents: inout [PathComponent], useRelativeWinding: Bool = false) { - let fillRule = PathFillRule.winding + let fillRule: PathFillRule = useRelativeWinding ? .winding : .evenOdd +// let fillRule = PathFillRule.winding for i in 0.. Bool { switch operation { + case .removeCrossings: + fallthrough case .union: return v.intersectionInfo.isExit case .difference: @@ -309,6 +337,8 @@ internal class AugmentedGraph { } var pathComponents: [PathComponent] = [] switch operation { + case .removeCrossings: + pathComponents += nonCrossingComponents1 // TODO: hmm7 case .union: pathComponents += nonCrossingComponents1.filter { path2.contains(anyPointOnComponent($0)) == false } pathComponents += nonCrossingComponents2.filter { path1.contains(anyPointOnComponent($0)) == false } diff --git a/BezierKit/Library/Path.swift b/BezierKit/Library/Path.swift index 15622c9f..79644d66 100644 --- a/BezierKit/Library/Path.swift +++ b/BezierKit/Library/Path.swift @@ -45,6 +45,10 @@ internal func windingCountImpliesContainment(_ count: Int, using rule: PathFillR return mutablePath.copy()! }() + @objc public var isEmpty: Bool { + return self.subpaths.isEmpty // components are not allowed to be empty + } + public lazy var boundingBox: BoundingBox = { return self.subpaths.reduce(BoundingBox.empty) { BoundingBox(first: $0, second: $1.boundingBox) @@ -186,6 +190,12 @@ internal func windingCountImpliesContainment(_ count: Int, using rule: PathFillR } } + @objc(offsetWithDistance:) public func offset(distance d: CGFloat) -> Path { + return Path(subpaths: self.subpaths.map { + $0.offset(distance: d) + }) + } + @objc public func contains(_ point: CGPoint, using rule: PathFillRule = .winding) -> Bool { let count = self.windingCount(point) return windingCountImpliesContainment(count, using: rule) @@ -202,6 +212,12 @@ internal func windingCountImpliesContainment(_ count: Int, using rule: PathFillR } @objc(unionedWithPath:threshold:) public func `union`(_ other: Path, threshold: CGFloat=BezierKit.defaultIntersectionThreshold) -> Path { + guard self.isEmpty == false else { + return other + } + guard other.isEmpty == false else { + return self + } return self.performBooleanOperation(.union, withPath: other, threshold: threshold) } @@ -210,25 +226,23 @@ internal func windingCountImpliesContainment(_ count: Int, using rule: PathFillR } @objc(crossingsRemovedWithThreshold:) public func crossingsRemoved(threshold: CGFloat=BezierKit.defaultIntersectionThreshold) -> Path { - assert(self.subpaths.count <= 1, "todo: support multi-component paths") - guard self.subpaths.count > 0 else { - return Path() - } - let component = self.subpaths[0] - let intersections = component.intersects(threshold: threshold).compactMap { (i: PathComponentIntersection) -> PathIntersection? in - guard i.indexedComponentLocation1.elementIndex <= i.indexedComponentLocation2.elementIndex else { - return nil + // assert(self.subpaths.count <= 1, "todo: support multi-component paths") + return self.subpaths.reduce(Path(), { result, component in + // TODO: this won't work properly if components intersect + let intersections = component.intersects(threshold: threshold).map {(i: PathComponentIntersection) -> PathIntersection in + return PathIntersection(indexedPathLocation1: IndexedPathLocation(componentIndex: 0, elementIndex: i.indexedComponentLocation1.elementIndex, t: i.indexedComponentLocation1.t), + indexedPathLocation2: IndexedPathLocation(componentIndex: 0, elementIndex: i.indexedComponentLocation2.elementIndex, t: i.indexedComponentLocation2.t)) } - return PathIntersection(indexedPathLocation1: IndexedPathLocation(componentIndex: 0, elementIndex: i.indexedComponentLocation1.elementIndex, t: i.indexedComponentLocation1.t), - indexedPathLocation2: IndexedPathLocation(componentIndex: 0, elementIndex: i.indexedComponentLocation2.elementIndex, t: i.indexedComponentLocation2.t)) - } - if intersections.count == 0 { - return self - } - let augmentedGraph = AugmentedGraph(path1: self, path2: self, intersections: intersections) - return augmentedGraph.booleanOperation(.union) + guard intersections.isEmpty == false else { + return Path(subpaths: result.subpaths + [component]) + } + let singleComponentPath = Path(subpaths: [component]) + let augmentedGraph = AugmentedGraph(path1: singleComponentPath, path2: singleComponentPath, intersections: intersections) + let path = augmentedGraph.booleanOperation(.removeCrossings) + return Path(subpaths: result.subpaths + path.subpaths) + }) } - + @objc public func disjointSubpaths() -> [Path] { var paths: Set = Set() diff --git a/BezierKit/Library/PathComponent.swift b/BezierKit/Library/PathComponent.swift index 552d50f3..b5ec059f 100644 --- a/BezierKit/Library/PathComponent.swift +++ b/BezierKit/Library/PathComponent.swift @@ -50,6 +50,7 @@ public final class PathComponent: NSObject, NSCoding { }() public init(curves: [BezierCurve]) { + precondition(curves.isEmpty == false, "Path components are by definition non-empty.") self.curves = curves } @@ -92,9 +93,13 @@ public final class PathComponent: NSObject, NSCoding { let c1 = o1 as! BezierCurve let c2 = o2 as! BezierCurve let elementIntersections = c1.intersects(curve: c2, threshold: threshold) - let pathComponentIntersections = elementIntersections.map { (i: Intersection) -> PathComponentIntersection in + let pathComponentIntersections = elementIntersections.compactMap { (i: Intersection) -> PathComponentIntersection? in let i1 = IndexedPathComponentLocation(elementIndex: i1, t: i.t1) let i2 = IndexedPathComponentLocation(elementIndex: i2, t: i.t2) + guard i1.t != 0.0 && i2.t != 0.0 else { + // we'll get this intersection at t=1 on the neighboring path element(s) instead + return nil + } return PathComponentIntersection(indexedComponentLocation1: i1, indexedComponentLocation2: i2) } intersections += pathComponentIntersections @@ -114,7 +119,7 @@ public final class PathComponent: NSObject, NSCoding { elementIntersections = c.intersects(threshold: threshold) } } - else { + else if i1 < i2 { // we are intersecting two distinct path elements elementIntersections = c1.intersects(curve: c2, threshold: threshold).filter { if i1 == Utils.mod(i2+1, self.curves.count) && $0.t1 == 0.0 { diff --git a/BezierKit/Library/Utils.swift b/BezierKit/Library/Utils.swift index cdc2e2eb..8960944d 100644 --- a/BezierKit/Library/Utils.swift +++ b/BezierKit/Library/Utils.swift @@ -382,6 +382,12 @@ internal class Utils { static func pairiteration(_ c1: Subcurve, _ c2: Subcurve, _ results: inout [Intersection], _ threshold: CGFloat = BezierKit.defaultIntersectionThreshold) { let c1b = c1.curve.boundingBox let c2b = c2.curve.boundingBox + + if results.count > 20 { + // TODO: better bailout conditions + return + } + if c1b.overlaps(c2b) == false { return } diff --git a/BezierKit/MacDemos/Demos.swift b/BezierKit/MacDemos/Demos.swift index f04fbb2d..3567016e 100644 --- a/BezierKit/MacDemos/Demos.swift +++ b/BezierKit/MacDemos/Demos.swift @@ -433,6 +433,10 @@ class Demos { if let mouse = demoState.lastInputLocation { //let m2 = CGPoint(x: -21.19140625, y: 131.38671875) + //let me2 = CGPoint(x: 24.34375, y: 110.703125) + + //let me3 = CGPoint(x: -7.78515625, y: 161.7265625) // seems to cause an issue because intersections[5].t = 0.99999422905833845, clamping the t values when they are appropximately 1 or 0 seems to work (but fix not applied) + // let me4 = CGPoint(x: 22.41796875, y: 168.48046875) // caused an infinite loop or graphical glitches var translation = CGAffineTransform.init(translationX: mouse.x, y: mouse.y) let cgPath2: CGPath = CTFontCreatePathForGlyph(font, glyph2, &translation)!