diff --git a/BezierKit.podspec b/BezierKit.podspec index 31db2d72..5ec53477 100644 --- a/BezierKit.podspec +++ b/BezierKit.podspec @@ -5,7 +5,7 @@ Pod::Spec.new do |s| s.name = "BezierKit" - s.version = "0.1.13" + s.version = "0.1.14" s.summary = "comprehensive Bezier curve library written in Swift" s.homepage = "https://github.com/hfutrell/BezierKit" s.license = "MIT" diff --git a/BezierKit/BezierKitTests/CubicBezierCurveTests.swift b/BezierKit/BezierKitTests/CubicBezierCurveTests.swift index 3180e9b4..a6f0ed19 100644 --- a/BezierKit/BezierKitTests/CubicBezierCurveTests.swift +++ b/BezierKit/BezierKitTests/CubicBezierCurveTests.swift @@ -360,6 +360,19 @@ class CubicBezierCurveTests: XCTestCase { XCTAssertEqual(i[2].t1, 1.0, accuracy: epsilon) } + func testCubicIntersectsLineEdgeCase() { + // this example caused issues in practice because it has a discriminant that is nearly equal to zero (but not exactly) + let c = CubicBezierCurve(p0: CGPoint(x: 3, y: 1), + p1: CGPoint(x: 3, y: 1.5522847498307932), + p2: CGPoint(x: 2.5522847498307932, y: 2), + p3: CGPoint(x: 2, y: 2)) + let l = LineSegment(p0: CGPoint(x: 2, y: 2), p1: CGPoint(x: 0, y: 2)) + let i = c.intersects(curve: l) + XCTAssertEqual(i.count, 1) + XCTAssertEqual(i[0].t1, 1) + XCTAssertEqual(i[0].t2, 0) + } + // MARK: - func testEquatable() { diff --git a/BezierKit/BezierKitTests/PathTests.swift b/BezierKit/BezierKitTests/PathTests.swift index ec9aa4df..2aff8275 100644 --- a/BezierKit/BezierKitTests/PathTests.swift +++ b/BezierKit/BezierKitTests/PathTests.swift @@ -342,7 +342,7 @@ class PathTests: XCTestCase { )]) let square1 = createSquare1() let square2 = createSquare2() - let subtracted = square1.subtracting(square2) + let subtracted = square1.subtracting(square2)! XCTAssertEqual(subtracted.subpaths.count, 1) XCTAssert( componentsEqualAsideFromElementOrdering(subtracted.subpaths[0], expectedResult.subpaths[0]) @@ -364,7 +364,7 @@ class PathTests: XCTestCase { )]) let square1 = createSquare1() let square2 = createSquare2() - let unioned = square1.union(square2) + let unioned = square1.union(square2)! XCTAssertEqual(unioned.subpaths.count, 1) XCTAssert( componentsEqualAsideFromElementOrdering(unioned.subpaths[0], expectedResult.subpaths[0]) @@ -382,7 +382,7 @@ class PathTests: XCTestCase { )]) let square1 = createSquare1() let square2 = createSquare2() - let intersected = square1.intersecting(square2) + let intersected = square1.intersecting(square2)! XCTAssertEqual(intersected.subpaths.count, 1) XCTAssert( componentsEqualAsideFromElementOrdering(intersected.subpaths[0], expectedResult.subpaths[0]) @@ -394,7 +394,7 @@ class PathTests: XCTestCase { // the order of the hole is reversed so that it is not contained in the shape when using .winding fill rule let circle = Path(cgPath: CGPath(ellipseIn: CGRect(x: 0, y: 0, width: 3, height: 3), transform: nil)) let hole = Path(cgPath: CGPath(ellipseIn: CGRect(x: 1, y: 1, width: 1, height: 1), transform: nil)) - let donut = circle.subtracting(hole) + let donut = circle.subtracting(hole)! XCTAssertTrue(donut.contains(CGPoint(x: 0.5, y: 0.5), using: .winding)) // inside the donut (but not the hole) XCTAssertFalse(donut.contains(CGPoint(x: 1.5, y: 1.5), using: .winding)) // center of donut hole } @@ -403,7 +403,7 @@ class PathTests: XCTestCase { // this is a specific test of `subtracting` to ensure that if a path component is entirely contained in the subtracting path that it gets removed let circle = Path(cgPath: CGPath(ellipseIn: CGRect(x: -1, y: -1, width: 2, height: 2), transform: nil)) let biggerCircle = Path(cgPath: CGPath(ellipseIn: CGRect(x: -2, y: -2, width: 4, height: 4), transform: nil)) - XCTAssertEqual(circle.subtracting(biggerCircle).subpaths.count, 0) + XCTAssertEqual(circle.subtracting(biggerCircle)!.subpaths.count, 0) } func testSubtractingEdgeCase1() { @@ -415,7 +415,7 @@ class PathTests: XCTestCase { 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) + let difference = rectangle.subtracting(circle)! XCTAssertEqual(difference.subpaths.count, 1) XCTAssertFalse(difference.contains(CGPoint(x: 2.0, y: 2.0))) } @@ -434,7 +434,7 @@ class PathTests: XCTestCase { square2CGPath.closeSubpath() let square2 = Path(cgPath: square2CGPath) - let result = square1.subtracting(square2) + let result = square1.subtracting(square2)! let expectedResultCGPath = CGMutablePath() expectedResultCGPath.move(to: CGPoint.zero) @@ -474,7 +474,7 @@ class PathTests: XCTestCase { XCTAssertTrue(path.contains(CGPoint(x: 1.5, y: 1.25), using: .winding)) XCTAssertFalse(path.contains(CGPoint(x: 1.5, y: 1.25), using: .evenOdd)) - let result = path.crossingsRemoved() + let result = path.crossingsRemoved()! XCTAssertEqual(result.subpaths.count, 1) XCTAssertTrue(componentsEqualAsideFromElementOrdering(result.subpaths[0], expectedResult.subpaths[0])) @@ -483,7 +483,7 @@ class PathTests: XCTestCase { cgPathAlt.addLines(between: Array(points[3.. 0.0 } + let cleanPath = Path(subpaths: [PathComponent(curves: curves2)]) + + let result = cleanPath.crossingsRemoved(threshold: 1.0e-4)! + + // check that the inner loop was eliminated by checking the winding count in the middle + XCTAssertEqual(result.windingCount(CGPoint(x: 0.5, y: 1)), 1) + XCTAssertEqual(result.windingCount(CGPoint(x: 2.0, y: 1)), 1) // if the inner loop wasn't eliminated we'd have a winding count of 2 here + XCTAssertEqual(result.windingCount(CGPoint(x: 3.5, y: 1)), 1) + } + } diff --git a/BezierKit/Library/AugmentedGraph.swift b/BezierKit/Library/AugmentedGraph.swift index ffa2e59b..2c541320 100644 --- a/BezierKit/Library/AugmentedGraph.swift +++ b/BezierKit/Library/AugmentedGraph.swift @@ -131,11 +131,27 @@ internal class PathLinkedListRepresentation { self.lists = p.subpaths.map { self.createListFor(component: $0) } } - fileprivate func markEntryExit(_ path: Path, _ nonCrossingComponents: inout [PathComponent], useRelativeWinding: Bool = false) { + fileprivate func nonCrossingComponents() -> [PathComponent] { + // returns the components of this path that do not cross the path passed as the argument to markEntryExit(_:) + var result: [PathComponent] = [] + for i in 0.. Vertex { @@ -311,9 +320,9 @@ internal class AugmentedGraph { } // mark each intersection as either entry or exit let useRelativeWinding = (list1 === list2) - list1.markEntryExit(path2, &nonCrossingComponents1, useRelativeWinding: useRelativeWinding) + list1.markEntryExit(path2, useRelativeWinding: useRelativeWinding) if useRelativeWinding == false { - list2.markEntryExit(path1, &nonCrossingComponents2, useRelativeWinding: useRelativeWinding) + list2.markEntryExit(path1, useRelativeWinding: useRelativeWinding) } } @@ -330,15 +339,19 @@ internal class AugmentedGraph { } } - internal func booleanOperation(_ operation: BooleanPathOperation) -> Path { - // handle components that have no crossings + internal func booleanOperation(_ operation: BooleanPathOperation) -> Path? { + + // special cases for components which do not cross + let nonCrossingComponents1: [PathComponent] = self.list1.nonCrossingComponents() + let nonCrossingComponents2: [PathComponent] = self.list2.nonCrossingComponents() + func anyPointOnComponent(_ c: PathComponent) -> CGPoint { return c.curves[0].startingPoint } var pathComponents: [PathComponent] = [] switch operation { case .removeCrossings: - pathComponents += nonCrossingComponents1 // TODO: hmm7 + pathComponents += nonCrossingComponents1 case .union: pathComponents += nonCrossingComponents1.filter { path2.contains(anyPointOnComponent($0)) == false } pathComponents += nonCrossingComponents2.filter { path1.contains(anyPointOnComponent($0)) == false } @@ -349,7 +362,8 @@ internal class AugmentedGraph { pathComponents += nonCrossingComponents1.filter { path2.contains(anyPointOnComponent($0)) == true } pathComponents += nonCrossingComponents2.filter { path1.contains(anyPointOnComponent($0)) == true } } - // handle components that have crossings + + // handle components that have crossings (the main algorithm) var unvisitedCrossings: [Vertex] = [] list1.forEachVertex { if $0.isCrossing && shouldMoveForwards(fromVertex: $0, forOperation: operation, isOnFirstCurve: true) { @@ -366,10 +380,6 @@ internal class AugmentedGraph { var isOnFirstCurve = true var v = start repeat { - // if isOnFirstCurve && unvisitedCrossings.contains(v) == false { - // print("already visited this crossing! bailing out to avoid infinite loop! Needs debugging.") - // break - // } let movingForwards = shouldMoveForwards(fromVertex: v, forOperation: operation, isOnFirstCurve: isOnFirstCurve) unvisitedCrossings = unvisitedCrossings.filter { $0 !== v } repeat { @@ -382,24 +392,11 @@ internal class AugmentedGraph { isOnFirstCurve = !isOnFirstCurve if isOnFirstCurve && unvisitedCrossings.contains(v) == false && v !== start { - print("already visited this crossing! bailing out to avoid infinite loop! Needs debugging.") - if let last = curves.last?.endingPoint, let first = curves.first?.startingPoint, last != first { - curves.append(LineSegment(p0: last, p1: first)) // close the component before we bail out - } - break - } - - unvisitedCrossings = unvisitedCrossings.filter { $0 !== v } - - if !v.isCrossing { - print("consistency error detected -- bailing out. Needs debugging.") - v = v.intersectionInfo.neighbor! // jump back to avoid infinite loop - isOnFirstCurve = !isOnFirstCurve + return nil } } while v !== start pathComponents.append(PathComponent(curves: curves)) } - // TODO: non-deterministic behavior from usage of Set when choosing starting vertex return Path(subpaths: pathComponents) } } diff --git a/BezierKit/Library/BezierCurve.swift b/BezierKit/Library/BezierCurve.swift index 0f2ec335..c80756e5 100644 --- a/BezierKit/Library/BezierCurve.swift +++ b/BezierKit/Library/BezierCurve.swift @@ -194,7 +194,7 @@ extension BezierCurve { */ public func reduce() -> [Subcurve] { - // todo: handle degenerate case of Cubic with all zero points better! + // TODO: handle degenerate case of Cubic with all zero points better! let step: CGFloat = 0.01 var extrema: [CGFloat] = self.extrema().values diff --git a/BezierKit/Library/Path.swift b/BezierKit/Library/Path.swift index 79644d66..f68bca47 100644 --- a/BezierKit/Library/Path.swift +++ b/BezierKit/Library/Path.swift @@ -201,17 +201,17 @@ internal func windingCountImpliesContainment(_ count: Int, using rule: PathFillR return windingCountImpliesContainment(count, using: rule) } - private func performBooleanOperation(_ operation: BooleanPathOperation, withPath other: Path, threshold: CGFloat) -> Path { + private func performBooleanOperation(_ operation: BooleanPathOperation, withPath other: Path, threshold: CGFloat) -> Path? { let intersections = self.intersects(path: other, threshold: threshold) let augmentedGraph = AugmentedGraph(path1: self, path2: other, intersections: intersections) return augmentedGraph.booleanOperation(operation) } - @objc(subtractingPath:threshold:) public func subtracting(_ other: Path, threshold: CGFloat=BezierKit.defaultIntersectionThreshold) -> Path { + @objc(subtractingPath:threshold:) public func subtracting(_ other: Path, threshold: CGFloat=BezierKit.defaultIntersectionThreshold) -> Path? { return self.performBooleanOperation(.difference, withPath: other.reversed(), threshold: threshold) } - @objc(unionedWithPath:threshold:) public func `union`(_ other: Path, threshold: CGFloat=BezierKit.defaultIntersectionThreshold) -> Path { + @objc(unionedWithPath:threshold:) public func `union`(_ other: Path, threshold: CGFloat=BezierKit.defaultIntersectionThreshold) -> Path? { guard self.isEmpty == false else { return other } @@ -221,14 +221,17 @@ internal func windingCountImpliesContainment(_ count: Int, using rule: PathFillR return self.performBooleanOperation(.union, withPath: other, threshold: threshold) } - @objc(intersectedWithPath:threshold:) public func intersecting(_ other: Path, threshold: CGFloat=BezierKit.defaultIntersectionThreshold) -> Path { + @objc(intersectedWithPath:threshold:) public func intersecting(_ other: Path, threshold: CGFloat=BezierKit.defaultIntersectionThreshold) -> Path? { return self.performBooleanOperation(.intersection, withPath: other, threshold: threshold) } - @objc(crossingsRemovedWithThreshold:) public func crossingsRemoved(threshold: CGFloat=BezierKit.defaultIntersectionThreshold) -> Path { + @objc(crossingsRemovedWithThreshold:) public func crossingsRemoved(threshold: CGFloat=BezierKit.defaultIntersectionThreshold) -> Path? { // assert(self.subpaths.count <= 1, "todo: support multi-component paths") - return self.subpaths.reduce(Path(), { result, component in + return self.subpaths.reduce(Path(), { (result: Path?, component: PathComponent) -> Path? in // TODO: this won't work properly if components intersect + guard let result = result else { + return nil + } 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)) @@ -238,7 +241,9 @@ internal func windingCountImpliesContainment(_ count: Int, using rule: PathFillR } let singleComponentPath = Path(subpaths: [component]) let augmentedGraph = AugmentedGraph(path1: singleComponentPath, path2: singleComponentPath, intersections: intersections) - let path = augmentedGraph.booleanOperation(.removeCrossings) + guard let path = augmentedGraph.booleanOperation(.removeCrossings) else { + return nil + } return Path(subpaths: result.subpaths + path.subpaths) }) } diff --git a/BezierKit/Library/PathComponent.swift b/BezierKit/Library/PathComponent.swift index b5ec059f..d7154640 100644 --- a/BezierKit/Library/PathComponent.swift +++ b/BezierKit/Library/PathComponent.swift @@ -113,21 +113,23 @@ public final class PathComponent: NSObject, NSCoding { let c1 = o1 as! BezierCurve let c2 = o2 as! BezierCurve var elementIntersections: [Intersection] = [] - if i1 == i2 { + // TODO: fix behavior for `crossingsRemoved` when there are self intersections at t=0 or t=1 and re-enable + /*if i1 == i2 { // we are intersecting a path element against itself if let c = c1 as? CubicBezierCurve { elementIntersections = c.intersects(threshold: threshold) } } - else if i1 < i2 { + 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 { - return false // exclude intersections of i and i-1 at t=0 - } if i1 == Utils.mod(i2-1, self.curves.count) && $0.t1 == 1.0 { return false // exclude intersections of i and i+1 at t=1 } + if $0.t1 == 0.0 || $0.t2 == 0.0 { + // use the intersection with the prior path element at t=1 instead + return false + } return true } } diff --git a/BezierKit/Library/Utils.swift b/BezierKit/Library/Utils.swift index 8960944d..e4e31288 100644 --- a/BezierKit/Library/Utils.swift +++ b/BezierKit/Library/Utils.swift @@ -235,7 +235,7 @@ internal class Utils { let q = (2*a*a*a - 9*a*b + 27*c)/27 let q2 = q/2 let discriminant = q2*q2 + p3*p3*p3 - if discriminant < 0 { + if discriminant < -epsilon { let mp3 = -p/3 let mp33 = mp3*mp3*mp3 let r = sqrt( mp33 ) @@ -249,18 +249,18 @@ internal class Utils { let x3 = t1 * cos((phi+2*tau)/3) - a/3 return [x1, x2, x3].compactMap(clamp) } - else if discriminant == 0 { - let u1 = q2 < 0 ? crt(-q2) : -crt(q2) - let x1 = 2*u1-a/3 - let x2 = -u1 - a/3 - return [x1,x2].compactMap(clamp) - } - else { + else if discriminant > epsilon { let sd = sqrt(discriminant) let u1 = crt(-q2+sd) let v1 = crt(q2+sd) return [u1-v1-a/3].compactMap(clamp) } + else { + let u1 = q2 < 0 ? crt(-q2) : -crt(q2) + let x1 = 2*u1-a/3 + let x2 = -u1 - a/3 + return [x1,x2].compactMap(clamp) + } } else { fatalError("unsupported") diff --git a/BezierKit/MacDemos/Demos.swift b/BezierKit/MacDemos/Demos.swift index 3567016e..a7474705 100644 --- a/BezierKit/MacDemos/Demos.swift +++ b/BezierKit/MacDemos/Demos.swift @@ -432,11 +432,11 @@ 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 +// 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 (increasing precision of boolean operation appears to resolve) var translation = CGAffineTransform.init(translationX: mouse.x, y: mouse.y) let cgPath2: CGPath = CTFontCreatePathForGlyph(font, glyph2, &translation)! @@ -464,7 +464,7 @@ class Demos { // v = v.next // } while v !== first - let subtracted = path1.intersecting(path2) + let subtracted = path1.intersecting(path2) ?? path1 Draw.drawPath(context, subtracted) } }) diff --git a/README.md b/README.md index 0b1dd703..f1928fe4 100644 --- a/README.md +++ b/README.md @@ -46,7 +46,7 @@ To integrate BezierKit into your Xcode project using CocoaPods, add it to your t ```ruby target '' do - pod 'BezierKit', '>= 0.1.13' + pod 'BezierKit', '>= 0.1.14' end ```