Skip to content

Commit 82e07dd

Browse files
committed
feat(Path): add CG backed Path implementation
1 parent f21a834 commit 82e07dd

2 files changed

Lines changed: 293 additions & 9 deletions

File tree

‎Sources/OpenSwiftUICore/Shape/Path.swift‎

Lines changed: 187 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -534,7 +534,30 @@ public struct Path: Equatable, LosslessStringConvertible, @unchecked Sendable {
534534
/// If `eoFill` is true, this method uses the even-odd rule to define which
535535
/// points are inside the path. Otherwise, it uses the non-zero rule.
536536
public func contains(_ p: CGPoint, eoFill: Bool = false) -> Bool {
537-
_openSwiftUIUnimplementedFailure()
537+
switch storage {
538+
case .empty:
539+
return false
540+
case let .rect(rect):
541+
return rect.contains(p)
542+
case let .ellipse(rect):
543+
return ellipseContains(in: rect, contains: p)
544+
case let .roundedRect(rect):
545+
#if canImport(Darwin)
546+
return rect.contains(p)
547+
#else
548+
// FIXME
549+
let path = retainRBPath()
550+
defer { path.release() }
551+
return path.contains(point: p, eoFill: false)
552+
#endif
553+
case .stroked, .trimmed:
554+
_openSwiftUIUnreachableCode()
555+
case let .path(box):
556+
let path = box.retainRBPath()
557+
// TBA
558+
defer { path.release() }
559+
return path.contains(point: p, eoFill: eoFill)
560+
}
538561
}
539562

540563
package func contains(
@@ -552,7 +575,12 @@ public struct Path: Equatable, LosslessStringConvertible, @unchecked Sendable {
552575
eoFill: Bool = false,
553576
origin: CGPoint = .zero
554577
) -> BitVector64 {
555-
_openSwiftUIUnimplementedFailure()
578+
// FIXME:
579+
// The RenderBox shim currently declares the batched mask result as Bool.
580+
// Use scalar containment to preserve every bit until that ABI is corrected.
581+
points.mapBool {
582+
contains(CGPoint(x: $0.x - origin.x, y: $0.y - origin.y), eoFill: eoFill)
583+
}
556584
}
557585

558586
/// An element of a path.
@@ -587,7 +615,28 @@ public struct Path: Equatable, LosslessStringConvertible, @unchecked Sendable {
587615

588616
/// Calls `body` with each element in the path.
589617
public func forEach(_ body: (Path.Element) -> Void) {
590-
_openSwiftUIUnimplementedFailure()
618+
#if canImport(CoreGraphics)
619+
// FIXME: Use RB API instead of CG API
620+
cgPath.applyWithBlock { element in
621+
let element = element.pointee
622+
switch element.type {
623+
case .moveToPoint:
624+
body(.move(to: element.points[0]))
625+
case .addLineToPoint:
626+
body(.line(to: element.points[0]))
627+
case .addQuadCurveToPoint:
628+
body(.quadCurve(to: element.points[1], control: element.points[0]))
629+
case .addCurveToPoint:
630+
body(.curve(to: element.points[2], control1: element.points[0], control2: element.points[1]))
631+
case .closeSubpath:
632+
body(.closeSubpath)
633+
@unknown default:
634+
break
635+
}
636+
}
637+
#else
638+
_openSwiftUIPlatformUnimplementedFailure()
639+
#endif
591640
}
592641

593642
/// Returns a stroked copy of the path using `style` to define how the
@@ -814,7 +863,18 @@ extension Path {
814863
_ path: Path,
815864
transform: CGAffineTransform = .identity
816865
) {
817-
_openSwiftUIUnimplementedFailure()
866+
// FIXME: Use RB instead of CG
867+
if case .empty = storage {
868+
self = path.applying(transform)
869+
} else if !path.isEmpty {
870+
#if canImport(CoreGraphics)
871+
let result = cgPath.mutableCopy()!
872+
result.addPath(path.cgPath, transform: transform)
873+
self = Path(result)
874+
#else
875+
_openSwiftUIPlatformUnimplementedFailure()
876+
#endif
877+
}
818878
}
819879

820880
public var currentPoint: CGPoint? {
@@ -875,7 +935,20 @@ extension Path {
875935
}
876936

877937
package mutating func formTrivialUnion(_ path: Path) {
878-
_openSwiftUIUnimplementedWarning()
938+
// TBA
939+
guard !path.isEmpty else {
940+
return
941+
}
942+
if let lhs = roundedRect(), let rhs = path.roundedRect() {
943+
if lhs.contains(rhs) {
944+
return
945+
}
946+
if rhs.contains(lhs) {
947+
self = path
948+
return
949+
}
950+
}
951+
addPath(path)
879952
}
880953

881954
public func applying(_ transform: CGAffineTransform) -> Path {
@@ -891,8 +964,16 @@ extension Path {
891964
return Path(ellipseIn: rect.applying(transform))
892965
case let .roundedRect(fixedRoundedRect) where transform.isRectilinear:
893966
return Path(storage: .roundedRect(fixedRoundedRect.applying(transform)))
967+
case .stroked, .trimmed:
968+
_openSwiftUIUnreachableCode()
894969
default:
895-
_openSwiftUIUnimplementedFailure()
970+
// TBA
971+
#if canImport(CoreGraphics)
972+
var transform = transform
973+
return Path(cgPath.copy(using: &transform)!)
974+
#else
975+
_openSwiftUIPlatformUnimplementedFailure()
976+
#endif
896977
}
897978
}
898979

@@ -904,12 +985,109 @@ extension Path {
904985
}
905986

906987
func mapPoints(_ body: (inout [CGPoint]) -> ()) -> Path {
907-
// TODO
908-
_openSwiftUIUnimplementedWarning()
909-
return self
988+
// TBA
989+
switch storage {
990+
case .empty:
991+
return self
992+
case let .rect(rect):
993+
var points = rect.cornerPoints
994+
body(&points)
995+
if let rect = CGRect(exactCornerPoints: points) {
996+
return Path(rect)
997+
}
998+
#if canImport(CoreGraphics)
999+
let result = CGMutablePath()
1000+
result.move(to: points[0])
1001+
for point in points[1..<4] {
1002+
result.addLine(to: point)
1003+
}
1004+
result.closeSubpath()
1005+
return Path(result)
1006+
#else
1007+
_openSwiftUIPlatformUnimplementedFailure()
1008+
#endif
1009+
case let .ellipse(rect):
1010+
var points = rect.cornerPoints
1011+
body(&points)
1012+
if let mapped = CGRect(exactCornerPoints: points),
1013+
mapped.width == rect.width,
1014+
mapped.height == rect.height {
1015+
return Path(ellipseIn: mapped)
1016+
}
1017+
case let .roundedRect(rect):
1018+
var points = rect.rect.cornerPoints
1019+
body(&points)
1020+
if let mapped = CGRect(exactCornerPoints: points),
1021+
mapped.width == rect.rect.width,
1022+
mapped.height == rect.rect.height {
1023+
return Path(storage: .roundedRect(FixedRoundedRect(
1024+
mapped,
1025+
cornerSize: rect.cornerSize,
1026+
style: rect.style
1027+
)))
1028+
}
1029+
case .stroked, .trimmed:
1030+
_openSwiftUIUnreachableCode()
1031+
case .path:
1032+
break
1033+
}
1034+
#if canImport(CoreGraphics)
1035+
var points: [CGPoint] = []
1036+
forEach { element in
1037+
switch element {
1038+
case let .move(point), let .line(point):
1039+
points.append(point)
1040+
case let .quadCurve(point, control):
1041+
points.append(contentsOf: [point, control])
1042+
case let .curve(point, control1, control2):
1043+
points.append(contentsOf: [point, control1, control2])
1044+
case .closeSubpath:
1045+
break
1046+
}
1047+
}
1048+
body(&points)
1049+
let result = CGMutablePath()
1050+
var index = 0
1051+
forEach { element in
1052+
switch element {
1053+
case .move:
1054+
result.move(to: points[index])
1055+
index += 1
1056+
case .line:
1057+
result.addLine(to: points[index])
1058+
index += 1
1059+
case .quadCurve:
1060+
result.addQuadCurve(to: points[index], control: points[index + 1])
1061+
index += 2
1062+
case .curve:
1063+
result.addCurve(to: points[index], control1: points[index + 1], control2: points[index + 2])
1064+
index += 3
1065+
case .closeSubpath:
1066+
result.closeSubpath()
1067+
}
1068+
}
1069+
return Path(result)
1070+
#else
1071+
_openSwiftUIPlatformUnimplementedFailure()
1072+
#endif
9101073
}
9111074
}
9121075

1076+
private func ellipseContains(in rect: CGRect, contains point: CGPoint) -> Bool {
1077+
var rect = rect
1078+
var point = point
1079+
if rect.width != rect.height {
1080+
let scale = rect.height / rect.width
1081+
rect.origin.x *= scale
1082+
rect.size.width *= scale
1083+
point.x *= scale
1084+
}
1085+
let dx = point.x - (rect.origin.x + rect.width / 2)
1086+
let dy = point.y - (rect.origin.y + rect.height / 2)
1087+
let radius = rect.width / 2
1088+
return dx * dx + dy * dy < radius * radius
1089+
}
1090+
9131091
// MARK: - RenderBox
9141092

9151093
private let temporaryPathCallbacks: UnsafePointer<ORBPath.Callbacks> = {
Lines changed: 106 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,106 @@
1+
//
2+
// PathTests.swift
3+
// OpenSwiftUICoreTests
4+
5+
#if canImport(CoreGraphics)
6+
import CoreGraphics
7+
#endif
8+
import Foundation
9+
@_spi(ForOpenSwiftUIOnly) @testable import OpenSwiftUICore
10+
import OpenSwiftUITestsSupport
11+
import Testing
12+
13+
@Suite(.tags(.aigc))
14+
struct PathTests {
15+
@Test(arguments: [0, 1, 64, 65])
16+
func containmentPreservesMaskAndOrigin(count: Int) {
17+
let path = Path(CGRect(x: 0, y: 0, width: 10, height: 10))
18+
var points = Array(repeating: CGPoint(x: 25, y: 35), count: count)
19+
if count > 0 {
20+
points[0] = CGPoint(x: 35, y: 35)
21+
}
22+
let mask = path.contains(points: points, origin: CGPoint(x: 20, y: 30))
23+
#expect(mask.rawValue == (count < 2 ? 0 : 0xffff_ffff_ffff_fffe))
24+
}
25+
26+
@Test
27+
func ellipseExcludesBoundary() {
28+
let path = Path(ellipseIn: CGRect(x: 10, y: 20, width: 40, height: 20))
29+
#expect(path.contains(CGPoint(x: 30, y: 30)))
30+
#expect(!path.contains(CGPoint(x: 10, y: 30)))
31+
#expect(!path.contains(CGPoint(x: 50, y: 30)))
32+
#expect(!path.contains(CGPoint(x: 11, y: 21)))
33+
}
34+
35+
@Test
36+
func coordinateConversionUpdatesPath() {
37+
var transform = ViewTransform()
38+
transform.appendCoordinateSpace(name: "path")
39+
transform.appendTranslation(CGSize(width: 3, height: 4))
40+
var path = Path(CGRect(x: 1, y: 2, width: 10, height: 20))
41+
42+
path.convert(to: .named("path"), transform: transform)
43+
#expect(path.boundingRect == CGRect(x: -2, y: -2, width: 10, height: 20))
44+
45+
path.convert(from: .named("path"), transform: transform)
46+
#expect(path.boundingRect == CGRect(x: 1, y: 2, width: 10, height: 20))
47+
}
48+
49+
#if canImport(CoreGraphics)
50+
@Test(arguments: [false, true])
51+
func fillRule(eoFill: Bool) {
52+
let outline = CGMutablePath()
53+
outline.addRect(CGRect(x: 0, y: 0, width: 100, height: 100))
54+
outline.addRect(CGRect(x: 25, y: 25, width: 50, height: 50))
55+
let path = Path(outline)
56+
let points = [CGPoint(x: 10, y: 10), CGPoint(x: 50, y: 50), CGPoint(x: 110, y: 50)]
57+
58+
#expect(path.contains(points[0], eoFill: eoFill))
59+
#expect(path.contains(points[1], eoFill: eoFill) == !eoFill)
60+
#expect(!path.contains(points[2], eoFill: eoFill))
61+
#expect(path.contains(points: points, eoFill: eoFill).rawValue == (eoFill ? 0b001 : 0b011))
62+
}
63+
64+
@Test(arguments: [false, true])
65+
func transformedCurvePreservesControlsAndOwnership(useAffineTransform: Bool) {
66+
let path: Path = {
67+
let outline = CGMutablePath()
68+
outline.move(to: CGPoint(x: 0, y: 0))
69+
outline.addQuadCurve(to: CGPoint(x: 10, y: 20), control: CGPoint(x: 5, y: 30))
70+
outline.addCurve(to: CGPoint(x: 40, y: 50), control1: CGPoint(x: 20, y: 60), control2: CGPoint(x: 30, y: 70))
71+
outline.closeSubpath()
72+
return Path(outline)
73+
}()
74+
let mapped: Path
75+
if useAffineTransform {
76+
mapped = path.applying(CGAffineTransform(translationX: 100, y: 200))
77+
} else {
78+
mapped = path.mapPoints { points in
79+
for index in points.indices {
80+
points[index].x += 100
81+
points[index].y += 200
82+
}
83+
}
84+
}
85+
var elements: [Path.Element] = []
86+
mapped.forEach { elements.append($0) }
87+
#expect(elements == [
88+
.move(to: CGPoint(x: 100, y: 200)),
89+
.quadCurve(to: CGPoint(x: 110, y: 220), control: CGPoint(x: 105, y: 230)),
90+
.curve(to: CGPoint(x: 140, y: 250), control1: CGPoint(x: 120, y: 260), control2: CGPoint(x: 130, y: 270)),
91+
.closeSubpath,
92+
])
93+
}
94+
95+
@Test
96+
func pathUnionKeepsDisjointRegions() {
97+
var path = Path(CGRect(x: 0, y: 0, width: 10, height: 10))
98+
let original = path
99+
path.formTrivialUnion(Path(CGRect(x: 20, y: 0, width: 10, height: 10)))
100+
#expect(path.contains(CGPoint(x: 5, y: 5)))
101+
#expect(!path.contains(CGPoint(x: 15, y: 5)))
102+
#expect(path.contains(CGPoint(x: 25, y: 5)))
103+
#expect(original.boundingRect == CGRect(x: 0, y: 0, width: 10, height: 10))
104+
}
105+
#endif
106+
}

0 commit comments

Comments
 (0)