Skip to content

Commit

Permalink
Merge pull request #21 from hfutrell/0.1.13-release
Browse files Browse the repository at this point in the history
0.1.13 release
  • Loading branch information
hfutrell authored Nov 28, 2018
2 parents ccaa27e + c2269d9 commit 7f28b47
Show file tree
Hide file tree
Showing 7 changed files with 163 additions and 36 deletions.
2 changes: 1 addition & 1 deletion BezierKit/BezierKitTests/AugmentedGraphTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -185,7 +185,7 @@ class AugmentedGraphTests: XCTestCase {
}

// func testMultipleIntersectionsSameElement() {
//
//
// }

}
84 changes: 76 additions & 8 deletions BezierKit/BezierKitTests/PathTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
//

import XCTest
import CoreGraphics
@testable import BezierKit

class PathTests: XCTestCase {
Expand Down Expand Up @@ -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)))
}
Expand Down Expand Up @@ -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),
Expand Down Expand Up @@ -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]))
}

}
46 changes: 38 additions & 8 deletions BezierKit/Library/AugmentedGraph.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
Expand Down Expand Up @@ -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..<lists.count {

// determine winding counts relative to the first vertex
Expand All @@ -158,6 +157,9 @@ internal class PathLinkedListRepresentation {
let cross = (side1 != side2)

if cross {
// TODO: there's an issue when corners intersect (try AugmentedGraphTests.testCornersIntersect which has this problem, even though it passes)
// the relative winding count can be decremented both for entry and for exit. This is not an issue with the even-odd winding rule, but using
// winding it can be an issue
let c = CGPoint.cross(v2, n2)
if c < 0 {
relativeWindingCount += 1
Expand All @@ -182,6 +184,22 @@ internal class PathLinkedListRepresentation {
}
}
initialWinding = -minimumWinding

let prev = lists[i][0].emitPrevious()
let a = prev.compute(0.5)
// TODO: 1.0e-5 is a magic number (just an arbitrary small value)
let b = a + 1.0e-5 * prev.normal(0.5)
//let c = a - 1.0e-5 * prev.normal(0.5)

let w1 = path.windingCount(b)
//let w2 = path.windingCount(c)

// print("w1 = \(w1)")
// print("w2 = \(w2)")

if w1 == initialWinding-1 {
initialWinding = w1
}
}
else {
initialWinding = path.windingCount(lists[i][0].emitPrevious().compute(0.5))
Expand All @@ -202,9 +220,16 @@ internal class PathLinkedListRepresentation {
guard v.isIntersection else {
return
}
let wasInside = windingCountImpliesContainment(windingCount, using: fillRule)
var wasInside = windingCountImpliesContainment(windingCount, using: fillRule)
if useRelativeWinding {
wasInside = windingCountImpliesContainment(windingCount, using: fillRule) && windingCountImpliesContainment(windingCount+1, using: fillRule)
}
windingCount = v.intersectionInfo.nextWinding
let isInside = windingCountImpliesContainment(windingCount, using: fillRule)
var isInside = windingCountImpliesContainment(windingCount, using: fillRule) || (useRelativeWinding && windingCountImpliesContainment(windingCount+1, using: fillRule))
if useRelativeWinding {
isInside = windingCountImpliesContainment(windingCount, using: fillRule) && windingCountImpliesContainment(windingCount+1, using: fillRule)
}

v.intersectionInfo.isEntry = wasInside == false && isInside == true
v.intersectionInfo.isExit = wasInside == true && isInside == false
if v.intersectionInfo.isEntry || v.intersectionInfo.isExit {
Expand Down Expand Up @@ -244,6 +269,7 @@ internal enum BooleanPathOperation {
case union
case difference
case intersection
case removeCrossings
}

internal class AugmentedGraph {
Expand Down Expand Up @@ -293,6 +319,8 @@ internal class AugmentedGraph {

private func shouldMoveForwards(fromVertex v: Vertex, forOperation operation: BooleanPathOperation, isOnFirstCurve: Bool) -> Bool {
switch operation {
case .removeCrossings:
fallthrough
case .union:
return v.intersectionInfo.isExit
case .difference:
Expand All @@ -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 }
Expand Down
48 changes: 31 additions & 17 deletions BezierKit/Library/Path.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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)
Expand All @@ -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)
}

Expand All @@ -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<Path> = Set<Path>()
Expand Down
9 changes: 7 additions & 2 deletions BezierKit/Library/PathComponent.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
}

Expand Down Expand Up @@ -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
Expand All @@ -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 {
Expand Down
6 changes: 6 additions & 0 deletions BezierKit/Library/Utils.swift
Original file line number Diff line number Diff line change
Expand Up @@ -382,6 +382,12 @@ internal class Utils {
static func pairiteration<C1, C2>(_ c1: Subcurve<C1>, _ c2: Subcurve<C2>, _ 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
}
Expand Down
4 changes: 4 additions & 0 deletions BezierKit/MacDemos/Demos.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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)!
Expand Down

0 comments on commit 7f28b47

Please sign in to comment.