Skip to content

Commit

Permalink
Merge branch '0.6.6-release' of https://github.com/hfutrell/BezierKit
Browse files Browse the repository at this point in the history
…into 0.6.6-release
  • Loading branch information
hfutrell committed Sep 8, 2020
2 parents e7db96f + 7cb8087 commit f990bd9
Show file tree
Hide file tree
Showing 9 changed files with 242 additions and 70 deletions.
146 changes: 138 additions & 8 deletions BezierKit/BezierKitTests/BezierCurveTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -237,14 +237,144 @@ class BezierCurveTests: XCTestCase {
XCTAssertEqual(i[1].t2, 0.0)
}

private func curveSelfIntersects(_ curve: CubicCurve) -> Bool {
let epsilon: CGFloat = 1.0e-5
let result = curve.selfIntersects
if result == true {
// check consistency
let intersections = curve.selfIntersections(accuracy: epsilon)
XCTAssertEqual(intersections.count, 1)
XCTAssertTrue(distance(curve.point(at: intersections[0].t1),
curve.point(at: intersections[0].t2)) < epsilon)
}
return result
}

func testCubicSelfIntersection() {
let epsilon: CGFloat = 1.0e-3
let curve = CubicCurve(p0: CGPoint(x: 0.0, y: 0.0),
p1: CGPoint(x: 2.0, y: 1.0),
p2: CGPoint(x: -1.0, y: 1.0),
p3: CGPoint(x: 1.0, y: 0.0))
let i = curve.selfIntersections(accuracy: epsilon)
XCTAssertEqual(i.count, 1, "wrong number of intersections!")
XCTAssert( distance(curve.point(at: i[0].t1), curve.point(at: i[0].t2)) < epsilon, "wrong or inaccurate intersection!" )

var curve = CubicCurve(p0: CGPoint(x: 0, y: 0),
p1: CGPoint(x: 0, y: 1),
p2: CGPoint(x: 1, y: 1),
p3: CGPoint(x: 1, y: 1))

func selfIntersectsWithEndpointMoved(to point: CGPoint) -> Bool {
var copy = curve
copy.p3 = point
return curveSelfIntersects(copy)
}

// check basic cases with no self-intersections
XCTAssertFalse(selfIntersectsWithEndpointMoved(to: CGPoint(x: 0.5, y: 2)))
XCTAssertFalse(selfIntersectsWithEndpointMoved(to: CGPoint(x: 0.5, y: 0.5)))
XCTAssertFalse(selfIntersectsWithEndpointMoved(to: CGPoint(x: 0.5, y: -1)))
XCTAssertFalse(selfIntersectsWithEndpointMoved(to: CGPoint(x: -0.5, y: -1)))
XCTAssertFalse(selfIntersectsWithEndpointMoved(to: CGPoint(x: -1, y: 0.5)))
XCTAssertFalse(selfIntersectsWithEndpointMoved(to: CGPoint(x: -1, y: 2)))

// check basic cases with self-intersections
XCTAssertTrue(selfIntersectsWithEndpointMoved(to: CGPoint(x: 0.25, y: 0.75)))
XCTAssertTrue(selfIntersectsWithEndpointMoved(to: CGPoint(x: -1, y: -0.5)))
XCTAssertTrue(selfIntersectsWithEndpointMoved(to: CGPoint(x: -0.5, y: 0.25)))

// check edge cases around (0, 0.75)
XCTAssertFalse(selfIntersectsWithEndpointMoved(to: CGPoint(x: 0, y: 0.76)))
XCTAssertFalse(selfIntersectsWithEndpointMoved(to: CGPoint(x: 0, y: 0.75)))
XCTAssertTrue(selfIntersectsWithEndpointMoved(to: CGPoint(x: 0, y: 0.74)))

// check for edge cases around (-1, 0)
XCTAssertFalse(selfIntersectsWithEndpointMoved(to: CGPoint(x: -1.01, y: 0)))
XCTAssertFalse(selfIntersectsWithEndpointMoved(to: CGPoint(x: -1, y: 0)))
XCTAssertTrue(selfIntersectsWithEndpointMoved(to: CGPoint(x: -0.99, y: 0)))

// check for edge cases around (-0.5, 0.58)
XCTAssertFalse(selfIntersectsWithEndpointMoved(to: CGPoint(x: -0.5, y: -0.59)))
XCTAssertTrue(selfIntersectsWithEndpointMoved(to: CGPoint(x: -0.5, y: -0.58)))

// check for edge cases around (0,0)
XCTAssertTrue(selfIntersectsWithEndpointMoved(to: CGPoint(x: 0, y: 0)))
XCTAssertFalse(selfIntersectsWithEndpointMoved(to: CGPoint(x: 0.01, y: 0)))
XCTAssertTrue(selfIntersectsWithEndpointMoved(to: CGPoint(x: 0, y: 0.01)))
XCTAssertTrue(selfIntersectsWithEndpointMoved(to: CGPoint(x: -0.01, y: 0)))
XCTAssertFalse(selfIntersectsWithEndpointMoved(to: CGPoint(x: 0, y: -0.01)))

// check for edge cases around (1,1)
XCTAssertFalse(selfIntersectsWithEndpointMoved(to: CGPoint(x: 1, y: 1)))
XCTAssertFalse(selfIntersectsWithEndpointMoved(to: CGPoint(x: 0.95, y: 0.9991)))
XCTAssertTrue(selfIntersectsWithEndpointMoved(to: CGPoint(x: 0.95, y: 0.9993)))
XCTAssertFalse(selfIntersectsWithEndpointMoved(to: CGPoint(x: 0.95, y: 0.9995)))

// check degenerate case where all points equal
let point = CGPoint(x: 3, y: 4)
let degenerateCurve = CubicCurve(p0: point, p1: point, p2: point, p3: point)
XCTAssertFalse(curveSelfIntersects(degenerateCurve))

// check line segment case
let lineSegment = CubicCurve(lineSegment: LineSegment(p0: CGPoint(x: 1, y: 2), p1: CGPoint(x: 3, y: 4)))
XCTAssertFalse(curveSelfIntersects(lineSegment))
}

