|
| 1 | +import Foundation |
| 2 | + |
| 3 | +public extension Geometry2D { |
| 4 | + /// Splits the geometry into two parts along the specified line. |
| 5 | + /// |
| 6 | + /// This method slices the geometry in two using a given line and passes the resulting parts |
| 7 | + /// to a closure for further transformation or arrangement. |
| 8 | + /// |
| 9 | + /// - Parameters: |
| 10 | + /// - line: The `Line2D` used to split the geometry. |
| 11 | + /// - reader: A closure that receives the two resulting geometry parts (on opposite sides of the line) |
| 12 | + /// and returns a new composed geometry. The first geometry is the side facing the clockwise |
| 13 | + /// normal of the line (right side relative to the line's direction). |
| 14 | + /// |
| 15 | + /// - Returns: A new geometry resulting from the closure. |
| 16 | + /// |
| 17 | + /// ## Example |
| 18 | + /// ```swift |
| 19 | + /// Circle(diameter: 10) |
| 20 | + /// .split(along: Line2D(point: [0, 2], direction: .x)) { a, b in |
| 21 | + /// a.colored(.red) |
| 22 | + /// b.colored(.blue) |
| 23 | + /// } |
| 24 | + /// ``` |
| 25 | + /// |
| 26 | + func split( |
| 27 | + along line: Line2D, |
| 28 | + @GeometryBuilder2D reader: @Sendable @escaping (_ right: any Geometry2D, _ left: any Geometry2D) -> any Geometry2D |
| 29 | + ) -> any Geometry2D { |
| 30 | + reader(trimmed(along: line), trimmed(along: line.flipped)) |
| 31 | + } |
| 32 | + |
| 33 | + /// Trims the geometry along the specified line, keeping only the portion on the clockwise side. |
| 34 | + /// |
| 35 | + /// This method behaves like a one-sided split: it cuts the geometry by a line and removes everything |
| 36 | + /// on the opposite side. The result is the portion of the geometry that remains on the clockwise side |
| 37 | + /// of the line (right side relative to the line's direction). |
| 38 | + /// |
| 39 | + /// - Parameter line: The `Line2D` defining the trimming boundary. |
| 40 | + /// - Returns: A new geometry containing only the portion on the clockwise side of the line. |
| 41 | + /// |
| 42 | + /// ## Example |
| 43 | + /// ```swift |
| 44 | + /// Circle(diameter: 10) |
| 45 | + /// .trimmed(along: Line2D.y) // Keeps the right half |
| 46 | + /// ``` |
| 47 | + /// |
| 48 | + func trimmed(along line: Line2D) -> any Geometry2D { |
| 49 | + measuringBounds { geometry, box in |
| 50 | + let mask = buildTrimMask(for: box, along: line) |
| 51 | + geometry.intersecting { mask } |
| 52 | + } |
| 53 | + } |
| 54 | +} |
| 55 | + |
| 56 | +private func buildTrimMask(for box: BoundingBox2D, along line: Line2D) -> Polygon { |
| 57 | + let margin = 1.0 |
| 58 | + let expandedMin = box.minimum - Vector2D(margin, margin) |
| 59 | + let expandedMax = box.maximum + Vector2D(margin, margin) |
| 60 | + |
| 61 | + let corners = [ |
| 62 | + expandedMin, |
| 63 | + Vector2D(expandedMax.x, expandedMin.y), |
| 64 | + expandedMax, |
| 65 | + Vector2D(expandedMin.x, expandedMax.y) |
| 66 | + ] |
| 67 | + |
| 68 | + // Project corners onto the line direction to find extent |
| 69 | + let projections = corners.map { corner in |
| 70 | + (corner - line.point) ⋅ line.direction.unitVector |
| 71 | + } |
| 72 | + let minT = projections.min()! - margin |
| 73 | + let maxT = projections.max()! + margin |
| 74 | + |
| 75 | + // Line endpoints within the bounding box |
| 76 | + let lineStart = line.point(at: minT) |
| 77 | + let lineEnd = line.point(at: maxT) |
| 78 | + |
| 79 | + // Extend perpendicular to the line (clockwise normal side) |
| 80 | + let normal = line.direction.clockwiseNormal.unitVector |
| 81 | + let extent = (expandedMax - expandedMin).magnitude + margin |
| 82 | + |
| 83 | + // Build a quad covering the clockwise side of the line |
| 84 | + return Polygon([ |
| 85 | + lineStart, |
| 86 | + lineEnd, |
| 87 | + lineEnd + normal * extent, |
| 88 | + lineStart + normal * extent |
| 89 | + ]) |
| 90 | +} |
0 commit comments