Skip to content

Commit dfeb6eb

Browse files
committed
Add smoothPath method to NSBezierPath for smooth path creation
1 parent ecdf562 commit dfeb6eb

File tree

1 file changed

+121
-0
lines changed

1 file changed

+121
-0
lines changed
Lines changed: 121 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,121 @@
1+
//
2+
// NSBezierPath+SmoothPath.swift
3+
// CodeEditSourceEditor
4+
//
5+
// Created by Tom Ludwig on 12.11.24.
6+
//
7+
8+
import AppKit
9+
import SwiftUI
10+
11+
extension NSBezierPath {
12+
private func quadCurve(to endPoint: CGPoint, controlPoint: CGPoint) {
13+
guard pointIsValid(endPoint) && pointIsValid(controlPoint) else { return }
14+
15+
let startPoint = self.currentPoint
16+
let controlPoint1 = CGPoint(x: (startPoint.x + (controlPoint.x - startPoint.x) * 2.0 / 3.0),
17+
y: (startPoint.y + (controlPoint.y - startPoint.y) * 2.0 / 3.0))
18+
let controlPoint2 = CGPoint(x: (endPoint.x + (controlPoint.x - endPoint.x) * 2.0 / 3.0),
19+
y: (endPoint.y + (controlPoint.y - endPoint.y) * 2.0 / 3.0))
20+
21+
curve(to: endPoint, controlPoint1: controlPoint1, controlPoint2: controlPoint2)
22+
}
23+
24+
private func pointIsValid(_ point: CGPoint) -> Bool {
25+
return !point.x.isNaN && !point.y.isNaN
26+
}
27+
28+
// swiftlint:disable:next function_body_length
29+
static func smoothPath(_ points: [NSPoint], radius cornerRadius: CGFloat) -> NSBezierPath {
30+
// Normalizing radius to compensate for the quadraticCurve
31+
let radius = cornerRadius * 1.15
32+
33+
let path = NSBezierPath()
34+
35+
guard points.count > 1 else { return path }
36+
37+
// Calculate the initial corner start based on the first two points
38+
let initialVector = NSPoint(x: points[1].x - points[0].x, y: points[1].y - points[0].y)
39+
let initialDistance = sqrt(initialVector.x * initialVector.x + initialVector.y * initialVector.y)
40+
41+
let initialUnitVector = NSPoint(x: initialVector.x / initialDistance, y: initialVector.y / initialDistance)
42+
let initialCornerStart = NSPoint(
43+
x: points[0].x + initialUnitVector.x * radius,
44+
y: points[0].y + initialUnitVector.y * radius
45+
)
46+
47+
// Start path at the initial corner start
48+
path.move(to: points.first == points.last ? initialCornerStart : points[0])
49+
50+
for index in 1..<points.count - 1 {
51+
let p0 = points[index - 1]
52+
let p1 = points[index]
53+
let p2 = points[index + 1]
54+
55+
// Calculate vectors
56+
let vector1 = NSPoint(x: p1.x - p0.x, y: p1.y - p0.y)
57+
let vector2 = NSPoint(x: p2.x - p1.x, y: p2.y - p1.y)
58+
59+
// Calculate unit vectors and distances
60+
let distance1 = sqrt(vector1.x * vector1.x + vector1.y * vector1.y)
61+
let distance2 = sqrt(vector2.x * vector2.x + vector2.y * vector2.y)
62+
63+
// TODO: Check if .zero should get used or just skipped
64+
if distance1.isZero || distance2.isZero { continue }
65+
let unitVector1 = distance1 > 0 ? NSPoint(x: vector1.x / distance1, y: vector1.y / distance1) : NSPoint.zero
66+
let unitVector2 = distance2 > 0 ? NSPoint(x: vector2.x / distance2, y: vector2.y / distance2) : NSPoint.zero
67+
68+
// This uses the dot product formula: cos(θ) = (u1 • u2),
69+
// where u1 and u2 are unit vectors. The result will range from -1 to 1:
70+
let angleCosine = unitVector1.x * unitVector2.x + unitVector1.y * unitVector2.y
71+
72+
// If the cosine of the angle is less than 0.5 (i.e., angle > ~60 degrees),
73+
// the radius is reduced to half to avoid overlapping or excessive smoothing.
74+
let clampedRadius = angleCosine < 0.5 ? radius /** 0.5 */: radius // Adjust for sharp angles
75+
76+
// Calculate the corner start and end
77+
let cornerStart = NSPoint(x: p1.x - unitVector1.x * radius, y: p1.y - unitVector1.y * radius)
78+
let cornerEnd = NSPoint(x: p1.x + unitVector2.x * radius, y: p1.y + unitVector2.y * radius)
79+
80+
// Check if this segment is a straight line or a curve
81+
if unitVector1 != unitVector2 { // There's a change in direction, add a curve
82+
path.line(to: cornerStart)
83+
path.quadCurve(to: cornerEnd, controlPoint: p1)
84+
} else { // Straight line, just add a line
85+
path.line(to: p1)
86+
}
87+
}
88+
89+
// Handle the final segment if the path is closed
90+
if points.first == points.last, points.count > 2 {
91+
// Closing path by rounding back to the initial point
92+
let lastPoint = points[points.count - 2]
93+
let firstPoint = points[0]
94+
95+
// Calculate the vectors and unit vectors
96+
let finalVector = NSPoint(x: firstPoint.x - lastPoint.x, y: firstPoint.y - lastPoint.y)
97+
let distance = sqrt(finalVector.x * finalVector.x + finalVector.y * finalVector.y)
98+
let unitVector = NSPoint(x: finalVector.x / distance, y: finalVector.y / distance)
99+
100+
// Calculate the final corner start and initial corner end
101+
let finalCornerStart = NSPoint(
102+
x: firstPoint.x - unitVector.x * radius,
103+
y: firstPoint.y - unitVector.y * radius
104+
)
105+
106+
let initialCornerEnd = NSPoint(
107+
x: points[0].x + initialUnitVector.x * radius,
108+
y: points[0].y + initialUnitVector.y * radius
109+
)
110+
111+
path.line(to: finalCornerStart)
112+
path.quadCurve(to: initialCornerEnd, controlPoint: firstPoint)
113+
path.close()
114+
115+
} else if let lastPoint = points.last { // For open paths, just connect to the last point
116+
path.line(to: lastPoint)
117+
}
118+
119+
return path
120+
}
121+
}

0 commit comments

Comments
 (0)