func testCubicSelfIntersectionEdgeCase() {
// this curve nearly has a "cusp" which causes `reduce()` to fail
// this failure could prevent detection of the self-intersection in practice
let curve = CubicCurve(p0: CGPoint(x: 0.6699848912467168, y: 0.6276580745456783),
p1: CGPoint(x: 0.3985029248079961, y: 0.6770972104768092),
p2: CGPoint(x: 0.6414685401578772, y: 0.8591306876578386),
p3: CGPoint(x: 0.4385385980761747, y: 0.3866255870526274))
XCTAssertTrue(curveSelfIntersects(curve))
}

private func generateRandomCurves(count: Int, selfIntersect: Bool, reseed: Int? = nil) -> [CubicCurve] {
if let reseed = reseed {
srand48(reseed) // seed with zero so that "random" values are actually the same across test runs
}
func randomPoint() -> CGPoint {
let x = CGFloat(drand48())
let y = CGFloat(drand48())
return CGPoint(x: x, y: y)
}
func randomCurve() -> CubicCurve {
return CubicCurve(p0: randomPoint(),
p1: randomPoint(),
p2: randomPoint(),
p3: randomPoint())
}
var curves: [CubicCurve] = []
while curves.count < count {
let curve = randomCurve()
if curve.selfIntersects == selfIntersect {
curves.append(curve)
}
}
return curves
}

func testCubicSelfIntersectionsPerformance1() {
// test the performance of `selfIntersections` when the curves DO NOT self-intersect
// -Onone 0.046 seconds
// -Os 0.04 seconds
let dataCount = 100000
let curves = generateRandomCurves(count: dataCount, selfIntersect: false, reseed: 0)
self.measure {
var count = 0
for curve in curves {
count += curve.selfIntersections(accuracy: 1.0e-5).count
}
XCTAssertEqual(count, 0)
}
}

func testCubicSelfIntersectionsPerformance2() {
// test the performance of `selfIntersections` when the curves self-intersect
// -Onone 0.911 seconds
// -Os 0.129 seconds
let dataCount = 1000
let curves = generateRandomCurves(count: dataCount, selfIntersect: true, reseed: 1)
self.measure {
var count = 0
for curve in curves {
count += curve.selfIntersections(accuracy: 1.0e-5).count
}
XCTAssertEqual(count, dataCount)
}
}
}
16 changes: 16 additions & 0 deletions BezierKit/BezierKitTests/CubicCurveTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -156,6 +156,22 @@ class CubicCurveTests: XCTestCase {
XCTAssert(BezierKitTestHelpers.curve(s, matchesCurve: c, overInterval: Interval(start: t1, end: t2), tolerance: epsilon))
}

