From 4d9ebab3be07486ca1c9196bd15edbd2489eebe8 Mon Sep 17 00:00:00 2001 From: Nick Lockwood Date: Wed, 7 Aug 2024 12:06:55 +0100 Subject: [PATCH] Path CSG WIP --- Euclid.xcodeproj/project.pbxproj | 20 +- .../xcshareddata/xcschemes/Euclid.xcscheme | 2 +- Example/SceneKitViewController.swift | 21 +- Sources/Color.swift | 11 + Sources/LineSegment.swift | 6 +- Sources/Mesh.swift | 18 +- Sources/Path+CSG.swift | 300 ++++++++++++++++++ Sources/Path.swift | 17 +- Sources/Polygon+CSG.swift | 38 +++ Sources/Polygon.swift | 43 +++ Sources/Vector.swift | 12 +- Sources/Vertex.swift | 11 + Tests/PathCSGTests.swift | 80 +++++ Tests/PathTests.swift | 13 + Tests/PolygonCSGTests.swift | 38 ++- 15 files changed, 584 insertions(+), 46 deletions(-) create mode 100644 Sources/Path+CSG.swift create mode 100644 Tests/PathCSGTests.swift diff --git a/Euclid.xcodeproj/project.pbxproj b/Euclid.xcodeproj/project.pbxproj index 51f55f96..713f76a2 100644 --- a/Euclid.xcodeproj/project.pbxproj +++ b/Euclid.xcodeproj/project.pbxproj @@ -13,6 +13,8 @@ 0112D5C928EE29BB00A1C085 /* Euclid+RealityKit.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0112D5C828EE29BB00A1C085 /* Euclid+RealityKit.swift */; }; 0125478027AFD53900C442C3 /* MeshShapeTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0125477F27AFD53900C442C3 /* MeshShapeTests.swift */; }; 0128EEBB2ABA607A00E60976 /* EuclidMesh.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0128EEBA2ABA607A00E60976 /* EuclidMesh.swift */; }; + 0131216A2A9E61B500BC8683 /* Path+CSG.swift in Sources */ = {isa = PBXBuildFile; fileRef = 013121692A9E61B500BC8683 /* Path+CSG.swift */; }; + 0131216D2AA2987500BC8683 /* PolygonCSGTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0131216B2AA2979900BC8683 /* PolygonCSGTests.swift */; }; 013312DD21CA532A00626F1B /* PlaneTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 013312DC21CA532A00626F1B /* PlaneTests.swift */; }; 013499932902FB5900CED6BE /* Euclid+SIMD.swift in Sources */ = {isa = PBXBuildFile; fileRef = 013499922902FB5900CED6BE /* Euclid+SIMD.swift */; }; 0134999729043ACC00CED6BE /* RealityKitViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0134999629043ACC00CED6BE /* RealityKitViewController.swift */; }; @@ -70,7 +72,7 @@ 01E5F54923D59BF100717D58 /* BSP.swift in Sources */ = {isa = PBXBuildFile; fileRef = 01E5F54823D59BF100717D58 /* BSP.swift */; }; 01F2382023BF4160005EC9DB /* LineSegment.swift in Sources */ = {isa = PBXBuildFile; fileRef = 01F2381F23BF4160005EC9DB /* LineSegment.swift */; }; 01F2465428FD4A020071AE64 /* QuaternionTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 01F2465228FD499F0071AE64 /* QuaternionTests.swift */; }; - 01FAE7BD29744E08008DB288 /* PolygonCSGTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 01FAE7BB29744C22008DB288 /* PolygonCSGTests.swift */; }; + 01FAE7BD29744E08008DB288 /* PathCSGTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 01FAE7BB29744C22008DB288 /* PathCSGTests.swift */; }; 0A240137256A64FB00C1535C /* AngleTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0A240136256A64FB00C1535C /* AngleTests.swift */; }; 0A24013F256A671600C1535C /* Angle.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0A24013E256A671600C1535C /* Angle.swift */; }; 2B4F06BC2B981DD30025DDF2 /* ExampleVisionOSApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2B4F06BB2B981DD30025DDF2 /* ExampleVisionOSApp.swift */; }; @@ -143,6 +145,8 @@ 0112D5C828EE29BB00A1C085 /* Euclid+RealityKit.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Euclid+RealityKit.swift"; sourceTree = ""; }; 0125477F27AFD53900C442C3 /* MeshShapeTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MeshShapeTests.swift; sourceTree = ""; }; 0128EEBA2ABA607A00E60976 /* EuclidMesh.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EuclidMesh.swift; sourceTree = ""; }; + 013121692A9E61B500BC8683 /* Path+CSG.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Path+CSG.swift"; sourceTree = ""; }; + 0131216B2AA2979900BC8683 /* PolygonCSGTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PolygonCSGTests.swift; sourceTree = ""; }; 013312DC21CA532A00626F1B /* PlaneTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PlaneTests.swift; sourceTree = ""; }; 013499922902FB5900CED6BE /* Euclid+SIMD.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = "Euclid+SIMD.swift"; sourceTree = ""; }; 0134999629043ACC00CED6BE /* RealityKitViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RealityKitViewController.swift; sourceTree = ""; }; @@ -203,7 +207,7 @@ 01E5F54823D59BF100717D58 /* BSP.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BSP.swift; sourceTree = ""; }; 01F2381F23BF4160005EC9DB /* LineSegment.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LineSegment.swift; sourceTree = ""; }; 01F2465228FD499F0071AE64 /* QuaternionTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = QuaternionTests.swift; sourceTree = ""; }; - 01FAE7BB29744C22008DB288 /* PolygonCSGTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PolygonCSGTests.swift; sourceTree = ""; }; + 01FAE7BB29744C22008DB288 /* PathCSGTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PathCSGTests.swift; sourceTree = ""; }; 0A240136256A64FB00C1535C /* AngleTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AngleTests.swift; sourceTree = ""; }; 0A24013E256A671600C1535C /* Angle.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Angle.swift; sourceTree = ""; }; 2B4F06B52B981DD30025DDF2 /* ExampleVisionOS.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = ExampleVisionOS.app; sourceTree = BUILT_PRODUCTS_DIR; }; @@ -299,7 +303,6 @@ 016FAB4C21BFE7C200AF60DC /* Polygon.swift */, EA6F2218296C5A9000B530BE /* Polygon+CSG.swift */, 01E5F54823D59BF100717D58 /* BSP.swift */, - 016FAB4921BFE7C200AF60DC /* Path.swift */, 016FAB4821BFE7C200AF60DC /* Mesh.swift */, 016FAB4421BFE7C100AF60DC /* Mesh+CSG.swift */, 016FAB4E21BFE7C200AF60DC /* Mesh+Shapes.swift */, @@ -308,7 +311,9 @@ 010A63382A951165000E3306 /* Mesh+OBJ.swift */, 0157FEC32B63B1BE009033D1 /* Mesh+IO.swift */, 016FAB4D21BFE7C200AF60DC /* Plane.swift */, + 016FAB4921BFE7C200AF60DC /* Path.swift */, 0148ECA52783796A00B3F836 /* PathPoint.swift */, + 013121692A9E61B500BC8683 /* Path+CSG.swift */, 0148ECA3278378D100B3F836 /* Path+Shapes.swift */, 52A663A023857D5300FACF9D /* Line.swift */, 01F2381F23BF4160005EC9DB /* LineSegment.swift */, @@ -335,9 +340,9 @@ 0A240136256A64FB00C1535C /* AngleTests.swift */, 01D96AB423D8E36A00D0D267 /* BoundsTests.swift */, 01BA29792235E34C0088D36B /* CGPathTests.swift */, - 0101BAF425687A450096B1E7 /* CodingTests.swift */, 0188E98226ACA0040029C253 /* LineSegmentTests.swift */, 52A3852D238D6E5700BE8407 /* LineTests.swift */, + 0101BAF425687A450096B1E7 /* CodingTests.swift */, 01CBE2672775E3EE00B7ED45 /* MeshTests.swift */, 016FAB5F21BFE7CE00AF60DC /* MeshCSGTests.swift */, 010A633A2A955BE9000E3306 /* MeshExportTests.swift */, @@ -346,8 +351,9 @@ 013B5BE426923087000860DC /* MetadataTests.swift */, 016FAB5C21BFE7CD00AF60DC /* PathTests.swift */, 016FAB6021BFE7CE00AF60DC /* PathShapeTests.swift */, + 01FAE7BB29744C22008DB288 /* PathCSGTests.swift */, 016FAB5E21BFE7CE00AF60DC /* PolygonTests.swift */, - 01FAE7BB29744C22008DB288 /* PolygonCSGTests.swift */, + 0131216B2AA2979900BC8683 /* PolygonCSGTests.swift */, 013312DC21CA532A00626F1B /* PlaneTests.swift */, 014AC60D2505963800F54349 /* SceneKitTests.swift */, 016A77F92B32184A00B7AB73 /* RealityKitTests.swift */, @@ -655,6 +661,7 @@ 016FAB5621BFE7C200AF60DC /* Euclid+SceneKit.swift in Sources */, 0A24013F256A671600C1535C /* Angle.swift in Sources */, 016FAB5921BFE7C200AF60DC /* Plane.swift in Sources */, + 0131216A2A9E61B500BC8683 /* Path+CSG.swift in Sources */, 016FAB5A21BFE7C200AF60DC /* Mesh+Shapes.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; @@ -663,12 +670,13 @@ isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; files = ( - 01FAE7BD29744E08008DB288 /* PolygonCSGTests.swift in Sources */, + 01FAE7BD29744E08008DB288 /* PathCSGTests.swift in Sources */, 01F2465428FD4A020071AE64 /* QuaternionTests.swift in Sources */, 01A429FA2237A85C00C251A6 /* TextTests.swift in Sources */, 0188E98326ACA0040029C253 /* LineSegmentTests.swift in Sources */, 0A240137256A64FB00C1535C /* AngleTests.swift in Sources */, 016A77F82B2F7C7800B7AB73 /* MeshImportTests.swift in Sources */, + 0131216D2AA2987500BC8683 /* PolygonCSGTests.swift in Sources */, 52A3852E238D6E5700BE8407 /* LineTests.swift in Sources */, 016FAB6621BFE7CE00AF60DC /* PathShapeTests.swift in Sources */, 016FAB6321BFE7CE00AF60DC /* UtilityTests.swift in Sources */, diff --git a/Euclid.xcodeproj/xcshareddata/xcschemes/Euclid.xcscheme b/Euclid.xcodeproj/xcshareddata/xcschemes/Euclid.xcscheme index 3c9c39ed..936771f4 100644 --- a/Euclid.xcodeproj/xcshareddata/xcschemes/Euclid.xcscheme +++ b/Euclid.xcodeproj/xcshareddata/xcschemes/Euclid.xcscheme @@ -53,7 +53,7 @@ Bool { + guard lhs.r == rhs.r else { return lhs.r < rhs.r } + guard lhs.g == rhs.g else { return lhs.g < rhs.g } + guard lhs.b == rhs.b else { return lhs.b < rhs.b } + return lhs.a < rhs.a + } +} + extension Color: Codable { private enum CodingKeys: String, CodingKey { case r, g, b, a diff --git a/Sources/LineSegment.swift b/Sources/LineSegment.swift index 3cddd9db..1241810d 100644 --- a/Sources/LineSegment.swift +++ b/Sources/LineSegment.swift @@ -59,10 +59,8 @@ extension LineSegment: Comparable { /// Returns whether the leftmost line segment has the lower value. /// This provides a stable order when sorting collections of line segments. public static func < (lhs: LineSegment, rhs: LineSegment) -> Bool { - if lhs.start == rhs.start { - return lhs.end < rhs.end - } - return lhs.start < rhs.start + guard lhs.start == rhs.start else { return lhs.start < rhs.start } + return lhs.end < rhs.end } } diff --git a/Sources/Mesh.swift b/Sources/Mesh.swift index 611f4055..434e74bd 100644 --- a/Sources/Mesh.swift +++ b/Sources/Mesh.swift @@ -306,23 +306,9 @@ public extension Mesh { if watertightIfSet == true { return self } - var holeEdges = polygons.holeEdges, polygons = self.polygons - var precision = epsilon - while !holeEdges.isEmpty { - let merged = polygons - .insertingEdgeVertices(with: holeEdges) - .mergingVertices(withPrecision: precision) - let newEdges = merged.holeEdges - if newEdges.count >= holeEdges.count { - // No improvement - break - } - polygons = merged - holeEdges = newEdges - precision *= 10 - } + var holeEdges = polygons.holeEdges return Mesh( - unchecked: polygons, + unchecked: polygons.makeWatertight(with: &holeEdges), bounds: boundsIfSet, isConvex: false, // TODO: can makeWatertight make this false? isWatertight: holeEdges.isEmpty, diff --git a/Sources/Path+CSG.swift b/Sources/Path+CSG.swift new file mode 100644 index 00000000..ccb95dff --- /dev/null +++ b/Sources/Path+CSG.swift @@ -0,0 +1,300 @@ +// +// Path+CSG.swift +// Euclid +// +// Created by Nick Lockwood on 29/08/2023. +// Copyright © 2023 Nick Lockwood. All rights reserved. +// + +public extension Path { + /// Returns a new mesh representing the combined volume of the + /// mesh parameter and the receiver, with inner faces removed. + /// + /// +-------+ +-------+ + /// | | | | + /// | A | | | + /// | +--+----+ = | +----+ + /// +----+--+ | +----+ | + /// | B | | | + /// | | | | + /// +-------+ +-------+ + /// + /// - Parameters + /// - path: The path to form a union with. + /// - Returns: An array of paths representing the union of the input paths. + func union(_ path: Path) -> [Path] { + Self.union([self, path]) + } + + /// Form a union from multiple paths. + /// - Parameters + /// - paths: A collection of paths to be unioned. + /// - Returns: An array of paths representing the union of the input paths. + static func union(_ paths: T) -> [Path] where T.Element == Path { + switch paths.count { + case 0: + return [] + case 1: + let subpaths = paths.first!.subpaths + return subpaths.count == 1 ? subpaths : symmetricDifference(subpaths) + default: + var result = symmetricDifference(paths.first!.subpaths) + for path in paths.dropFirst() { + result = result.union(symmetricDifference(path.subpaths)) + } + return result + } + } + + /// Returns the result of subtracting the area of the path parameter from the + /// receiver. If the input path is open or does not intersect the receiver then + /// the subtract will have no effect. + /// + /// +-------+ +-------+ + /// | | | | + /// | A | | | + /// | +--+----+ = | +--+ + /// +----+--+ | +----+ + /// | B | + /// | | + /// +-------+ + /// + /// - Parameters + /// - path: The path to subtract from this one. + /// - Returns: An array of paths representing the result of the subtraction. + func subtracting(_ path: Path) -> [Path] { + Self.difference([self, path]) + } + + /// Get the difference between multiple paths. + /// - Parameters + /// - paths: An ordered collection of paths. All but the first will be subtracted from the first. + /// - Returns: An array of paths representing the difference between the input paths. + static func difference(_ paths: T) -> [Path] where T.Element == Path { + switch paths.count { + case 0: + return [] + case 1: + let subpaths = paths.first!.subpaths + return subpaths.count == 1 ? subpaths : symmetricDifference(subpaths) + default: + var result = symmetricDifference(paths.first!.subpaths) + for path in paths.dropFirst() { + result = result.clip(to: symmetricDifference(path.subpaths)) + } + return result + } + } + + /// Returns a new path reprenting only the area exclusively occupied by + /// one path or the other, but not both. If either path is open it will be clipped to the + /// area of the other. If both paths are open then both will be returned unmodified. + /// + /// +-------+ +-------+ + /// | | | | + /// | A | | | + /// | +--+----+ = | ++++----+ + /// +----+--+ | +----++++ | + /// | B | | | + /// | | | | + /// +-------+ +-------+ + /// + /// - Parameters + /// - path: The path to be XORed with this one. + /// - Returns: An array of paths representing the XOR of the input paths. + func symmetricDifference(_ path: Path) -> [Path] { + Self.symmetricDifference([self, path]) + } + + /// XOR multiple paths. + /// - Parameters + /// - paths: A collection of paths to be XORed. + /// - Returns: An array of paths representing the XOR of the input paths. + static func symmetricDifference(_ paths: T) -> [Path] where T.Element == Path { + switch paths.count { + case 0: + return [] + case 1: + let subpaths = paths.first!.subpaths + return subpaths.count == 1 ? subpaths : symmetricDifference(subpaths) + default: + var result = symmetricDifference(paths.first!.subpaths) + for path in paths.dropFirst() { + result = result.symmetricDifference(symmetricDifference(path.subpaths)) + } + return result + } + } + + // Efficiently computes the intersection of multiple paths. + // - Parameters + // - paths: A collection of paths to be intersected. + // - Returns: A new mesh representing the intersection of the meshes. +// static func intersection( +// _ meshes: T, +// isCancelled: CancellationHandler = { false } +// ) -> Mesh where T.Element == Mesh { +// let head = meshes.first ?? .empty, tail = meshes.dropFirst() +// let bounds = tail.reduce(into: head.bounds) { $0.formUnion($1.bounds) } +// if bounds.isEmpty { +// return .empty +// } +// return tail.reduce(into: head) { +// $0 = $0.intersection($1, isCancelled: isCancelled) +// } +// } + + /// Split the path along a plane. + /// - Parameter along: The ``Plane`` to split the path along. + /// - Returns: A pair of arrays representing the path fragments in front of and behind the plane respectively. + /// + /// > Note: If the plane and polygon do not intersect, one of the returned arrays will be empty. + func split(along plane: Plane) -> (front: [Path], back: [Path]) { + guard subpaths.count == 1 else { + let (front, back) = subpaths.reduce(into: (front: [Path](), back: [Path]())) { + let (front, back) = $1.split(along: plane) + $0.front += front + $0.back += back + } + return ([Path(subpaths: front)], [Path(subpaths: back)]) + } + if isClosed { + var front = [Polygon](), back = [Polygon](), coplanar = [Polygon](), id = 0 + for polygon in facePolygons() { + polygon.split(along: plane, &coplanar, &front, &back, &id) + } + return ( + front: (coplanar + front).makeWatertight().edgePaths(withOriginalPaths: [self]), + back: back.makeWatertight().edgePaths(withOriginalPaths: [self]) + ) + } + var front = [Path](), back = [Path]() + var path = [PathPoint]() + var lastComparison = PlaneComparison.coplanar + for point in points { + let comparison = point.position.compare(with: plane) + guard var last = path.last else { + path.append(point) + lastComparison = comparison + continue + } + switch comparison { + case .coplanar: + path.append(point) + case .front where lastComparison != .back, + .back where lastComparison != .front: + path.append(point) + lastComparison = comparison + case .front, .back: + if last.position.compare(with: plane) != .coplanar { + let delta = (point.position - last.position) + let length = delta.length + let direction = delta / length + guard let d = linePlaneIntersection(last.position, direction, plane) else { + assertionFailure() // Shouldn't happen + path.append(point) + continue + } + last = last.lerp(point, d / length) + path.append(last) + } + if lastComparison == .front { + front.append(Path(path)) + } else { + back.append(Path(path)) + } + path = [last, point] + lastComparison = comparison + case .spanning: + preconditionFailure() + } + } + if path.count > 1 { + if lastComparison == .back { + back.append(Path(path)) + } else { + front.append(Path(path)) + } + } + return (front, back) + } + + /// Clip path to the specified plane + /// - Parameter plane: The plane to clip the path to. + /// - Returns: An array of the path fragments that lie in front of the plane. + func clip(to plane: Plane) -> [Path] { + // TODO: avoid calculating back parts and discarding them + split(along: plane).front + } +} + +private extension Array where Element == Path { + func symmetricDifference(_ paths: [Path]) -> [Path] { + clip(to: paths) + paths.clip(to: self) + } + + func union(_ paths: [Path]) -> [Path] { + let allPaths = self + paths + var polygons = flatMap { $0.facePolygons() } + for path in paths { + for polygon in path.facePolygons() { + var inside = [Polygon](), outside = [Polygon](), id = 0 + polygon.clip(to: polygons, &inside, &outside, &id) + polygons += outside + } + } + let openPaths = filter { !$0.isClosed } + paths.filter { !$0.isClosed } + return openPaths + polygons.makeWatertight().edgePaths(withOriginalPaths: allPaths) + } + + func clip(to paths: [Path]) -> [Path] { + let rhs = paths.flatMap { $0.facePolygons() } + return flatMap { + var inside = [Polygon](), outside = [Polygon](), id = 0 + for polygon in $0.facePolygons() { + polygon.clip(to: rhs, &inside, &outside, &id) + } + return outside.makeWatertight().edgePaths(withOriginalPaths: [$0] + paths) + } + } +} + +private extension Array where Element == Polygon { + func edgePaths(withOriginalPaths paths: T) -> [Path] where T.Element == Path { + var pointMap = Dictionary(paths.flatMap { path -> [(Vector, PathPoint)] in + path.points.map { ($0.position, $0) } + }, uniquingKeysWith: { + $0.lerp($1, 0.5) + }) + var polylines = [[Vector]]() + var edges = holeEdges.sorted() + while let edge = edges.popLast() { + var polyline = [edge.start, edge.end] + while let i = edges.firstIndex(where: { + polyline.first!.isEqual(to: $0.start) || + polyline.last!.isEqual(to: $0.start) || + polyline.first!.isEqual(to: $0.end) || + polyline.last!.isEqual(to: $0.end) + }) { + let edge = edges.remove(at: i) + if polyline.first!.isEqual(to: edge.start) { + polyline.insert(edge.end, at: 0) + } else if polyline.last!.isEqual(to: edge.start) { + polyline.append(edge.end) + } else if polyline.first!.isEqual(to: edge.end) { + polyline.insert(edge.start, at: 0) + } else if polyline.last!.isEqual(to: edge.end) { + polyline.append(edge.start) + } + } + polylines.append(polyline) + } + // TODO: this is just to recover texcoords/colors - find more efficient solution + for polygon in self { + for vertex in polygon.vertices where pointMap[vertex.position] == nil { + pointMap[vertex.position] = PathPoint(vertex) + } + } + return polylines.map { Path($0.map { pointMap[$0] ?? .point($0) }) } + } +} diff --git a/Sources/Path.swift b/Sources/Path.swift index f78bdb01..23423c08 100644 --- a/Sources/Path.swift +++ b/Sources/Path.swift @@ -275,7 +275,7 @@ public extension Path { /// path point positions relative to the bounding rectangle of the path. func facePolygons(material: Mesh.Material? = nil) -> [Polygon] { guard subpaths.count <= 1 else { - return subpaths.flatMap { $0.facePolygons(material: material) } + return Polygon.symmetricDifference(subpaths.flatMap { $0.facePolygons(material: material) }) } guard let vertices = faceVertices else { return [] @@ -649,4 +649,19 @@ extension Path { } return (translated(by: -offset), offset) } + + /// Compare path with plane + func compare(with plane: Plane) -> PlaneComparison { + if let plane = self.plane, plane.isEqual(to: plane) { + return .coplanar + } + var comparison = PlaneComparison.coplanar + for point in points { + comparison = comparison.union(point.position.compare(with: plane)) + if comparison == .spanning { + break + } + } + return comparison + } } diff --git a/Sources/Polygon+CSG.swift b/Sources/Polygon+CSG.swift index f6ae123b..da7b3a3c 100644 --- a/Sources/Polygon+CSG.swift +++ b/Sources/Polygon+CSG.swift @@ -7,6 +7,44 @@ // public extension Polygon { + /// Compute a new array of polygons representing only the area occupied by one polygon or the + /// other, but not both. Polygons are not required to be coplanar - splits will occur along edge planes. + /// + /// +-------+ +-------+ + /// | | | | + /// | A | | | + /// | +--+----+ = | ++++----+ + /// +----+--+ | +----++++ | + /// | B | | | + /// | | | | + /// +-------+ +-------+ + /// + /// - Parameters + /// - other: The polygon to be XORed with this one. + /// - Returns: An array of polygons representing the XOR of the polygons. + func symmetricDifference(_ other: Polygon) -> [Polygon] { + var inside = [Polygon](), outside = [Polygon](), id = 0 + clip(to: [other], &inside, &outside, &id) + other.clip(to: [self], &inside, &outside, &id) + return outside + } + +// /// Efficiently XORs multiple polygons. +// /// - Parameters +// /// - polygons: A collection of polygons to be XORed. +// /// - Returns: An array of polygons representing the XOR of the input polygons. +// static func symmetricDifference(_ polygons: T) -> [Polygon] where T.Element == Polygon { +// guard polygons.count == 2 else { +// return Array(polygons) +// } +// let lhs = polygons.first! +// let rhs = polygons.last! +// var inside = [Polygon](), outside = [Polygon](), id = 0 +// lhs.clip(to: [rhs], &inside, &outside, &id) +// rhs.clip(to: [lhs], &inside, &outside, &id) +// return outside +// } + /// Split the polygon along a plane. /// - Parameter along: The ``Plane`` to split the polygon along. /// - Returns: A pair of arrays representing the polygon fragments in front of and behind the plane respectively. diff --git a/Sources/Polygon.swift b/Sources/Polygon.swift index ac225c16..cbf1b32f 100644 --- a/Sources/Polygon.swift +++ b/Sources/Polygon.swift @@ -523,6 +523,23 @@ public extension Polygon { return [self] } } + + /// Efficiently XORs multiple polygons. + /// - Parameters + /// - paths: A collection of paths to be XORed. + /// - Returns: An array of paths representing the XOR of the input paths. + static func symmetricDifference(_ polygons: T) -> [Polygon] where T.Element == Polygon { + let polygons = Array(polygons) + guard polygons.count == 2 else { + return polygons + } + let lhs = polygons.first! + let rhs = polygons.last! + var inside = [Polygon](), outside = [Polygon](), id = 0 + lhs.clip(to: [rhs], &inside, &outside, &id) + rhs.clip(to: [lhs], &inside, &outside, &id) + return outside + } } extension Collection where Element == LineSegment { @@ -618,6 +635,32 @@ extension Collection where Element == Polygon { return polygons } + /// Insert missing vertices and merge result until no further improvement can be made. + func makeWatertight() -> [Polygon] { + var holeEdges = self.holeEdges + return makeWatertight(with: &holeEdges) + } + + /// Insert missing vertices and merge result until no further improvement can be made. + func makeWatertight(with holeEdges: inout Set) -> [Polygon] { + var polygons = Array(self) + var precision = epsilon + while !holeEdges.isEmpty { + let merged = polygons + .insertingEdgeVertices(with: holeEdges) + .mergingVertices(withPrecision: precision) + let newEdges = merged.holeEdges + if newEdges.count >= holeEdges.count { + // No improvement + break + } + polygons = merged + holeEdges = newEdges + precision *= 10 + } + return polygons + } + /// Merge vertices with similar positions. /// - Parameter precision: The maximum distance between vertices. func mergingVertices(withPrecision precision: Double) -> [Polygon] { diff --git a/Sources/Vector.swift b/Sources/Vector.swift index 8aca9373..bb8bf298 100644 --- a/Sources/Vector.swift +++ b/Sources/Vector.swift @@ -59,16 +59,8 @@ extension Vector: Comparable { /// Returns whether the leftmost vector has the lower value. /// This provides a stable order when sorting collections of vectors. public static func < (lhs: Vector, rhs: Vector) -> Bool { - if lhs.x < rhs.x { - return true - } else if lhs.x > rhs.x { - return false - } - if lhs.y < rhs.y { - return true - } else if lhs.y > rhs.y { - return false - } + guard lhs.x == rhs.x else { return lhs.x < rhs.x } + guard lhs.y == rhs.y else { return lhs.y < rhs.y } return lhs.z < rhs.z } } diff --git a/Sources/Vertex.swift b/Sources/Vertex.swift index 8fada251..44751322 100644 --- a/Sources/Vertex.swift +++ b/Sources/Vertex.swift @@ -67,6 +67,17 @@ public struct Vertex: Hashable, Sendable { } } +extension Vertex: Comparable { + /// Returns whether the leftmost vertex has the lower value. + /// This provides a stable order when sorting collections of vertices. + public static func < (lhs: Vertex, rhs: Vertex) -> Bool { + guard lhs.position == rhs.position else { return lhs.position < rhs.position } + guard lhs.normal == rhs.normal else { return lhs.normal < rhs.normal } + guard lhs.texcoord == rhs.texcoord else { return lhs.texcoord < rhs.texcoord } + return lhs.color < rhs.color + } +} + extension Vertex: Codable { private enum CodingKeys: CodingKey { case position, normal, texcoord, color diff --git a/Tests/PathCSGTests.swift b/Tests/PathCSGTests.swift new file mode 100644 index 00000000..449e11fb --- /dev/null +++ b/Tests/PathCSGTests.swift @@ -0,0 +1,80 @@ +// +// PathCSGTests.swift +// Euclid +// +// Created by Nick Lockwood on 01/09/2023. +// Copyright © 2023 Nick Lockwood. All rights reserved. +// + +@testable import Euclid +import XCTest + +class PathCSGTests: XCTestCase { + // MARK: XOR + + func testXorCoincidingSquares() { + let a = Path.square() + let b = Path.square() + let c = a.symmetricDifference(b) + XCTAssert(c.isEmpty) + } + + func testXorAdjacentSquares() { + let a = Path.square() + let b = a.translated(by: .unitX) + let c = a.symmetricDifference(b) + XCTAssertEqual(Bounds(c), a.bounds.union(b.bounds)) + } + + func testXorOverlappingSquares() { + let a = Path.square() + let b = a.translated(by: Vector(0.5, 0, 0)) + let c = a.symmetricDifference(b) + XCTAssertEqual(Bounds(c), Bounds( + min: Vector(-0.5, -0.5, 0), + max: Vector(1.0, 0.5, 0) + )) + } + + // MARK: Plane splitting + + func testSquareSplitAlongPlane() { + let a = Path.square() + let plane = Plane(unchecked: .unitX, pointOnPlane: .zero) + let b = a.split(along: plane) + XCTAssertEqual( + Bounds(b.0), + .init(Vector(0, -0.5), Vector(0.5, 0.5)) + ) + XCTAssertEqual( + Bounds(b.1), + .init(Vector(-0.5, -0.5), Vector(0, 0.5)) + ) + XCTAssertEqual(b.front, b.0) + XCTAssertEqual(b.back, b.1) + } + + func testSplitLineAlongPlane() { + let a = Path.line(Vector(-0.5, 0), Vector(0.5, 0)) + let plane = Plane(unchecked: .unitX, pointOnPlane: .zero) + let b = a.split(along: plane) + XCTAssertEqual(b.front, [Path.line(Vector(0, 0), Vector(0.5, 0))]) + XCTAssertEqual(b.back, [Path.line(Vector(-0.5, 0), Vector(0, 0))]) + } + + func testSquareSplitAlongItsOwnPlane() { + let a = Path.square() + let plane = Plane(unchecked: .unitZ, pointOnPlane: .zero) + let b = a.split(along: plane) + XCTAssertEqual(Bounds(b.front), a.bounds) + XCTAssert(b.back.isEmpty) + } + + func testSquareSplitAlongReversePlane() { + let a = Path.square() + let plane = Plane(unchecked: -.unitZ, pointOnPlane: .zero) + let b = a.split(along: plane) + XCTAssertEqual(Bounds(b.front), a.bounds) + XCTAssert(b.back.isEmpty) + } +} diff --git a/Tests/PathTests.swift b/Tests/PathTests.swift index 9d5626b2..d52dc6bc 100644 --- a/Tests/PathTests.swift +++ b/Tests/PathTests.swift @@ -88,6 +88,19 @@ class PathTests: XCTestCase { XCTAssertTrue(path.isClosed) } + func testClosedPathWithOffshoot() { + let path = Path([ + .point(0, 0), + .point(1, 0), + .point(1, 1), + .point(0, 1), + .point(0, 0), + .point(-1, 0), + ]) + XCTAssertTrue(path.isSimple) + XCTAssertFalse(path.isClosed) + } + // MARK: winding direction func testConvexClosedPathAnticlockwiseWinding() { diff --git a/Tests/PolygonCSGTests.swift b/Tests/PolygonCSGTests.swift index ae34a817..582494b9 100644 --- a/Tests/PolygonCSGTests.swift +++ b/Tests/PolygonCSGTests.swift @@ -10,17 +10,43 @@ import XCTest class PolygonCSGTests: XCTestCase { + // MARK: XOR + + func testXorCoincidingSquares() { + let a = Polygon(shape: .square())! + let b = Polygon(shape: .square())! + let c = a.symmetricDifference(b) + XCTAssert(c.isEmpty) + } + + func testXorAdjacentSquares() { + let a = Polygon(shape: .square())! + let b = a.translated(by: .unitX) + let c = a.symmetricDifference(b) + XCTAssertEqual(Bounds(c), a.bounds.union(b.bounds)) + } + + func testXorOverlappingSquares() { + let a = Polygon(shape: .square())! + let b = a.translated(by: Vector(0.5, 0, 0)) + let c = a.symmetricDifference(b) + XCTAssertEqual(Bounds(c), Bounds( + min: Vector(-0.5, -0.5, 0), + max: Vector(1.0, 0.5, 0) + )) + } + // MARK: Plane clipping func testSquareClippedToPlane() { - let a = Path.square().facePolygons()[0] + let a = Polygon(shape: .square())! let plane = Plane(unchecked: .unitX, pointOnPlane: .zero) let b = a.clip(to: plane) XCTAssertEqual(Bounds(b), .init(Vector(0, -0.5), Vector(0.5, 0.5))) } func testPentagonClippedToPlane() { - let a = Path.circle(segments: 5).facePolygons()[0] + let a = Polygon(shape: .circle(segments: 5))! let plane = Plane(unchecked: .unitX, pointOnPlane: .zero) let b = a.clip(to: plane) XCTAssertEqual(Bounds(b), .init( @@ -30,7 +56,7 @@ class PolygonCSGTests: XCTestCase { } func testDiamondClippedToPlane() { - let a = Path.circle(segments: 4).facePolygons()[0] + let a = Polygon(shape: .circle(segments: 4))! let plane = Plane(unchecked: .unitX, pointOnPlane: .zero) let b = a.clip(to: plane) XCTAssertEqual(Bounds(b), .init(Vector(0, -0.5), Vector(0.5, 0.5))) @@ -39,7 +65,7 @@ class PolygonCSGTests: XCTestCase { // MARK: Plane splitting func testSquareSplitAlongPlane() { - let a = Path.square().facePolygons()[0] + let a = Polygon(shape: .square())! let plane = Plane(unchecked: .unitX, pointOnPlane: .zero) let b = a.split(along: plane) XCTAssertEqual( @@ -55,7 +81,7 @@ class PolygonCSGTests: XCTestCase { } func testSquareSplitAlongItsOwnPlane() { - let a = Path.square().facePolygons()[0] + let a = Polygon(shape: .square())! let plane = Plane(unchecked: .unitZ, pointOnPlane: .zero) let b = a.split(along: plane) XCTAssertEqual(b.front, [a]) @@ -63,7 +89,7 @@ class PolygonCSGTests: XCTestCase { } func testSquareSplitAlongReversePlane() { - let a = Path.square().facePolygons()[0] + let a = Polygon(shape: .square())! let plane = Plane(unchecked: -.unitZ, pointOnPlane: .zero) let b = a.split(along: plane) XCTAssertEqual(b.back, [a])