func testSplitFromToSameLocation() {
// when splitting with same `from` and `to` parameter, we should get a point back.
// but if we aren't careful round-off error will give us something slightly different.
let cubic = CubicCurve(p0: CGPoint(x: 0.041630344771878214, y: 0.45449244472862915),
p1: CGPoint(x: 0.8348172181669149, y: 0.33598603014520023),
p2: CGPoint(x: 0.5654894035661364, y: 0.001766912391744313),
p3: CGPoint(x: 0.18758951699996018, y: 0.9904340799376641))
let t: CGFloat = 0.920134
let result = cubic.split(from: t, to: t)
let expectedPoint = cubic.point(at: t)
XCTAssertEqual(result.p0, expectedPoint)
XCTAssertEqual(result.p1, expectedPoint)
XCTAssertEqual(result.p2, expectedPoint)
XCTAssertEqual(result.p3, expectedPoint)
}

func testSplitContinuous() {
// if I call split(from: a, to: b) and split(from: b, to: c)
// then the two subcurves should be continuous. However, from lack of precision that might not happen unless we are careful!
Expand Down
2 changes: 1 addition & 1 deletion BezierKit/BezierKitTests/LineSegmentTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -148,7 +148,7 @@ class LineSegmentTests: XCTestCase {

func testSelfIntersects() {
let l = LineSegment(p0: CGPoint(x: 3.0, y: 4.0), p1: CGPoint(x: 5.0, y: 6.0))
XCTAssertFalse(l.selfIntersects()) // lines never self-intersect
XCTAssertFalse(l.selfIntersects) // lines never self-intersect
}

func testSelfIntersections() {
Expand Down
10 changes: 10 additions & 0 deletions BezierKit/BezierKitTests/UtilsTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -115,4 +115,14 @@ class UtilsTests: XCTestCase {
XCTAssertEqual([1, 3, 1].sortedAndUniqued(), [1, 3])
XCTAssertEqual([1, 2, 4, 5, 5, 6].sortedAndUniqued(), [1, 2, 4, 5, 6])
}

func testMap() {
XCTAssertEqual(Utils.map(5, 4, 6, 8, 12), 10, "midpoint of [4, 6] should map to midpoint of [8, 12] (which is 10)")
XCTAssertEqual(Utils.map(0.75, 0, 1, 4, 8), 7, "75% the way between 0 and 1 should map to 75% between 4 and 8 (which is 7)")
// might fail for precision reasons
let tStart: CGFloat = 0.16559884114811005
let tEnd: CGFloat = 0.45268493225341283
XCTAssertEqual(Utils.map(0, 0, 1, tStart, tEnd), tStart, "start of first interval (0) should map to start of second interval exactly (tStart)")
XCTAssertEqual(Utils.map(1, 0, 1, tStart, tEnd), tEnd, "end of first interval (1) should map to end of second interval exactly (tEnd)")
}
}
89 changes: 63 additions & 26 deletions BezierKit/Library/BezierCurve+Intersection.swift
Original file line number Diff line number Diff line change
Expand Up @@ -22,18 +22,18 @@ public extension BezierCurve {
func selfIntersections() -> [Intersection] {
return self.selfIntersections(accuracy: BezierKit.defaultIntersectionAccuracy)
}
func selfIntersects() -> Bool {
return self.selfIntersects(accuracy: BezierKit.defaultIntersectionAccuracy)
}
func selfIntersects(accuracy: CGFloat) -> Bool {
return !self.selfIntersections(accuracy: accuracy).isEmpty
}
func intersects(_ line: LineSegment) -> Bool {
return !self.intersections(with: line).isEmpty
}
func intersects(_ curve: BezierCurve, accuracy: CGFloat) -> Bool {
return !self.intersections(with: curve, accuracy: accuracy).isEmpty
}
var selfIntersects: Bool {
return false
}
func selfIntersections(accuracy: CGFloat) -> [Intersection] {
return []
}
}

private func coincidenceCheck<U: BezierCurve, T: BezierCurve>(_ curve1: U, _ curve2: T, accuracy: CGFloat) -> [Intersection]? {
Expand Down Expand Up @@ -171,23 +171,42 @@ internal func helperIntersectsCurveLine<U>(_ curve: U, _ line: LineSegment, reve

// MARK: - extensions to support intersection

extension NonlinearBezierCurve {
public func intersections(with line: LineSegment) -> [Intersection] {
return helperIntersectsCurveLine(self, line)
}
public func intersections(with curve: BezierCurve, accuracy: CGFloat) -> [Intersection] {
switch curve.order {
case 3:
return helperIntersectsCurveCurve(Subcurve(curve: self), Subcurve(curve: curve as! CubicCurve), accuracy: accuracy)
case 2:
return helperIntersectsCurveCurve(Subcurve(curve: self), Subcurve(curve: curve as! QuadraticCurve), accuracy: accuracy)
case 1:
return helperIntersectsCurveLine(self, curve as! LineSegment)
default:
fatalError("unsupported")
extension CubicCurve {
public var selfIntersects: Bool {
let d1 = self.p1 - self.p0
let d2 = self.p2 - self.p0
// https://pomax.github.io/bezierinfo/#canonical
// we'll use cramer's rule to find a matrix M that maps d1 -> (1, 0) and d2 -> (0, 1)
// then compute the transform to canonical form as [[0, 1], [1, 1]] * M
let a = d1.x
let c = d1.y
let b = d2.x
let d = d2.y
let det = a * d - b * c
guard det != 0 else { return false }
let d3 = self.p3 - self.p0
// find the coordinates of the last point in canonical form
let x = (1 / det) * (-c * d3.x + a * d3.y)
let y = (1 / det) * ((d - c) * d3.x + (a - b) * d3.y)
// use the coordinates of the last point to determine if any self-intersections exist
guard x < 1 else { return false }
let xSquared = x * x
let cuspEdge = (-xSquared + 2 * x + 3) / 4
guard y < cuspEdge else { return false }
if x <= 0 {
let loopAtTZeroEdge = (-xSquared + 3 * x) / 3
guard y >= loopAtTZeroEdge else { return false }
} else {
let loopAtTOneEdge = (sqrt(3 * (4 * x - xSquared)) - x) / 2
guard y >= loopAtTOneEdge else { return false }
}
return true
}
public func selfIntersections(accuracy: CGFloat) -> [Intersection] {
guard self.selfIntersects else {
// call to `selfIntersects` is much faster than actually locating points of intersection, so check this first
return []
}
let reduced = self.reduce()
// "simple" curves cannot intersect with their direct
// neighbour, so for each segment X we check whether
Expand All @@ -197,6 +216,15 @@ extension NonlinearBezierCurve {
if len > 0 {
for i in 0..<len {
let left = reduced[i]
if reduced[i+1].curve.simple == false {
// this codepath is rarely needed (about 0.1% of the time)
// because `reduce()` should return simple curves
// but sometimes does return non-simple curves due to `BezierKit.reduceStepSize`
let result = helperIntersectsCurveCurve(left, reduced[i+1], accuracy: accuracy).filter { $0.t1 < 1 && $0.t1 != $0.t2 }
if result.isEmpty == false {
results += result
}
}
for j in i+2..<reduced.count {
results += helperIntersectsCurveCurve(left, reduced[j], accuracy: accuracy)
}
Expand All @@ -206,9 +234,21 @@ extension NonlinearBezierCurve {
}
}

public extension QuadraticCurve {
func selfIntersections(accuracy: CGFloat) -> [Intersection] {
return []
extension NonlinearBezierCurve {
public func intersections(with line: LineSegment) -> [Intersection] {
return helperIntersectsCurveLine(self, line)
}
public func intersections(with curve: BezierCurve, accuracy: CGFloat) -> [Intersection] {
switch curve.order {
case 3:
return helperIntersectsCurveCurve(Subcurve(curve: self), Subcurve(curve: curve as! CubicCurve), accuracy: accuracy)
case 2:
return helperIntersectsCurveCurve(Subcurve(curve: self), Subcurve(curve: curve as! QuadraticCurve), accuracy: accuracy)
case 1:
return helperIntersectsCurveLine(self, curve as! LineSegment)
default:
fatalError("unsupported")
}
}
}

Expand Down Expand Up @@ -298,7 +338,4 @@ public extension LineSegment {
}
return [Intersection(t1: t1, t2: t2)]
}
func selfIntersections(accuracy: CGFloat) -> [Intersection] {
return []
}
}
2 changes: 1 addition & 1 deletion BezierKit/Library/BezierCurve.swift
Original file line number Diff line number Diff line change
Expand Up @@ -317,7 +317,7 @@ public protocol BezierCurve: BoundingBoxProtocol, Transformable, Reversible {
func lookupTable(steps: Int) -> [CGPoint]
func project(_ point: CGPoint) -> (point: CGPoint, t: CGFloat)
// intersection routines
func selfIntersects(accuracy: CGFloat) -> Bool
var selfIntersects: Bool { get }
func selfIntersections(accuracy: CGFloat) -> [Intersection]
func intersects(_ line: LineSegment) -> Bool
func intersects(_ curve: BezierCurve, accuracy: CGFloat) -> Bool
Expand Down
25 changes: 6 additions & 19 deletions BezierKit/Library/CubicCurve.swift
Original file line number Diff line number Diff line change
Expand Up @@ -162,25 +162,12 @@ public struct CubicCurve: NonlinearBezierCurve, Equatable {

public func split(from t1: CGFloat, to t2: CGFloat) -> CubicCurve {
guard t1 != 0.0 || t2 != 1.0 else { return self }
// compute the coordinates of a new curve where t' = t1 + (t2 - t1) * t
// see 'Deriving new hull coordinates' https://pomax.github.io/bezierinfo/#matrixsplit
// the coefficients q_xy represent the entry at the xth row and yth column of the matrix Q
// using a computer algebra system is helpful here
// compute p1
let t1 = Double(t1)
let t2 = Double(t2)
let q10 = CGFloat(1 - 2*t1 - t2 + t1*t1 + 2*t1*t2 - t1*t1*t2)
let q11 = CGFloat(t2 + 2*t1 + 3*t1*t1*t2 - 2*t1*t1 - 4*t1*t2)
let q12 = CGFloat(t1*t1 - 3*t1*t1*t2 + 2*t1*t2)
let q13 = CGFloat(t1*t1*t2)
let p1 = q10 * self.p0 + q11 * self.p1 + q12 * self.p2 + q13 * self.p3
// compute p2 (notice that this just flips the role of t1 and t2 from the computation of p1)
let q20 = CGFloat(1 - 2*t2 - t1 + t2*t2 + 2*t1*t2 - t1*t2*t2)
let q21 = CGFloat(t1 + 2*t2 + 3*t1*t2*t2 - 2*t2*t2 - 4*t1*t2)
let q22 = CGFloat(t2*t2 - 3*t1*t2*t2 + 2*t1*t2)
let q23 = CGFloat(t1*t2*t2)
let p2 = q20 * self.p0 + q21 * self.p1 + q22 * self.p2 + q23 * self.p3
return CubicCurve(p0: self.point(at: CGFloat(t1)), p1: p1, p2: p2, p3: self.point(at: CGFloat(t2)))
let k = (t2 - t1) / 3.0
let p0 = self.point(at: t1)
let p3 = self.point(at: t2)
let p1 = p0 + k * self.derivative(at: t1)
let p2 = p3 - k * self.derivative(at: t2)
return CubicCurve(p0: p0, p1: p1, p2: p2, p3: p3)
}

public func split(at t: CGFloat) -> (left: CubicCurve, right: CubicCurve) {
Expand Down
15 changes: 5 additions & 10 deletions BezierKit/Library/QuadraticCurve.swift
Original file line number Diff line number Diff line change
Expand Up @@ -101,16 +101,11 @@ public struct QuadraticCurve: NonlinearBezierCurve, Equatable {

public func split(from t1: CGFloat, to t2: CGFloat) -> QuadraticCurve {
guard t1 != 0.0 || t2 != 1.0 else { return self }
// compute the coordinates of a new curve where t' = t1 + (t2 - t1) * t
// the coefficients q_xy represent the entry at the xth row and yth column of the matrix Q
// see 'Deriving new hull coordinates' https://pomax.github.io/bezierinfo/#matrixsplit
let t1 = Double(t1)
let t2 = Double(t2)
let q10 = CGFloat(1 - t1 - t2 + t1*t2)
let q11 = CGFloat(t1 + t2 - 2*t1*t2)
let q12 = CGFloat(t1*t2)
let p1 = q10 * self.p0 + q11 * self.p1 + q12 * self.p2
return QuadraticCurve(p0: self.point(at: CGFloat(t1)), p1: p1, p2: self.point(at: CGFloat(t2)))
let k = (t2 - t1) / 2
let p0 = self.point(at: t1)
let p2 = self.point(at: t2)
let p1 = (p0 + p2) / 2 + k / 2 * (self.derivative(at: t1) - self.derivative(at: t2))
return QuadraticCurve(p0: p0, p1: p1, p2: p2)
}

public func split(at t: CGFloat) -> (left: QuadraticCurve, right: QuadraticCurve) {
Expand Down
Loading

0 comments on commit f990bd9

Please sign in to comment.