diff --git a/Euclid.docc/Extensions/Color.md b/Euclid.docc/Extensions/Color.md index 00bda1a0..66cce8bd 100644 --- a/Euclid.docc/Extensions/Color.md +++ b/Euclid.docc/Extensions/Color.md @@ -4,11 +4,11 @@ ### Creating a Color +- ``Color/init(r:g:b:a:)`` - ``Color/init(_:_:_:_:)`` - ``Color/init(_:_:)`` -- ``Color/init(_:)-25eby`` - ``Color/init(_:)-53lhy`` -- ``Color/init(_:)-7d8un`` +- ``Color/init(_:)-9bvpm`` ### Default Colors diff --git a/Euclid.docc/Extensions/Vector.md b/Euclid.docc/Extensions/Vector.md index 1de01ab7..19c82427 100644 --- a/Euclid.docc/Extensions/Vector.md +++ b/Euclid.docc/Extensions/Vector.md @@ -4,14 +4,12 @@ ### Creating Vectors -- ``Vector/init(_:)-228p6`` -- ``Vector/init(_:)-4eop9`` -- ``Vector/init(_:)-5n3j`` -- ``Vector/init(_:)-63ct7`` -- ``Vector/init(_:)-6nlm`` +- ``Vector/init(x:y:z:)`` - ``Vector/init(_:_:_:)`` - ``Vector/init(size:)-8b34m`` - ``Vector/init(size:)-nkyk`` +- ``Vector/init(_:)-63ct7`` +- ``Vector/init(_:)-602vn`` ### Default Vectors diff --git a/Euclid.xcodeproj/project.pbxproj b/Euclid.xcodeproj/project.pbxproj index d0d4824a..b79c8e9f 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 */; }; @@ -22,7 +24,9 @@ 0148ECA62783796A00B3F836 /* PathPoint.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0148ECA52783796A00B3F836 /* PathPoint.swift */; }; 014AC60E2505963800F54349 /* SceneKitTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 014AC60D2505963800F54349 /* SceneKitTests.swift */; }; 0157FEC42B63B1BE009033D1 /* Mesh+IO.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0157FEC32B63B1BE009033D1 /* Mesh+IO.swift */; }; - 0162A09623795E260078AE84 /* Euclid.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 016FAB2921BFE78100AF60DC /* Euclid.framework */; }; + 01593CCB297C39180058A35C /* Direction.swift in Sources */ = {isa = PBXBuildFile; fileRef = 01593CCA297C39180058A35C /* Direction.swift */; }; + 01593CCD297CB5AF0058A35C /* Position.swift in Sources */ = {isa = PBXBuildFile; fileRef = 01593CCC297CB5AF0058A35C /* Position.swift */; }; + 0162A09623795E260078AE84 /* Euclid.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 016FAB2921BFE78100AF60DC /* Euclid.framework */; platformFilters = (ios, maccatalyst, ); }; 0162A09923795EB30078AE84 /* Euclid.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 016FAB2921BFE78100AF60DC /* Euclid.framework */; }; 016A77F82B2F7C7800B7AB73 /* MeshImportTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 016A77F72B2F7C7800B7AB73 /* MeshImportTests.swift */; }; 016A77FA2B32184A00B7AB73 /* RealityKitTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 016A77F92B32184A00B7AB73 /* RealityKitTests.swift */; }; @@ -64,18 +68,25 @@ 01BA297A2235E34C0088D36B /* CGPathTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 01BA29792235E34C0088D36B /* CGPathTests.swift */; }; 01BA297C2235E3590088D36B /* Euclid+CoreGraphics.swift in Sources */ = {isa = PBXBuildFile; fileRef = 01BA297B2235E3580088D36B /* Euclid+CoreGraphics.swift */; }; 01CBE2682775E3EE00B7ED45 /* MeshTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 01CBE2672775E3EE00B7ED45 /* MeshTests.swift */; }; - 01CF789326EBE0C70097907A /* Quaternion.swift in Sources */ = {isa = PBXBuildFile; fileRef = 01CF789226EBE0C70097907A /* Quaternion.swift */; }; 01D2F9F82AADBB1400C201D9 /* Mesh+Texcoords.swift in Sources */ = {isa = PBXBuildFile; fileRef = 01D2F9F72AADBB1400C201D9 /* Mesh+Texcoords.swift */; }; 01D96AB523D8E36A00D0D267 /* BoundsTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 01D96AB423D8E36A00D0D267 /* BoundsTests.swift */; }; 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 */; }; + 2B4F06BE2B981DD30025DDF2 /* ContentView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2B4F06BD2B981DD30025DDF2 /* ContentView.swift */; }; + 2B4F06C22B981DD40025DDF2 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 2B4F06C12B981DD40025DDF2 /* Assets.xcassets */; }; + 2B4F06C52B981DD40025DDF2 /* Preview Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 2B4F06C42B981DD40025DDF2 /* Preview Assets.xcassets */; }; + 2B4F06CD2B9831960025DDF2 /* Euclid.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 016FAB2921BFE78100AF60DC /* Euclid.framework */; }; + 2B4F06CE2B9831960025DDF2 /* Euclid.framework in Embed Frameworks */ = {isa = PBXBuildFile; fileRef = 016FAB2921BFE78100AF60DC /* Euclid.framework */; settings = {ATTRIBUTES = (CodeSignOnCopy, RemoveHeadersOnCopy, ); }; }; + 2B4F06D32B9831F50025DDF2 /* EuclidMesh.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2B4F06D22B9831F50025DDF2 /* EuclidMesh.swift */; }; + 2B6474662B994019006D0E09 /* VolumetricView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2B6474652B994019006D0E09 /* VolumetricView.swift */; }; 52A3852E238D6E5700BE8407 /* LineTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 52A3852D238D6E5700BE8407 /* LineTests.swift */; }; 52A663A123857D5300FACF9D /* Line.swift in Sources */ = {isa = PBXBuildFile; fileRef = 52A663A023857D5300FACF9D /* Line.swift */; }; 52C844E223854CDF009C0A73 /* VectorTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 52C844E023854C87009C0A73 /* VectorTests.swift */; }; + EA3A382D29700A4400A6185A /* Edge.swift in Sources */ = {isa = PBXBuildFile; fileRef = EA3A382C29700A4400A6185A /* Edge.swift */; }; EA6F2219296C5A9000B530BE /* Polygon+CSG.swift in Sources */ = {isa = PBXBuildFile; fileRef = EA6F2218296C5A9000B530BE /* Polygon+CSG.swift */; }; /* End PBXBuildFile section */ @@ -94,6 +105,13 @@ remoteGlobalIDString = 016FAB2821BFE78100AF60DC; remoteInfo = Euclid; }; + 2B4F06CF2B9831960025DDF2 /* PBXContainerItemProxy */ = { + isa = PBXContainerItemProxy; + containerPortal = 016FAB2021BFE78100AF60DC /* Project object */; + proxyType = 1; + remoteGlobalIDString = 016FAB2821BFE78100AF60DC; + remoteInfo = Euclid; + }; /* End PBXContainerItemProxy section */ /* Begin PBXCopyFilesBuildPhase section */ @@ -108,6 +126,17 @@ name = "Embed Frameworks"; runOnlyForDeploymentPostprocessing = 0; }; + 2B4F06D12B9831960025DDF2 /* Embed Frameworks */ = { + isa = PBXCopyFilesBuildPhase; + buildActionMask = 2147483647; + dstPath = ""; + dstSubfolderSpec = 10; + files = ( + 2B4F06CE2B9831960025DDF2 /* Euclid.framework in Embed Frameworks */, + ); + name = "Embed Frameworks"; + runOnlyForDeploymentPostprocessing = 0; + }; /* End PBXCopyFilesBuildPhase section */ /* Begin PBXFileReference section */ @@ -117,6 +146,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 = ""; }; @@ -127,6 +158,8 @@ 0148ECA52783796A00B3F836 /* PathPoint.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PathPoint.swift; sourceTree = ""; }; 014AC60D2505963800F54349 /* SceneKitTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SceneKitTests.swift; sourceTree = ""; }; 0157FEC32B63B1BE009033D1 /* Mesh+IO.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Mesh+IO.swift"; sourceTree = ""; }; + 01593CCA297C39180058A35C /* Direction.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Direction.swift; sourceTree = ""; }; + 01593CCC297CB5AF0058A35C /* Position.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Position.swift; sourceTree = ""; }; 016A77F72B2F7C7800B7AB73 /* MeshImportTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MeshImportTests.swift; sourceTree = ""; }; 016A77F92B32184A00B7AB73 /* RealityKitTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RealityKitTests.swift; sourceTree = ""; }; 016FAB2921BFE78100AF60DC /* Euclid.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Euclid.framework; sourceTree = BUILT_PRODUCTS_DIR; }; @@ -171,18 +204,25 @@ 01BA29792235E34C0088D36B /* CGPathTests.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = CGPathTests.swift; sourceTree = ""; }; 01BA297B2235E3580088D36B /* Euclid+CoreGraphics.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = "Euclid+CoreGraphics.swift"; sourceTree = ""; }; 01CBE2672775E3EE00B7ED45 /* MeshTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MeshTests.swift; sourceTree = ""; }; - 01CF789226EBE0C70097907A /* Quaternion.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Quaternion.swift; sourceTree = ""; }; 01D2F9F72AADBB1400C201D9 /* Mesh+Texcoords.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Mesh+Texcoords.swift"; sourceTree = ""; }; 01D96AB423D8E36A00D0D267 /* BoundsTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BoundsTests.swift; sourceTree = ""; }; 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; }; + 2B4F06BB2B981DD30025DDF2 /* ExampleVisionOSApp.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ExampleVisionOSApp.swift; sourceTree = ""; }; + 2B4F06BD2B981DD30025DDF2 /* ContentView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContentView.swift; sourceTree = ""; }; + 2B4F06C12B981DD40025DDF2 /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = ""; }; + 2B4F06C42B981DD40025DDF2 /* Preview Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = "Preview Assets.xcassets"; sourceTree = ""; }; + 2B4F06C62B981DD40025DDF2 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; + 2B4F06D22B9831F50025DDF2 /* EuclidMesh.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EuclidMesh.swift; sourceTree = ""; }; + 2B6474652B994019006D0E09 /* VolumetricView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VolumetricView.swift; sourceTree = ""; }; 52A3852D238D6E5700BE8407 /* LineTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LineTests.swift; sourceTree = ""; }; 52A663A023857D5300FACF9D /* Line.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Line.swift; sourceTree = ""; }; 52C844E023854C87009C0A73 /* VectorTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VectorTests.swift; sourceTree = ""; }; + EA3A382C29700A4400A6185A /* Edge.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Edge.swift; sourceTree = ""; }; EA6F2218296C5A9000B530BE /* Polygon+CSG.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Polygon+CSG.swift"; sourceTree = ""; }; /* End PBXFileReference section */ @@ -210,6 +250,14 @@ ); runOnlyForDeploymentPostprocessing = 0; }; + 2B4F06B22B981DD30025DDF2 /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + 2B4F06CD2B9831960025DDF2 /* Euclid.framework in Frameworks */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; /* End PBXFrameworksBuildPhase section */ /* Begin PBXGroup section */ @@ -226,6 +274,7 @@ 016FAB2B21BFE78100AF60DC /* Sources */, 016FAB3621BFE78100AF60DC /* Tests */, 016FABEB21C078E400AF60DC /* Example */, + 2B4F06B62B981DD30025DDF2 /* ExampleVisionOS */, 016FAB2A21BFE78100AF60DC /* Products */, 0162A09523795E260078AE84 /* Frameworks */, ); @@ -239,6 +288,7 @@ 016FAB2921BFE78100AF60DC /* Euclid.framework */, 016FAB3221BFE78100AF60DC /* EuclidTests.xctest */, 016FABEA21C078E400AF60DC /* EuclidExample.app */, + 2B4F06B52B981DD30025DDF2 /* ExampleVisionOS.app */, ); name = Products; sourceTree = ""; @@ -251,11 +301,13 @@ 0188525726D951490079C602 /* Color.swift */, 016FAB4521BFE7C100AF60DC /* Utilities.swift */, 016FAB4321BFE7C100AF60DC /* Vector.swift */, + 01593CCC297CB5AF0058A35C /* Position.swift */, + 01593CCA297C39180058A35C /* Direction.swift */, 016FAB4621BFE7C100AF60DC /* Vertex.swift */, + EA3A382C29700A4400A6185A /* Edge.swift */, 016FAB4C21BFE7C200AF60DC /* Polygon.swift */, EA6F2218296C5A9000B530BE /* Polygon+CSG.swift */, 01E5F54823D59BF100717D58 /* BSP.swift */, - 016FAB4921BFE7C200AF60DC /* Path.swift */, 016FAB4821BFE7C200AF60DC /* Mesh.swift */, 016FAB4421BFE7C100AF60DC /* Mesh+CSG.swift */, 016FAB4E21BFE7C200AF60DC /* Mesh+Shapes.swift */, @@ -264,12 +316,13 @@ 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 */, 013ED08E23C0E60900FEEE5C /* Rotation.swift */, - 01CF789226EBE0C70097907A /* Quaternion.swift */, 01B9F60D292BCE57002CC3EB /* Stretchable.swift */, 016FAB4B21BFE7C200AF60DC /* Transforms.swift */, 016FAB4A21BFE7C200AF60DC /* Euclid+SceneKit.swift */, @@ -291,9 +344,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 */, @@ -302,8 +355,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 */, @@ -312,7 +366,6 @@ 01B9F60F292BD7FE002CC3EB /* StretchableTests.swift */, 016FAB5D21BFE7CE00AF60DC /* UtilityTests.swift */, 52C844E023854C87009C0A73 /* VectorTests.swift */, - 01F2465228FD499F0071AE64 /* QuaternionTests.swift */, 0141CAEE2857DBF4006ADFDB /* Euclid+Testing.swift */, 016FAB3921BFE78100AF60DC /* Info.plist */, ); @@ -334,6 +387,28 @@ path = Example; sourceTree = ""; }; + 2B4F06B62B981DD30025DDF2 /* ExampleVisionOS */ = { + isa = PBXGroup; + children = ( + 2B4F06BB2B981DD30025DDF2 /* ExampleVisionOSApp.swift */, + 2B4F06BD2B981DD30025DDF2 /* ContentView.swift */, + 2B6474652B994019006D0E09 /* VolumetricView.swift */, + 2B4F06D22B9831F50025DDF2 /* EuclidMesh.swift */, + 2B4F06C12B981DD40025DDF2 /* Assets.xcassets */, + 2B4F06C62B981DD40025DDF2 /* Info.plist */, + 2B4F06C32B981DD40025DDF2 /* Preview Content */, + ); + path = ExampleVisionOS; + sourceTree = ""; + }; + 2B4F06C32B981DD40025DDF2 /* Preview Content */ = { + isa = PBXGroup; + children = ( + 2B4F06C42B981DD40025DDF2 /* Preview Assets.xcassets */, + ); + path = "Preview Content"; + sourceTree = ""; + }; /* End PBXGroup section */ /* Begin PBXHeadersBuildPhase section */ @@ -405,6 +480,27 @@ productReference = 016FABEA21C078E400AF60DC /* EuclidExample.app */; productType = "com.apple.product-type.application"; }; + 2B4F06B42B981DD30025DDF2 /* ExampleVisionOS */ = { + isa = PBXNativeTarget; + buildConfigurationList = 2B4F06C92B981DD40025DDF2 /* Build configuration list for PBXNativeTarget "ExampleVisionOS" */; + buildPhases = ( + 2B4F06B12B981DD30025DDF2 /* Sources */, + 2B4F06B22B981DD30025DDF2 /* Frameworks */, + 2B4F06B32B981DD30025DDF2 /* Resources */, + 2B4F06D12B9831960025DDF2 /* Embed Frameworks */, + ); + buildRules = ( + ); + dependencies = ( + 2B4F06D02B9831960025DDF2 /* PBXTargetDependency */, + ); + name = ExampleVisionOS; + packageProductDependencies = ( + ); + productName = ExampleVisionOS; + productReference = 2B4F06B52B981DD30025DDF2 /* ExampleVisionOS.app */; + productType = "com.apple.product-type.application"; + }; /* End PBXNativeTarget section */ /* Begin PBXProject section */ @@ -412,7 +508,7 @@ isa = PBXProject; attributes = { BuildIndependentTargetsInParallel = YES; - LastSwiftUpdateCheck = 1400; + LastSwiftUpdateCheck = 1530; LastUpgradeCheck = 1500; ORGANIZATIONNAME = "Nick Lockwood"; TargetAttributes = { @@ -428,6 +524,9 @@ CreatedOnToolsVersion = 10.1; LastSwiftMigration = 1240; }; + 2B4F06B42B981DD30025DDF2 = { + CreatedOnToolsVersion = 15.3; + }; }; }; buildConfigurationList = 016FAB2321BFE78100AF60DC /* Build configuration list for PBXProject "Euclid" */; @@ -446,6 +545,7 @@ 016FAB2821BFE78100AF60DC /* Euclid */, 016FAB3121BFE78100AF60DC /* EuclidTests */, 016FABE921C078E400AF60DC /* Example */, + 2B4F06B42B981DD30025DDF2 /* ExampleVisionOS */, ); }; /* End PBXProject section */ @@ -475,6 +575,15 @@ ); runOnlyForDeploymentPostprocessing = 0; }; + 2B4F06B32B981DD30025DDF2 /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + 2B4F06C52B981DD40025DDF2 /* Preview Assets.xcassets in Resources */, + 2B4F06C22B981DD40025DDF2 /* Assets.xcassets in Resources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; /* End PBXResourcesBuildPhase section */ /* Begin PBXShellScriptBuildPhase section */ @@ -528,8 +637,8 @@ 0188525826D951490079C602 /* Color.swift in Sources */, 010A63392A951165000E3306 /* Mesh+OBJ.swift in Sources */, 016FAB5421BFE7C200AF60DC /* Mesh.swift in Sources */, - 01CF789326EBE0C70097907A /* Quaternion.swift in Sources */, 0112D5C928EE29BB00A1C085 /* Euclid+RealityKit.swift in Sources */, + 01593CCD297CB5AF0058A35C /* Position.swift in Sources */, EA6F2219296C5A9000B530BE /* Polygon+CSG.swift in Sources */, 016FAB5721BFE7C200AF60DC /* Transforms.swift in Sources */, 016FAB5121BFE7C200AF60DC /* Utilities.swift in Sources */, @@ -537,6 +646,7 @@ 01BA297C2235E3590088D36B /* Euclid+CoreGraphics.swift in Sources */, 0148ECA62783796A00B3F836 /* PathPoint.swift in Sources */, 013499932902FB5900CED6BE /* Euclid+SIMD.swift in Sources */, + EA3A382D29700A4400A6185A /* Edge.swift in Sources */, 0188525A26D952890079C602 /* Euclid+AppKit.swift in Sources */, 01A3D52129F32D360085D6BE /* Mesh+STL.swift in Sources */, 01B9F60E292BCE57002CC3EB /* Stretchable.swift in Sources */, @@ -550,11 +660,13 @@ 0148ECA4278378D100B3F836 /* Path+Shapes.swift in Sources */, 52A663A123857D5300FACF9D /* Line.swift in Sources */, 01D2F9F82AADBB1400C201D9 /* Mesh+Texcoords.swift in Sources */, + 01593CCB297C39180058A35C /* Direction.swift in Sources */, 016FAB4F21BFE7C200AF60DC /* Vector.swift in Sources */, 016FAB5021BFE7C200AF60DC /* Mesh+CSG.swift in Sources */, 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; @@ -563,12 +675,12 @@ isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; files = ( - 01FAE7BD29744E08008DB288 /* PolygonCSGTests.swift in Sources */, - 01F2465428FD4A020071AE64 /* QuaternionTests.swift in Sources */, + 01FAE7BD29744E08008DB288 /* PathCSGTests.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 */, @@ -603,6 +715,17 @@ ); runOnlyForDeploymentPostprocessing = 0; }; + 2B4F06B12B981DD30025DDF2 /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + 2B4F06BE2B981DD30025DDF2 /* ContentView.swift in Sources */, + 2B4F06D32B9831F50025DDF2 /* EuclidMesh.swift in Sources */, + 2B4F06BC2B981DD30025DDF2 /* ExampleVisionOSApp.swift in Sources */, + 2B6474662B994019006D0E09 /* VolumetricView.swift in Sources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; /* End PBXSourcesBuildPhase section */ /* Begin PBXTargetDependency section */ @@ -616,6 +739,11 @@ target = 016FAB2821BFE78100AF60DC /* Euclid */; targetProxy = 0162A09723795E820078AE84 /* PBXContainerItemProxy */; }; + 2B4F06D02B9831960025DDF2 /* PBXTargetDependency */ = { + isa = PBXTargetDependency; + target = 016FAB2821BFE78100AF60DC /* Euclid */; + targetProxy = 2B4F06CF2B9831960025DDF2 /* PBXContainerItemProxy */; + }; /* End PBXTargetDependency section */ /* Begin PBXVariantGroup section */ @@ -677,6 +805,7 @@ CURRENT_PROJECT_VERSION = 1; DEAD_CODE_STRIPPING = YES; DEBUG_INFORMATION_FORMAT = dwarf; + DRIVERKIT_DEPLOYMENT_TARGET = 19.0; ENABLE_STRICT_OBJC_MSGSEND = YES; ENABLE_TESTABILITY = YES; ENABLE_USER_SCRIPT_SANDBOXING = NO; @@ -704,8 +833,11 @@ SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG; SWIFT_OPTIMIZATION_LEVEL = "-Onone"; SWIFT_VERSION = 4.2; + TVOS_DEPLOYMENT_TARGET = 12.0; VERSIONING_SYSTEM = "apple-generic"; VERSION_INFO_PREFIX = ""; + WATCHOS_DEPLOYMENT_TARGET = 4.0; + XROS_DEPLOYMENT_TARGET = 1.0; }; name = Debug; }; @@ -748,6 +880,7 @@ CURRENT_PROJECT_VERSION = 1; DEAD_CODE_STRIPPING = YES; DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; + DRIVERKIT_DEPLOYMENT_TARGET = 19.0; ENABLE_NS_ASSERTIONS = NO; ENABLE_STRICT_OBJC_MSGSEND = YES; ENABLE_USER_SCRIPT_SANDBOXING = NO; @@ -767,8 +900,11 @@ SUPPORTS_MACCATALYST = YES; SWIFT_COMPILATION_MODE = wholemodule; SWIFT_VERSION = 4.2; + TVOS_DEPLOYMENT_TARGET = 12.0; VERSIONING_SYSTEM = "apple-generic"; VERSION_INFO_PREFIX = ""; + WATCHOS_DEPLOYMENT_TARGET = 4.0; + XROS_DEPLOYMENT_TARGET = 1.0; }; name = Release; }; @@ -783,6 +919,7 @@ DEAD_CODE_STRIPPING = YES; DEFINES_MODULE = YES; DEVELOPMENT_TEAM = ""; + DRIVERKIT_DEPLOYMENT_TARGET = 19.0; DYLIB_COMPATIBILITY_VERSION = 1; DYLIB_CURRENT_VERSION = 1; DYLIB_INSTALL_NAME_BASE = "@rpath"; @@ -790,6 +927,7 @@ FRAMEWORK_VERSION = A; INFOPLIST_FILE = Sources/Info.plist; INSTALL_PATH = "$(LOCAL_LIBRARY_DIR)/Frameworks"; + IPHONEOS_DEPLOYMENT_TARGET = 11.0; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/../Frameworks", @@ -824,6 +962,7 @@ DEAD_CODE_STRIPPING = YES; DEFINES_MODULE = YES; DEVELOPMENT_TEAM = ""; + DRIVERKIT_DEPLOYMENT_TARGET = 19.0; DYLIB_COMPATIBILITY_VERSION = 1; DYLIB_CURRENT_VERSION = 1; DYLIB_INSTALL_NAME_BASE = "@rpath"; @@ -831,6 +970,7 @@ FRAMEWORK_VERSION = A; INFOPLIST_FILE = Sources/Info.plist; INSTALL_PATH = "$(LOCAL_LIBRARY_DIR)/Frameworks"; + IPHONEOS_DEPLOYMENT_TARGET = 11.0; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/../Frameworks", @@ -914,7 +1054,7 @@ ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; CODE_SIGN_IDENTITY = "iPhone Developer"; CODE_SIGN_STYLE = Automatic; - DEVELOPMENT_TEAM = 8VQKF583ED; + DEVELOPMENT_TEAM = TW8SJXYFP5; INFOPLIST_FILE = Example/Info.plist; INFOPLIST_KEY_CFBundleDisplayName = "Euclid Example"; INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.developer-tools"; @@ -927,7 +1067,7 @@ PRODUCT_BUNDLE_IDENTIFIER = com.charcoaldesign.EuclidExample; PRODUCT_NAME = EuclidExample; SDKROOT = iphoneos; - SUPPORTED_PLATFORMS = "iphoneos iphonesimulator xros xrsimulator"; + SUPPORTED_PLATFORMS = "iphoneos iphonesimulator"; SUPPORTS_MACCATALYST = YES; SUPPORTS_MAC_DESIGNED_FOR_IPHONE_IPAD = YES; SWIFT_VERSION = 5.0; @@ -942,7 +1082,7 @@ ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; CODE_SIGN_IDENTITY = "iPhone Developer"; CODE_SIGN_STYLE = Automatic; - DEVELOPMENT_TEAM = 8VQKF583ED; + DEVELOPMENT_TEAM = TW8SJXYFP5; INFOPLIST_FILE = Example/Info.plist; INFOPLIST_KEY_CFBundleDisplayName = "Euclid Example"; INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.developer-tools"; @@ -955,13 +1095,82 @@ PRODUCT_BUNDLE_IDENTIFIER = com.charcoaldesign.EuclidExample; PRODUCT_NAME = EuclidExample; SDKROOT = iphoneos; - SUPPORTED_PLATFORMS = "iphoneos iphonesimulator xros xrsimulator"; + SUPPORTED_PLATFORMS = "iphoneos iphonesimulator"; SUPPORTS_MACCATALYST = YES; SUPPORTS_MAC_DESIGNED_FOR_IPHONE_IPAD = YES; SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = "1,2"; + TVOS_DEPLOYMENT_TARGET = 12.0; + VALIDATE_PRODUCT = YES; + }; + name = Release; + }; + 2B4F06C72B981DD40025DDF2 /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; + ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++20"; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + DEVELOPMENT_ASSET_PATHS = "\"ExampleVisionOS/Preview Content\""; + DEVELOPMENT_TEAM = TW8SJXYFP5; + ENABLE_PREVIEWS = YES; + ENABLE_USER_SCRIPT_SANDBOXING = YES; + GCC_C_LANGUAGE_STANDARD = gnu17; + GENERATE_INFOPLIST_FILE = YES; + INFOPLIST_FILE = "$(TARGET_NAME)/Info.plist"; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + ); + LOCALIZATION_PREFERS_STRING_CATALOGS = YES; + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = com.charcoaldesign.ExampleVisionOS; + PRODUCT_NAME = "$(TARGET_NAME)"; + SDKROOT = xros; + SUPPORTED_PLATFORMS = "xros xrsimulator"; + SWIFT_ACTIVE_COMPILATION_CONDITIONS = "DEBUG $(inherited)"; + SWIFT_EMIT_LOC_STRINGS = YES; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = "1,2,7"; + XROS_DEPLOYMENT_TARGET = 1.1; + }; + name = Debug; + }; + 2B4F06C82B981DD40025DDF2 /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; + ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++20"; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + DEVELOPMENT_ASSET_PATHS = "\"ExampleVisionOS/Preview Content\""; + DEVELOPMENT_TEAM = TW8SJXYFP5; + ENABLE_PREVIEWS = YES; + ENABLE_USER_SCRIPT_SANDBOXING = YES; + GCC_C_LANGUAGE_STANDARD = gnu17; + GENERATE_INFOPLIST_FILE = YES; + INFOPLIST_FILE = "$(TARGET_NAME)/Info.plist"; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + ); + LOCALIZATION_PREFERS_STRING_CATALOGS = YES; + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = com.charcoaldesign.ExampleVisionOS; + PRODUCT_NAME = "$(TARGET_NAME)"; + SDKROOT = xros; + SUPPORTED_PLATFORMS = "xros xrsimulator"; + SWIFT_EMIT_LOC_STRINGS = YES; + SWIFT_VERSION = 5.0; TARGETED_DEVICE_FAMILY = "1,2,7"; TVOS_DEPLOYMENT_TARGET = 12.0; VALIDATE_PRODUCT = YES; + XROS_DEPLOYMENT_TARGET = 1.1; }; name = Release; }; @@ -1004,6 +1213,15 @@ defaultConfigurationIsVisible = 0; defaultConfigurationName = Release; }; + 2B4F06C92B981DD40025DDF2 /* Build configuration list for PBXNativeTarget "ExampleVisionOS" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 2B4F06C72B981DD40025DDF2 /* Debug */, + 2B4F06C82B981DD40025DDF2 /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; /* End XCConfigurationList section */ }; rootObject = 016FAB2021BFE78100AF60DC /* Project object */; 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 @@ + + + + UIApplicationSceneManifest + + UIApplicationPreferredDefaultSceneSessionRole + UIWindowSceneSessionRoleVolumetricApplication + UIApplicationSupportsMultipleScenes + + UISceneConfigurations + + + + diff --git a/ExampleVisionOS/Preview Content/Preview Assets.xcassets/Contents.json b/ExampleVisionOS/Preview Content/Preview Assets.xcassets/Contents.json new file mode 100644 index 00000000..73c00596 --- /dev/null +++ b/ExampleVisionOS/Preview Content/Preview Assets.xcassets/Contents.json @@ -0,0 +1,6 @@ +{ + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/ExampleVisionOS/VolumetricView.swift b/ExampleVisionOS/VolumetricView.swift new file mode 100644 index 00000000..56a3f891 --- /dev/null +++ b/ExampleVisionOS/VolumetricView.swift @@ -0,0 +1,53 @@ +// +// VolumetricView.swift +// ExampleVisionOS +// +// Created by Hal Mueller on 3/6/24. +// Copyright © 2024 Nick Lockwood. All rights reserved. +// + +import RealityKit +import SwiftUI + +struct VolumetricView: View { + @State private var spinX = 0.0 + @State private var spinY = 0.0 + + var body: some View { + RealityView { content in + if let demoBoxEntity = try? ModelEntity(euclidMesh.scaled(by: 0.5)) { + // for more realism, add a shadow + demoBoxEntity.components.set(GroundingShadowComponent(castsShadow: true)) + + // needed for tap detection/response + demoBoxEntity.generateCollisionShapes(recursive: true) + + // for gesture targeting + demoBoxEntity.components.set(InputTargetComponent()) + + content.add(demoBoxEntity) + } + } update: { content in + guard let entity = content.entities.first else { return } + + let pitch = Transform(pitch: Float(spinX * -1)).matrix + let yaw = Transform(yaw: Float(spinY)).matrix + entity.transform.matrix = pitch * yaw + } + .gesture( + DragGesture(minimumDistance: 0) + .targetedToAnyEntity() + .onChanged { value in + let startLocation = value.convert(value.startLocation3D, from: .local, to: .scene) + let currentLocation = value.convert(value.location3D, from: .local, to: .scene) + let delta = currentLocation - startLocation + spinX = Double(delta.y) * 5 + spinY = Double(delta.x) * 5 + } + ) + } +} + +#Preview { + VolumetricView() +} diff --git a/README.md b/README.md index 7d7c82fa..d7b3d2ca 100644 --- a/README.md +++ b/README.md @@ -58,9 +58,10 @@ Feel free to open an issue in Github if you have questions about how to use the If you wish to contribute improvements to the documentation or the code itself, that's great! But please read the [CONTRIBUTING.md](CONTRIBUTING.md) file before submitting a pull request. -# Example +# Example and ExampleVisionOS -See the included project for an example of how Euclid can be used in conjunction with SceneKit or RealityKit to generate and render a nontrivial 3D shape on iOS. +See the included projects for examples of how Euclid can be used in conjunction with SceneKit or RealityKit to generate and render a nontrivial 3D shape. `Example` uses storyboards, is built for iOS, and runs in "Designed for iPad" mode on macOS and visionOS. +`ExampleVisionPro` uses SwiftUI and a RealityView in a volumetric window, and runs only on visionOS. # Documentation diff --git a/Sources/Angle.swift b/Sources/Angle.swift index 6da5f497..2acf59b7 100644 --- a/Sources/Angle.swift +++ b/Sources/Angle.swift @@ -98,13 +98,13 @@ public func tan(_ angle: Angle) -> Double { public extension Angle { /// Angle representing a zero (identity) rotation. - static let zero = Angle.radians(0) + static let zero: Angle = .radians(0) /// Angle representing a quarter rotation. - static let halfPi = Angle.radians(.pi / 2) + static let halfPi: Angle = .radians(.pi / 2) /// Angle representing a half-rotation. - static let pi = Angle.radians(.pi) + static let pi: Angle = .radians(.pi) /// Angle representing a full rotation. - static let twoPi = Angle.radians(.pi * 2) + static let twoPi: Angle = .radians(.pi * 2) /// The angle in degrees. var degrees: Double { diff --git a/Sources/BSP.swift b/Sources/BSP.swift index d42ccc48..cbb0f9f7 100644 --- a/Sources/BSP.swift +++ b/Sources/BSP.swift @@ -32,6 +32,7 @@ struct BSP { private var nodes: [BSPNode] private(set) var isConvex: Bool + private(set) var isInverted: Bool } extension BSP { @@ -45,9 +46,14 @@ extension BSP { } init(_ mesh: Mesh, _ isCancelled: CancellationHandler) { + self.init(mesh.polygons, isConvex: mesh.isKnownConvex, isCancelled) + } + + init(_ polygons: [Polygon], isConvex: Bool, _ isCancelled: CancellationHandler) { self.nodes = [BSPNode]() - self.isConvex = mesh.isKnownConvex - initialize(mesh.polygons, isCancelled) + self.isConvex = isConvex + self.isInverted = false + initialize(polygons, isCancelled) } func clip( @@ -136,17 +142,20 @@ private extension BSP { } mutating func initialize(_ polygons: [Polygon], _ isCancelled: CancellationHandler) { + guard !polygons.isEmpty else { + return + } + var rng = DeterministicRNG() guard isConvex else { - guard !polygons.isEmpty else { - return - } + let startPlane = polygons[0].plane // Randomly shuffle polygons to reduce average number of splits let polygons = polygons.shuffled(using: &rng) nodes.reserveCapacity(polygons.count) - nodes.append(BSPNode(plane: polygons[0].plane)) + nodes.append(BSPNode(plane: startPlane)) insert(polygons, isCancelled) + isInverted = containsPoint(startPlane.normal * .greatestFiniteMagnitude) return } diff --git a/Sources/Bounds.swift b/Sources/Bounds.swift index ca9a0e58..7517e890 100644 --- a/Sources/Bounds.swift +++ b/Sources/Bounds.swift @@ -56,6 +56,12 @@ public protocol Bounded { var bounds: Bounds { get } } +extension Bounds: CustomStringConvertible { + public var description: String { + "Bounds(min: [\(min.components)], max: [\(max.components)])" + } +} + extension Bounds: Codable { private enum CodingKeys: CodingKey { case min, max @@ -125,26 +131,6 @@ public extension Bounds { } } - /// Deprecated. - @available(*, deprecated, renamed: "init(_:)") - init(points: [Vector] = []) { - self = points.reduce(.empty) { - Bounds(min: Euclid.min($0.min, $1), max: Euclid.max($0.max, $1)) - } - } - - /// Deprecated. - @available(*, deprecated, renamed: "init(_:)") - init(polygons: [Polygon]) { - self.init(polygons) - } - - /// Deprecated. - @available(*, deprecated, renamed: "init(_:)") - init(bounds: [Bounds]) { - self.init(bounds) - } - /// A Boolean value that indicates whether the bounds is empty (has zero volume). var isEmpty: Bool { size == .zero diff --git a/Sources/Color.swift b/Sources/Color.swift index 32ca40e5..92f1454a 100644 --- a/Sources/Color.swift +++ b/Sources/Color.swift @@ -29,6 +29,31 @@ // SOFTWARE. // +/// Protocol for types that can be converted to RGBA color components. +public protocol RGBAConvertible { + /// Get RGBA color components. + var rgbaComponents: (r: Double, g: Double, b: Double, a: Double) { get } +} + +/// Protocol for types that can be represented by RGBA color components. +public protocol RGBARepresentable: RGBAConvertible { + /// Initialize with RGBA components. + /// - Parameters: + /// - r: The red component of the color, from 0 to 1. + /// - g: The green component of the color, from 0 to 1. + /// - b: The blue component of the color, from 0 to 1. + /// - a: The alpha component of the color, from 0 to 1. + init(r: Double, g: Double, b: Double, a: Double) +} + +public extension RGBARepresentable { + /// Initialize with an RGBAConvertible value. + init(_ value: RGBAConvertible) { + let components = value.rgbaComponents + self.init(r: components.r, g: components.g, b: components.b, a: components.a) + } +} + /// A color in RGBA format. /// /// Color can be used as a ``Polygon/material-swift.property`` or as a ``Vertex/color``. @@ -56,6 +81,39 @@ public struct Color: Hashable, Sendable { } } +extension Color: Comparable { + /// Returns whether the leftmost color has the lower value. + /// This provides a stable order when sorting collections of colors. + public static func < (lhs: Color, rhs: Color) -> 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: RGBARepresentable { + public var rgbaComponents: (r: Double, g: Double, b: Double, a: Double) { + (r, g, b, a) + } + + public init(r: Double = 0, g: Double = 0, b: Double = 0, a: Double = 1) { + self.init(r, g, b, a) + } +} + +extension Color: ExpressibleByArrayLiteral { + public init(arrayLiteral elements: Double...) { + self.init(unchecked: elements) + } +} + +extension Color: CustomStringConvertible { + public var description: String { + "Color(\(r), \(g), \(b)\(a == 1 ? "" : ", \(a)"))" + } +} + extension Color: Codable { private enum CodingKeys: String, CodingKey { case r, g, b, a diff --git a/Sources/Direction.swift b/Sources/Direction.swift new file mode 100644 index 00000000..e05878e7 --- /dev/null +++ b/Sources/Direction.swift @@ -0,0 +1,173 @@ +// +// Direction.swift +// Euclid +// +// Created by Nick Lockwood on 21/01/2023. +// Copyright © 2023 Nick Lockwood. All rights reserved. +// +// Distributed under the permissive MIT license +// Get the latest version from here: +// +// https://github.com/nicklockwood/Euclid +// +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to deal +// in the Software without restriction, including without limitation the rights +// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +// copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in all +// copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +// SOFTWARE. +// + +import Foundation + +/// A normalized direction vector in 3D space. +public struct Direction: Hashable, Sendable { + /// The X component of the direction. + public var x: Double + /// The Y component of the direction. + public var y: Double + /// The Z component of the direction. + public var z: Double + + /// Creates a direction from the values you provide. + /// - Parameters: + /// - x: The X component of the direction. + /// - y: The Y component of the direction. + /// - z: The Z component of the direction. + public init?(_ x: Double, _ y: Double, _ z: Double = 0) { + let length = Vector(x, y, z).length + guard length > 0 else { + return nil + } + self.x = x / length + self.y = y / length + self.z = z / length + } +} + +extension Direction: XYZConvertible { + public var xyzComponents: (x: Double, y: Double, z: Double) { + (x, y, z) + } +} + +extension Direction: Codable { + /// Creates a new direction by decoding from the given decoder. + /// - Parameter decoder: The decoder to read data from. + public init(from decoder: Decoder) throws { + let container = try decoder.singleValueContainer() + let vector = try container.decode(Vector.self) + guard let direction = Direction(vector) else { + throw DecodingError.dataCorruptedError( + in: container, + debugDescription: "Decoded direction vector has zero length" + ) + } + self = direction + } + + /// Encodes the direction into the given encoder. + /// - Parameter encoder: The encoder to write data to. + public func encode(to encoder: Encoder) throws { + var container = encoder.unkeyedContainer() + try Vector(self).encode(to: &container, skipZ: z == 0) + } +} + +public extension Direction { + /// A direction along the X axis. + static let x: Direction = .init(unchecked: 1, 0, 0) + /// A direction along the Y axis. + static let y: Direction = .init(unchecked: 0, 1, 0) + /// A direction along the Z axis. + static let z: Direction = .init(unchecked: 0, 0, 1) + + /// Initialize with some XYZConvertible value. + init?(_ value: T) { + let components = value.xyzComponents + self.init(components.x, components.y, components.z) + } + + /// Initialize with any XYZConvertible value. + @_disfavoredOverload + init?(_ value: XYZConvertible) { + let components = value.xyzComponents + self.init(components.x, components.y, components.z) + } + + /// An array containing the X, Y, and Z components of the direction vector. + var components: [Double] { + [x, y, z] + } + + /// Returns the inverse direction. + static prefix func - (rhs: Direction) -> Direction { + .init(unchecked: -rhs.x, -rhs.y, -rhs.z) + } + + /// Returns a vector representing a direction multiplied by a scale factor. + static func * (lhs: Direction, rhs: Double) -> Vector { + Vector(lhs.x * rhs, lhs.y * rhs, lhs.z * rhs) + } + + /// Returns a vector representing a direction multiplied by a scale factor. + static func * (lhs: Double, rhs: Direction) -> Vector { + Vector(lhs * rhs.x, lhs * rhs.y, lhs * rhs.z) + } + + /// Computes the dot-product of this vector and another. + /// - Parameter a: The direction with which to compute the dot product. + /// - Returns: The dot product of the two direction vectors. + func dot(_ a: Direction) -> Double { + x * a.x + y * a.y + z * a.z + } + + /// Computes the cross-product of this direction and another. + /// - Parameter a: The direction with which to compute the cross product. + /// - Returns: Returns a direction that is orthogonal to the other two. + func cross(_ a: Direction) -> Direction { + .init(unchecked: y * a.z - z * a.y, z * a.x - x * a.z, x * a.y - y * a.x) + } + + /// Returns the angle between this direction and another. + /// - Parameter a: The vector to compare with. + func angle(with a: Direction) -> Angle { + .acos(dot(a)) + } + + /// Returns the angle between this direction and the specified plane. + /// - Parameter plane: The plane to compare with. + func angle(with plane: Plane) -> Angle { + .asin(dot(Direction(unchecked: plane.normal))) + } +} + +extension Direction { + init(unchecked x: Double, _ y: Double, _ z: Double) { + assert(Vector(x, y, z).length.isEqual(to: 1)) + self.x = x + self.y = y + self.z = z + } + + init(unchecked value: T) { + let components = value.xyzComponents + self.init(unchecked: components.x, components.y, components.z) + } + + init(unchecked value: XYZConvertible) { + let components = value.xyzComponents + self.init(unchecked: components.x, components.y, components.z) + } +} diff --git a/Sources/Edge.swift b/Sources/Edge.swift new file mode 100644 index 00000000..caad47c4 --- /dev/null +++ b/Sources/Edge.swift @@ -0,0 +1,176 @@ +// +// Edge.swift +// Euclid +// +// Created by Nick Lockwood on 12/01/2023. +// Copyright © 2023 Nick Lockwood. All rights reserved. +// +// Distributed under the permissive MIT license +// Get the latest version from here: +// +// https://github.com/nicklockwood/Euclid +// +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to deal +// in the Software without restriction, including without limitation the rights +// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +// copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in all +// copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +// SOFTWARE. +// + +/// A polygon edge. +public struct Edge: Hashable, Sendable { + /// The starting point of the line segment. + public let start: Vertex + /// The end point of the line segment. + public let end: Vertex + + /// Creates an edge with a start and end vertex. + /// - Parameters: + /// - start: The start of the edge + /// - end: The end of the edge + public init?(_ start: Vertex, _ end: Vertex) { + guard start != end else { + return nil + } + self.start = start + self.end = end + } +} + +extension Edge: Comparable { + /// Returns whether the leftmost edge has the lower value. + /// This provides a stable order when sorting collections of edges. + public static func < (lhs: Edge, rhs: Edge) -> Bool { + guard lhs.start == rhs.start else { return lhs.start < rhs.start } + return lhs.end < rhs.end + } +} + +extension Edge: Codable { + private enum CodingKeys: CodingKey { + case start, end + } + + /// Creates a new line segment by decoding from the given decoder. + /// - Parameter decoder: The decoder to read data from. + public init(from decoder: Decoder) throws { + if let container = try? decoder.container(keyedBy: CodingKeys.self) { + guard let edge = try Edge( + container.decode(Vertex.self, forKey: .start), + container.decode(Vertex.self, forKey: .end) + ) else { + throw DecodingError.dataCorruptedError( + forKey: .end, + in: container, + debugDescription: "Edge cannot have zero length" + ) + } + self = edge + } else { + var container = try decoder.unkeyedContainer() + guard let edge = try Edge( + container.decode(Vertex.self), + container.decode(Vertex.self) + ) else { + throw DecodingError.dataCorruptedError( + in: container, + debugDescription: "Edge cannot have zero length" + ) + } + self = edge + } + } + + /// Encodes this line segment into the given encoder. + /// - Parameter encoder: The encoder to write data to. + public func encode(to encoder: Encoder) throws { + var container = encoder.unkeyedContainer() + try container.encode(start) + try container.encode(end) + } +} + +public extension Edge { + /// The direction of the line segment as a normalized vector. + var direction: Vector { + (end.position - start.position).normalized() + } + + /// The length of the line segment. + var length: Double { + (end.position - start.position).length + } + + /// Flip the direction of the line segment + func inverted() -> Edge { + .init(unchecked: end, start) + } + +// /// Returns a Boolean value that indicates whether the specified point lies on the line segment. +// /// - Parameter point: The point to test. +// /// - Returns: `true` if the point lies on the line segment and `false` otherwise. +// func containsPoint(_ point: Vector) -> Bool { +// let v = vectorFromPointToLine(point, start, direction) +// guard v.isEqual(to: .zero, withPrecision: epsilon) else { +// return false +// } +// return Bounds(start, end).inset(by: -epsilon).containsPoint(point) +// } +// +// /// Returns the intersection point between the specified line segment and this one. +// /// - Parameter segment: The line segment to compare with. +// /// - Returns: The point of intersection, or `nil` if the line segments don't intersect. +// func intersection(with segment: Edge) -> Vector? { +// lineSegmentsIntersection(start, end, segment.start, segment.end) +// } +// +// /// Returns a Boolean value that indicates whether two line segements intersect. +// /// - Parameter segment: The line segment to compare with. +// /// - Returns: `true` if the line segments intersect and `false` otherwise. +// func intersects(_ segment: LineSegment) -> Bool { +// intersection(with: segment) != nil +// } +} + +extension Edge { + init(unchecked start: Vertex, _ end: Vertex) { + assert(start != end) + self.start = start + self.end = end + } + + init(normalized start: Vertex, _ end: Vertex) { + if start < end { + self.init(unchecked: start, end) + } else { + self.init(unchecked: end, start) + } + } + +// func compare(with plane: Plane) -> PlaneComparison { +// switch (start.compare(with: plane), end.compare(with: plane)) { +// case (.coplanar, .coplanar): +// return .coplanar +// case (.front, .back), (.back, .front): +// return .spanning +// case (.front, _), (_, .front): +// return .front +// case (.back, _), (_, .back): +// return .back +// case (.spanning, _), (_, .spanning): +// preconditionFailure() +// } +// } +} diff --git a/Sources/Euclid+AppKit.swift b/Sources/Euclid+AppKit.swift index 25e7d0a9..caf6dec3 100644 --- a/Sources/Euclid+AppKit.swift +++ b/Sources/Euclid+AppKit.swift @@ -56,11 +56,9 @@ public extension NSColor { } } -public extension Color { - /// Creates a color from an `NSColor`. - /// - Parameter nsColor: The `NSColor` to convert. - init(_ nsColor: NSColor) { - self.init(nsColor.cgColor) +extension NSColor: RGBAConvertible { + public var rgbaComponents: (r: Double, g: Double, b: Double, a: Double) { + cgColor.rgbaComponents } } diff --git a/Sources/Euclid+CoreGraphics.swift b/Sources/Euclid+CoreGraphics.swift index 82b71fbb..c8dbbfb7 100644 --- a/Sources/Euclid+CoreGraphics.swift +++ b/Sources/Euclid+CoreGraphics.swift @@ -33,26 +33,35 @@ import CoreGraphics -public extension Vector { - /// Creates a vector from a CoreGraphics `CGPoint`. - /// - Parameter cgPoint: the CoreGraphics point. - init(_ cgPoint: CGPoint) { - self.init(Double(cgPoint.x), Double(cgPoint.y)) +extension CGPoint: XYZRepresentable { + public var xyzComponents: (x: Double, y: Double, z: Double) { + (Double(x), Double(y), 0) } - /// Creates a new vector from a CoreGraphics size. - /// - Parameter cgSize: the CoreGraphics size. - init(_ cgSize: CGSize) { - self.init(Double(cgSize.width), Double(cgSize.height)) + public init(x: Double, y: Double, z _: Double) { + self.init(x: x, y: y) } } -public extension Color { - /// Creates a color from a CoreGraphics `CGColor`. - /// - Parameter cgColor: The CoreGraphics color instance. - init(_ cgColor: CGColor) { - let components = cgColor.components ?? [1] - self.init(unchecked: components.map(Double.init)) +extension CGSize: XYZRepresentable { + public var xyzComponents: (x: Double, y: Double, z: Double) { + (Double(width), Double(height), 0) + } + + public init(x: Double, y: Double, z _: Double) { + self.init(width: x, height: y) + } +} + +extension CGColor: RGBAConvertible { + public var rgbaComponents: (r: Double, g: Double, b: Double, a: Double) { + let c = components?.map(Double.init) ?? [1] + switch c.count { + case 1: return (c[0], c[0], c[0], 1) + case 2: return (c[0], c[0], c[0], c[1]) + case 3: return (c[0], c[1], c[2], 1) + default: return (c[0], c[1], c[2], 1) + } } } @@ -111,11 +120,6 @@ public extension Path { init(_ cgPath: CGPath, detail: Int = 4, color: Color? = nil) { self.init(subpaths: cgPath.paths(detail: detail, color: color)) } - - @available(*, deprecated, renamed: "init(_:detail:color:)") - init(cgPath: CGPath, detail: Int = 4, color: Color? = nil) { - self.init(subpaths: cgPath.paths(detail: detail, color: color)) - } } public extension CGPath { diff --git a/Sources/Euclid+RealityKit.swift b/Sources/Euclid+RealityKit.swift index bb8564f0..efd8c66e 100644 --- a/Sources/Euclid+RealityKit.swift +++ b/Sources/Euclid+RealityKit.swift @@ -96,12 +96,20 @@ private func defaultMaterialLookup(_ material: Polygon.Material?) -> RealityKit. @available(macOS 12.0, iOS 15.0, *) public extension MeshDescriptor { + private typealias SIMDVertexData = ( + positions: [simd_float3], + normals: [simd_float3], + texcoords: [simd_float2]?, + indices: [UInt32], + materialIndices: [UInt32] + ) + /// Creates a mesh descriptor from a ``Mesh`` using triangles. /// - Parameter triangles: The mesh to convert into a RealityKit mesh descriptor. init(triangles mesh: Mesh) { self.init() var counts: [UInt8]? - let data = mesh.getVertexData(maxSides: 3, counts: &counts) + let data: SIMDVertexData = mesh.getVertexData(maxSides: 3, counts: &counts) self.positions = .init(data.positions) self.normals = .init(data.normals) self.textureCoordinates = data.texcoords.map(MeshBuffers.TextureCoordinates.init) @@ -118,7 +126,7 @@ public extension MeshDescriptor { init(polygons mesh: Mesh) { self.init() var counts: [UInt8]? = [] - let data = mesh.getVertexData(maxSides: 255, counts: &counts) + let data: SIMDVertexData = mesh.getVertexData(maxSides: 255, counts: &counts) self.positions = .init(data.positions) self.normals = .init(data.normals) self.textureCoordinates = data.texcoords.map(MeshBuffers.TextureCoordinates.init) @@ -135,7 +143,7 @@ public extension MeshDescriptor { init(quads mesh: Mesh) { self.init() var counts: [UInt8]? = [] - let data = mesh.getVertexData(maxSides: 4, counts: &counts) + let data: SIMDVertexData = mesh.getVertexData(maxSides: 4, counts: &counts) self.positions = .init(data.positions) self.normals = .init(data.normals) self.textureCoordinates = data.texcoords.map(MeshBuffers.TextureCoordinates.init) @@ -232,56 +240,6 @@ private extension Mesh { let materialLookup = materialLookup ?? defaultMaterialLookup return materials.map { materialLookup($0) ?? SimpleMaterial() } } - - func getVertexData(maxSides: UInt8, counts: inout [UInt8]?) -> ( - positions: [SIMD3], - normals: [SIMD3], - texcoords: [SIMD2]?, - indices: [UInt32], - materialIndices: [UInt32] - ) { - var positions: [SIMD3] = [] - var normals: [SIMD3] = [] // looks bad with default normals - var texcoords: [SIMD2]? = hasTexcoords ? [] : nil - var indices = [UInt32]() - var materialIndices = [UInt32]() - var indicesByVertex = [Vertex: UInt32]() - let polygonsByMaterial = self.polygonsByMaterial - let perFaceMaterials = materials.count > 1 - for (materialIndex, material) in materials.enumerated() { - let polygons = polygonsByMaterial[material] ?? [] - for polygon in polygons.tessellate(maxSides: Int(maxSides)) { - counts?.append(UInt8(polygon.vertices.count)) - for var vertex in polygon.vertices { - vertex.color = .white // Note: vertex colors are not supported - if let index = indicesByVertex[vertex] { - indices.append(index) - continue - } - let index = UInt32(indicesByVertex.count) - indicesByVertex[vertex] = index - indices.append(index) - positions.append(.init(vertex.position)) - normals.append(.init(vertex.normal)) - if texcoords != nil { - var texcoord = vertex.texcoord - texcoord.y = 1 - texcoord.y - texcoords?.append(.init(texcoord)) - } - } - if perFaceMaterials { - materialIndices.append(UInt32(materialIndex)) - } - } - } - return ( - positions: positions, - normals: normals, - texcoords: texcoords, - indices: indices, - materialIndices: materialIndices - ) - } } // MARK: import diff --git a/Sources/Euclid+SIMD.swift b/Sources/Euclid+SIMD.swift index 5b6dcd8c..18af6c0d 100644 --- a/Sources/Euclid+SIMD.swift +++ b/Sources/Euclid+SIMD.swift @@ -33,47 +33,82 @@ import simd -public extension simd_double3 { - /// Creates a simd vector 3 from a Euclid `Vector`. - /// - Parameter vector: A Euclid vector. - init(_ vector: Vector) { - self.init(vector.x, vector.y, vector.z) +extension SIMD3: XYZConvertible where Scalar: FloatingPoint { + public var xyzComponents: (x: Double, y: Double, z: Double) { + switch self { + case let value as simd_double3: + return (value.x, value.y, value.z) + case let value as simd_float3: + return (Double(value.x), Double(value.y), Double(value.z)) + default: + preconditionFailure() + } } } -public extension simd_float3 { - /// Creates a simd float vector 3 from a Euclid `Vector`. - /// - Parameter vector: A Euclid vector. - init(_ vector: Vector) { - self.init(Float(vector.x), Float(vector.y), Float(vector.z)) +extension SIMD3: XYZRepresentable where Scalar: FloatingPoint { + @_disfavoredOverload + public init(x: Double, y: Double, z: Double) { + switch Self.self { + case let type as simd_double3.Type: + self = type.init(x, y, z) as! Self + case let type as simd_float3.Type: + self = type.init(Float(x), Float(y), Float(z)) as! Self + default: + preconditionFailure() + } } } -public extension simd_float2 { - /// Creates a simd float vector 2 from a Euclid `Vector`. - /// - Parameter vector: A Euclid vector. - init(_ vector: Vector) { - self.init(Float(vector.x), Float(vector.y)) +extension SIMD2: XYZConvertible where Scalar: FloatingPoint { + public var xyzComponents: (x: Double, y: Double, z: Double) { + switch self { + case let value as simd_double2: + return (value.x, value.y, 0) + case let value as simd_float2: + return (Double(value.x), Double(value.y), 0) + default: + preconditionFailure() + } } } -public extension Vector { - /// Creates a `Vector` from a simd vector 3. - /// - Parameter vector: A simd vector. - init(_ vector: simd_double3) { - self.init(vector.x, vector.y, vector.z) +extension SIMD2: XYZRepresentable where Scalar: FloatingPoint { + public init(x: Double, y: Double, z _: Double) { + switch Self.self { + case let type as simd_double2.Type: + self = type.init(x, y) as! Self + case let type as simd_float2.Type: + self = type.init(Float(x), Float(y)) as! Self + default: + preconditionFailure() + } } +} - /// Creates a `Vector` from a simd vector 3. - /// - Parameter vector: A simd vector. - init(_ vector: simd_float3) { - self.init(Double(vector.x), Double(vector.y), Double(vector.z)) +extension SIMD4: RGBAConvertible where Scalar: FloatingPoint { + public var rgbaComponents: (r: Double, g: Double, b: Double, a: Double) { + switch self { + case let value as simd_double4: + return (value.x, value.y, value.z, value.w) + case let value as simd_float4: + return (Double(value.x), Double(value.y), Double(value.z), Double(value.w)) + default: + preconditionFailure() + } } +} - /// Creates a `Vector` from a simd vector 2. - /// - Parameter vector: A simd vector. - init(_ vector: simd_float2) { - self.init(Double(vector.x), Double(vector.y)) +extension SIMD4: RGBARepresentable where Scalar: FloatingPoint { + public init(r: Double, g: Double, b: Double, a: Double) { + switch Self.self { + case let type as simd_double4.Type: + self = type.init(r, g, b, a) as! Self + case let type as simd_float4.Type: + self = type.init(Float(r), Float(g), Float(b), Float(a)) as! Self + default: + preconditionFailure() + } } } @@ -83,13 +118,6 @@ public extension simd_quatd { init(_ rotation: Rotation) { self = rotation.storage } - - /// Creates a simd quaternion from a Euclid `Quaternion`. - /// - Parameter quaternion: A Euclid quaternion. - @available(*, deprecated) - init(_ quaternion: Quaternion) { - self = quaternion.storage - } } public extension simd_quatf { @@ -98,13 +126,6 @@ public extension simd_quatf { init(_ rotation: Rotation) { self.init(vector: simd_float4(rotation.storage.vector)) } - - /// Creates a simd float quaternion from a Euclid `Quaternion`. - /// - Parameter quaternion: A Euclid quaternion. - @available(*, deprecated) - init(_ quaternion: Quaternion) { - self.init(vector: simd_float4(quaternion.storage.vector)) - } } public extension Rotation { @@ -121,19 +142,4 @@ public extension Rotation { } } -@available(*, deprecated) -public extension Quaternion { - /// Creates a `Quaternion` from a simd quaternion. - /// - Parameter quaternion: A simd quaternion. - init(_ quaternion: simd_quatd) { - self.init(storage: quaternion) - } - - /// Creates a `Quaternion` from a simd quaternion. - /// - Parameter quaternion: A simd quaternion. - init(_ quaternion: simd_quatf) { - self.init(simd_quatd(vector: simd_double4(quaternion.vector))) - } -} - #endif diff --git a/Sources/Euclid+SceneKit.swift b/Sources/Euclid+SceneKit.swift index 16484328..2189d95a 100644 --- a/Sources/Euclid+SceneKit.swift +++ b/Sources/Euclid+SceneKit.swift @@ -85,11 +85,24 @@ public extension SCNVector3 { } } -public extension SCNVector4 { - /// Creates a 4D SceneKit vector from a color. - /// - Parameter c: The color to convert. - init(_ c: Color) { - self.init(c.r, c.g, c.b, c.a) +extension SCNVector3: XYZRepresentable { + public var xyzComponents: (x: Double, y: Double, z: Double) { + (Double(x), Double(y), Double(z)) + } + + @_disfavoredOverload + public init(x: Double, y: Double, z: Double) { + self.init(CGFloat(x), CGFloat(y), CGFloat(z)) + } +} + +extension SCNVector4: RGBARepresentable { + public var rgbaComponents: (r: Double, g: Double, b: Double, a: Double) { + (Double(x), Double(y), Double(z), Double(w)) + } + + public init(r: Double, g: Double, b: Double, a: Double) { + self.init(CGFloat(r), CGFloat(g), CGFloat(b), CGFloat(a)) } } @@ -102,16 +115,6 @@ public extension SCNQuaternion { init(_ rotation: Rotation) { self.init(rotation.x, rotation.y, rotation.z, rotation.w) } - - /// Creates a new SceneKit quaternion from a Euclid `Quaternion` - /// - Parameter quaternion: The quaternion to convert. - /// - /// > Note: ``SCNQuaternion`` is actually just a typealias for ``SCNVector4`` so be - /// careful to avoid type ambiguity when using this value. - @available(*, deprecated) - init(_ quaternion: Quaternion) { - self.init(quaternion.x, quaternion.y, quaternion.z, quaternion.w) - } } public extension SCNMatrix4 { @@ -176,6 +179,15 @@ extension SCNGeometrySource { } public extension SCNGeometry { + private typealias SCNVertexData = ( + positions: [SCNVector3], + normals: [SCNVector3], + texcoords: [CGPoint]?, + colors: [SCNVector4]?, + indices: [UInt32], + materialIndices: [UInt32] + ) + /// A closure that maps a Euclid material to a SceneKit material. /// - Parameter m: A Euclid material to convert, or `nil` for the default material. /// - Returns: An `SCNMaterial` used by SceneKit. @@ -425,11 +437,6 @@ public extension SCNGeometry { ] ) } - - @available(*, deprecated, renamed: "init(_:)") - convenience init(bounds: Bounds) { - self.init(bounds) - } } // MARK: import @@ -495,14 +502,6 @@ private extension Data { } } -public extension Vector { - /// Creates a new vector from a SceneKit vector. - /// - Parameter v: The SceneKit `SCNVector3`. - init(_ v: SCNVector3) { - self.init(Double(v.x), Double(v.y), Double(v.z)) - } -} - public extension Rotation { /// Creates a rotation from a SceneKit quaternion. /// - Parameter q: The `SCNQuaternion` to convert. @@ -511,15 +510,6 @@ public extension Rotation { } } -@available(*, deprecated) -public extension Quaternion { - /// Creates a Euclid `Quaternion` from a SceneKit quaternion. - /// - Parameter q: The `SCNQuaternion` to convert. - init(_ q: SCNQuaternion) { - self.init(Double(q.x), Double(q.y), Double(q.z), Double(q.w)) - } -} - public extension Transform { /// Creates a transform from a SceneKit transform matrix. /// - Parameter scnMatrix: The `SCNMatrix4` from which to determine the transform. @@ -558,9 +548,6 @@ public extension Mesh { /// - Returns: A ``Material`` instance, or `nil` for the default material. typealias SCNMaterialProvider = (_ m: SCNMaterial) -> Material? - @available(*, deprecated, renamed: "SCNMaterialProvider") - typealias MaterialProvider = (_ m: SCNMaterial) -> Material? - /// Loads a mesh from a file using any format supported by SceneKit, with optional material mapping. /// - Parameters: /// - url: The `URL` of the file to be loaded. @@ -784,11 +771,6 @@ public extension Mesh { init?(_ scnGeometry: SCNGeometry, material: Material?) { self.init(scnGeometry) { _ in material } } - - @available(*, deprecated, renamed: "init(_:materialLookup:)") - init?(scnGeometry: SCNGeometry, materialLookup: SCNMaterialProvider? = nil) { - self.init(scnGeometry, materialLookup: materialLookup) - } } #else diff --git a/Sources/Euclid+UIKit.swift b/Sources/Euclid+UIKit.swift index 1689a07a..a3257ee1 100644 --- a/Sources/Euclid+UIKit.swift +++ b/Sources/Euclid+UIKit.swift @@ -50,11 +50,9 @@ public extension UIColor { } } -public extension Color { - /// Creates a color from a `UIColor`. - /// - Parameter uiColor: The `UIColor` to convert. - init(_ uiColor: UIColor) { - self.init(uiColor.cgColor) +extension UIColor: RGBAConvertible { + public var rgbaComponents: (r: Double, g: Double, b: Double, a: Double) { + cgColor.rgbaComponents } } diff --git a/Sources/Line.swift b/Sources/Line.swift index bc22fc33..7f230c39 100644 --- a/Sources/Line.swift +++ b/Sources/Line.swift @@ -47,6 +47,14 @@ public struct Line: Hashable, Sendable { } self.init(unchecked: origin, direction: direction / length) } + + /// Creates a line from an origin and direction. + /// - Parameters: + /// - origin: An arbitrary point on the line selected as the origin. + /// - direction: The direction of the line, emanating from the origin. + public init(origin: Vector, direction: Direction) { + self.init(unchecked: origin, direction: Vector(direction)) + } } extension Line: Codable { diff --git a/Sources/LineSegment.swift b/Sources/LineSegment.swift index 3cddd9db..07faadc8 100644 --- a/Sources/LineSegment.swift +++ b/Sources/LineSegment.swift @@ -47,22 +47,14 @@ public struct LineSegment: Hashable, Sendable { self.start = start self.end = end } - - /// Deprecated. - @available(*, deprecated, renamed: "init(start:end:)") - public init?(_ start: Vector, _ end: Vector) { - self.init(start: start, end: end) - } } 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+CSG.swift b/Sources/Mesh+CSG.swift index cf73e570..c76f07f7 100644 --- a/Sources/Mesh+CSG.swift +++ b/Sources/Mesh+CSG.swift @@ -54,9 +54,40 @@ public extension Mesh { /// - Returns: A new mesh representing the union of the input meshes. func union(_ mesh: Mesh, isCancelled: CancellationHandler = { false }) -> Mesh { let intersection = bounds.intersection(mesh.bounds) - if intersection.isEmpty { + let absp = getBSP(isCancelled), bbsp = mesh.getBSP(isCancelled) + switch (absp.isInverted, bbsp.isInverted) { + case (false, false): + if intersection.isEmpty { + return Mesh( + unchecked: polygons + mesh.polygons, + bounds: bounds.union(mesh.bounds), + isConvex: false, + isWatertight: watertightIfSet.flatMap { isWatertight in + mesh.watertightIfSet.map { $0 && isWatertight } + }, + submeshes: [self, mesh] + ) + } + var lhs: [Polygon] = [], rhs: [Polygon] = [] + inParallel({ + var aout: [Polygon]? = [] + let ap = BSP(mesh, isCancelled).clip( + boundsTest(intersection, polygons, &aout), + .greaterThan, + isCancelled + ) + lhs = aout! + ap + }, { + var bout: [Polygon]? = [] + let bp = BSP(self, isCancelled).clip( + boundsTest(intersection, mesh.polygons, &bout), + .greaterThanEqual, + isCancelled + ) + rhs = bout! + bp + }) return Mesh( - unchecked: polygons + mesh.polygons, + unchecked: lhs + rhs, bounds: bounds.union(mesh.bounds), isConvex: false, isWatertight: watertightIfSet.flatMap { isWatertight in @@ -66,32 +97,47 @@ public extension Mesh { mesh.submeshesIfEmpty.map { _ in [self, mesh] } } ) - } - var lhs: [Polygon] = [], rhs: [Polygon] = [] - inParallel({ - var aout: [Polygon]? = [] - let ap = BSP(mesh, isCancelled).clip( - boundsTest(intersection, polygons, &aout), + case (true, true): + if intersection.isEmpty { + return .empty + } + var out: [Polygon]? + let ap = bbsp.clip( + boundsTest(intersection, polygons, &out), .greaterThan, isCancelled ) - lhs = aout! + ap - }, { - var bout: [Polygon]? = [] - let bp = BSP(self, isCancelled).clip( - boundsTest(intersection, mesh.polygons, &bout), + let bp = absp.clip( + boundsTest(intersection, mesh.polygons, &out), .greaterThanEqual, isCancelled ) - rhs = bout! + bp - }) - return Mesh( - unchecked: lhs + rhs, - bounds: bounds.union(mesh.bounds), - isConvex: false, - isWatertight: nil, - submeshes: nil // TODO: can this be preserved? - ) + return Mesh( + unchecked: ap + bp, + bounds: bounds.union(mesh.bounds), + isConvex: false, + isWatertight: nil, + submeshes: nil // TODO: can this be preserved? + ) + default: + let ap = bbsp.clip( + polygons, + .greaterThan, + isCancelled + ) + let bp = absp.clip( + mesh.polygons, + .greaterThanEqual, + isCancelled + ) + return Mesh( + unchecked: ap + bp, + bounds: bounds.union(mesh.bounds), + isConvex: false, + isWatertight: nil, + submeshes: nil // TODO: can this be preserved? + ) + } } /// Efficiently forms a union from multiple meshes. @@ -154,11 +200,6 @@ public extension Mesh { ) } - @available(*, deprecated, renamed: "subtracting(_:isCancelled:)") - func subtract(_ mesh: Mesh, isCancelled: CancellationHandler = { false }) -> Mesh { - subtracting(mesh, isCancelled: isCancelled) - } - /// Efficiently gets the difference between multiple meshes. /// - Parameters /// - meshes: An ordered collection of meshes. All but the first will be subtracted from the first. @@ -210,7 +251,6 @@ public extension Mesh { let (bp2, bp1) = absp.split(bp, .greaterThan, .lessThan, isCancelled) rhs = bout! + bp2 + bp1.inverted() }) - return Mesh( unchecked: lhs + rhs, bounds: nil, // TODO: is there a way to efficiently preserve this? @@ -220,11 +260,6 @@ public extension Mesh { ) } - @available(*, deprecated, renamed: "symmetricDifference(_:isCancelled:)") - func xor(_ mesh: Mesh, isCancelled: CancellationHandler = { false }) -> Mesh { - symmetricDifference(mesh, isCancelled: isCancelled) - } - /// Efficiently XORs multiple meshes. /// - Parameters /// - meshes: A collection of meshes to be XORed. @@ -237,14 +272,6 @@ public extension Mesh { merge(meshes, using: { $0.symmetricDifference($1, isCancelled: $2) }, isCancelled) } - @available(*, deprecated, renamed: "symmetricDifference(_:isCancelled:)") - static func xor( - _ meshes: T, - isCancelled: CancellationHandler = { false } - ) -> Mesh where T.Element == Mesh { - symmetricDifference(meshes, isCancelled: isCancelled) - } - /// Returns a new mesh representing the volume shared by both the mesh /// parameter and the receiver. If these do not intersect, an empty mesh will be returned. /// @@ -290,11 +317,6 @@ public extension Mesh { ) } - @available(*, deprecated, renamed: "intersection(_:isCancelled:)") - func intersect(_ mesh: Mesh, isCancelled: CancellationHandler = { false }) -> Mesh { - intersection(mesh, isCancelled: isCancelled) - } - /// Efficiently computes the intersection of multiple meshes. /// - Parameters /// - meshes: A collection of meshes to be intersected. @@ -337,7 +359,7 @@ public extension Mesh { } var aout: [Polygon]? = [] let ap = boundsTest(bounds.intersection(mesh.bounds), polygons, &aout) - let bsp = BSP(mesh, isCancelled) + let bsp = mesh.getBSP(isCancelled) let (outside, inside) = bsp.split(ap, .greaterThan, .lessThanEqual, isCancelled) let material = mesh.polygons.first?.material return Mesh( @@ -408,11 +430,16 @@ public extension Mesh { /// Clip mesh to the specified plane and optionally fill sheared faces with specified material. /// - Parameters - /// - plane: The plane to clip the mesh to - /// - fill: The material to fill the sheared face(s) with. + /// - plane: The plane to clip the mesh against. + /// - fill: Optional material to fill the sheared face(s) with. + /// - isCancelled: Callback used to cancel the operation. /// /// > Note: Specifying nil for the fill material will leave the sheared face unfilled. - func clip(to plane: Plane, fill: Material? = nil) -> Mesh { + func clip( + to plane: Plane, + fill: Material? = nil, + isCancelled: CancellationHandler = { false } + ) -> Mesh { guard !polygons.isEmpty else { return self } @@ -484,6 +511,11 @@ public extension Mesh { } return edges } + + /// Reflects each polygon of the mesh along a plane. + /// - Parameter plane: The ``Plane`` against which the vertices are to be reflected. + /// - Returns: A ``Mesh`` representing the reflected mesh. + func reflect(along plane: Plane) -> Self { Self(polygons.map { $0.reflect(along: plane) }) } } private func boundsTest( diff --git a/Sources/Mesh+STL.swift b/Sources/Mesh+STL.swift index 0a98f6f2..6b4fade0 100644 --- a/Sources/Mesh+STL.swift +++ b/Sources/Mesh+STL.swift @@ -13,15 +13,69 @@ private let triangleSize = 50 // MARK: Export +/// Configuration options for text/ASCII STL export. +public struct STLTextOptions { + /// The name to be embedded in the file. + public var name: String + /// A whitespace string to use as the indent value. + public var indent: String + /// Should normal values be zeroed out? + public var zeroNormals: Bool + + public init( + name: String = "", + indent: String = "\t", + zeroNormals: Bool = false + ) { + self.name = name + self.indent = indent + self.zeroNormals = zeroNormals + } +} + +/// Configuration options for binary STL export. +public struct STLBinaryOptions { + /// Data to use for file header. + /// Note: data will be padded to 80 bytes. If more than 80 bytes are provided, data will be truncated. + public var header: Data + /// Should normal values be zeroed out? + public var zeroNormals: Bool + /// A closure that maps each polygon's material to an STL facet color. + public var colorLookup: Mesh.STLColorProvider + + public init( + header: Data = .init(), + zeroNormals: Bool = false, + colorLookup: Mesh.STLColorProvider? = nil + ) { + self.header = header + self.zeroNormals = zeroNormals + self.colorLookup = colorLookup ?? defaultColorMapping + } +} + +/// Configuration for exported STL file. +public enum STLFormat { + /// Export in ASCII format. + case text(STLTextOptions) + /// Export in binary format. + case binary(STLBinaryOptions) +} + public extension Mesh { /// Return ASCII STL string data for the mesh. - func stlString(name: String) -> String { + func stlString(name: String = "") -> String { + stlString(options: .init(name: name)) + } + + /// Return ASCII STL string data for the mesh. + func stlString(options: STLTextOptions) -> String { """ - solid \(name) + solid \(options.name) \(triangulate().polygons.map { - $0.stlString + $0.stlString(options: options) }.joined(separator: "\n")) - endsolid \(name) + endsolid \(options.name) """ } @@ -34,26 +88,43 @@ public extension Mesh { /// - Parameter colorLookup: A closure to map Euclid materials to STL facet colors. Use `nil` for default mapping. /// - Returns: A Euclid `Color` value. func stlData(colorLookup: STLColorProvider? = nil) -> Data { + stlData(options: .init(colorLookup: colorLookup)) + } + + /// Return binary STL data for the mesh. + /// - Parameter options: The output ooptions for the STL file + /// - Returns: The encoded STL data. + func stlData(options: STLBinaryOptions) -> Data { let triangles = triangulate().polygons let bufferSize = headerSize + 4 + triangles.count * triangleSize let buffer = Buffer(capacity: bufferSize) + options.header.copyBytes(to: buffer.buffer, count: min(options.header.count, 80)) buffer.count = headerSize buffer.append(UInt32(triangles.count)) - let colorLookup = colorLookup ?? defaultColorMapping - triangles.forEach { buffer.append($0, colorLookup: colorLookup) } + triangles.forEach { buffer.append($0, options: options) } return Data(buffer) } + + /// Return STL data for the mesh. + func stlData(format: STLFormat) -> Data { + switch format { + case let .binary(options): + return stlData(options: options) + case let .text(options): + return Data(stlString(options: options).utf8) + } + } } private extension Polygon { - var stlString: String { + func stlString(options: STLTextOptions) -> String { """ - facet normal \(plane.normal.stlString) - \touter loop + facet normal \((options.zeroNormals ? .zero : plane.normal).stlString) + \(options.indent)outer loop \(vertices.map { - "\t\tvertex \($0.position.stlString)" + "\(options.indent)\(options.indent)vertex \($0.position.stlString)" }.joined(separator: "\n")) - \tendloop + \(options.indent)endloop endfacet """ } @@ -86,10 +157,10 @@ private extension Buffer { append(0x8000 | red << 10 | green << 5 | blue) } - func append(_ polygon: Polygon, colorLookup: Mesh.STLColorProvider) { - append(polygon.plane.normal) + func append(_ polygon: Polygon, options: STLBinaryOptions) { + append(options.zeroNormals ? .zero : polygon.plane.normal) polygon.vertices.forEach { append($0.position) } - if let color = colorLookup(polygon.material) { + if let color = options.colorLookup(polygon.material) { append(color) } else { count += 2 diff --git a/Sources/Mesh+Shapes.swift b/Sources/Mesh+Shapes.swift index 1b24d028..ba1b647a 100644 --- a/Sources/Mesh+Shapes.swift +++ b/Sources/Mesh+Shapes.swift @@ -162,7 +162,7 @@ public extension Mesh { cube(center: c, size: Vector(s, s, s), faces: faces, wrapMode: wrapMode, material: material) } - /// Creates a sphere by subdividing an icosahedron. + /// Creates an icosahedron. /// - Parameters: /// - radius: The radius of the icosahedron. /// - faces: The direction the polygon faces. @@ -269,6 +269,54 @@ public extension Mesh { } } + /// Creates a sphere by subdividing an icosahedron. + /// - Parameters: + /// - radius: The radius of the icosphere. + /// - subdivisions: The number of times to subdivide (each iteration quadruples the triangle count). + /// - faces: The direction the polygon faces. + /// - wrapMode: The mode in which texture coordinates are wrapped around the mesh. + /// - material: The optional material for the mesh. + static func icosphere( + radius: Double = 0.5, + subdivisions: Int = 2, + faces: Faces = .default, + wrapMode: WrapMode = .default, + material: Material? = nil + ) -> Mesh { + let icosahedron = self.icosahedron( + radius: 1, + faces: faces, + wrapMode: .none, + material: material + ) + var triangles = icosahedron.polygons + for _ in 0 ..< subdivisions { + triangles = triangles.subdivide() + } + triangles = triangles.mapVertices { + let direction = $0.position.normalized() + return $0.withPosition(direction * radius).withNormal(direction) + } + let mesh = Mesh( + unchecked: triangles, + bounds: Bounds( + min: Vector(-radius, -radius, -radius), + max: Vector(radius, radius, radius) + ), + isConvex: true, + isWatertight: true, + submeshes: [] + ) + switch wrapMode { + case .default, .shrink: + return mesh.sphereMapped() + case .tube: + return mesh.cylinderMapped() + case .none: + return mesh + } + } + /// Creates a spherical mesh. /// - Parameters: /// - radius: The radius of the sphere. @@ -975,7 +1023,7 @@ private extension Mesh { let t0 = Double(i) / Double(slices) let t1 = Double(i + 1) / Double(slices) let a0 = t0 * Angle.twoPi - let a1 = t1 * Angle.twoPi + let a1 = t1 * Angle.twoPi.radians let cos0 = cos(a0) let cos1 = cos(a1) let sin0 = sin(a0) diff --git a/Sources/Mesh.swift b/Sources/Mesh.swift index f220e4df..6ae0e6eb 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, @@ -343,22 +329,27 @@ public extension Mesh { ) } - /// Deprecated. - @available(*, deprecated, renamed: "smoothingNormals(forAnglesGreaterThan:)") - func smoothNormals(_ threshold: Angle) -> Mesh { - smoothingNormals(forAnglesGreaterThan: threshold) + /// Subdivides triangles and quads, leaving other polygons unchanged. + func subdivide() -> Mesh { + Mesh( + unchecked: polygons.subdivide(), + bounds: boundsIfSet, + isConvex: isKnownConvex, + isWatertight: watertightIfSet, + submeshes: submeshesIfEmpty + ) } /// Returns a Boolean value that indicates if the specified point is inside the mesh. /// - Parameter point: The point to compare. /// - Returns: `true` if the point lies inside the mesh, and `false` otherwise. func containsPoint(_ point: Vector) -> Bool { + guard isKnownConvex else { + return storage.getBSP { false }.containsPoint(point) + } if !bounds.containsPoint(point) { return false } - guard isKnownConvex else { - return BSP(self) { false }.containsPoint(point) - } for polygon in polygons { switch point.compare(with: polygon.plane) { case .coplanar, .spanning: @@ -371,6 +362,15 @@ public extension Mesh { } return true } + + /// Applies a uniform inset to the faces of the mesh. + /// - Parameter distance: The distance by which to inset the polygon faces. + /// - Returns: A copy of the mesh, inset by the specified distance. + /// + /// > Note: Passing a negative `distance` will expand the mesh instead of shrinking it. + func inset(by distance: Double) -> Mesh { + Mesh(polygons.insetFaces(by: distance)) + } } extension Mesh { @@ -392,11 +392,73 @@ extension Mesh { var boundsIfSet: Bounds? { storage.boundsIfSet } var watertightIfSet: Bool? { storage.watertightIfSet } + var bspIfSet: Bool? { storage.watertightIfSet } var isKnownConvex: Bool { storage.isConvex } /// Note: we don't expose submeshesIfSet because it's unsafe to reuse var submeshesIfEmpty: [Mesh]? { storage.submeshesIfSet.flatMap { $0.isEmpty ? [] : nil } } + + func getBSP(_ isCancelled: CancellationHandler) -> BSP { + storage.getBSP(isCancelled) + } + + func getVertexData< + Position: XYZRepresentable, + Normal: XYZRepresentable, + Texcoord: XYZRepresentable + >(maxSides: UInt8, counts: inout [UInt8]?) -> ( + positions: [Position], + normals: [Normal], + texcoords: [Texcoord], + indices: [UInt32], + materialIndices: [UInt32] + ) { + var positions: [Position] = [] + var normals: [Normal] = [] + var texcoords: [Texcoord] = [] + var indices = [UInt32]() + var materialIndices = [UInt32]() + let hasTexcoords = self.hasTexcoords + var indicesByVertex = [Vertex: UInt32]() + let polygonsByMaterial = self.polygonsByMaterial + let perFaceMaterials = materials.count > 1 + for (materialIndex, material) in materials.enumerated() { + let polygons = polygonsByMaterial[material] ?? [] + for polygon in polygons { + for polygon in polygon.tessellate(maxSides: Int(maxSides)) { + counts?.append(UInt8(polygon.vertices.count)) + for vertex in polygon.vertices { + if let index = indicesByVertex[vertex] { + indices.append(index) + continue + } + let index = UInt32(indicesByVertex.count) + indicesByVertex[vertex] = index + indices.append(index) + positions.append(.init(vertex.position)) + normals.append(.init(vertex.normal)) + if hasTexcoords { + var texcoord = vertex.texcoord + texcoord.y = 1 - texcoord.y + texcoords.append(.init(texcoord)) + } + // Note: vertex colors are not supported + } + if perFaceMaterials { + materialIndices.append(UInt32(materialIndex)) + } + } + } + } + return ( + positions: positions, + normals: normals, + texcoords: texcoords, + indices: indices, + materialIndices: materialIndices + ) + } } private extension Mesh { @@ -443,6 +505,18 @@ private extension Mesh { return watertightIfSet! } + private(set) var bspIfSet: BSP? + func getBSP(_ isCancelled: CancellationHandler) -> BSP { + var bsp = bspIfSet + if bsp == nil { + bsp = BSP(polygons, isConvex: isConvex, isCancelled) + if !isCancelled() { + bspIfSet = bsp + } + } + return bsp! + } + private(set) var submeshesIfSet: [Mesh]? var submeshes: [Mesh] { if submeshesIfSet == nil { 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+Shapes.swift b/Sources/Path+Shapes.swift index 99af2da1..4ca30823 100644 --- a/Sources/Path+Shapes.swift +++ b/Sources/Path+Shapes.swift @@ -510,8 +510,8 @@ public extension Path { if let color = p.color { shape = shape.withColor(color) } - if let scale = scale, let line = Line(origin: .zero, direction: upVector) { - shape.stretch(by: scale, along: line) + if let scale = scale, let axis = Direction(upVector) { + shape.stretch(by: scale, along: axis) } shape.translate(by: p.position) shapes.append(shape) diff --git a/Sources/Path.swift b/Sources/Path.swift index f78bdb01..51994163 100644 --- a/Sources/Path.swift +++ b/Sources/Path.swift @@ -140,12 +140,6 @@ public extension Path { mapColors { _ in color } } - /// Deprecated. - @available(*, deprecated, renamed: "withColor(_:)") - func with(color: Color?) -> Path { - withColor(color) - } - /// Closes the path by joining last point to first. /// - Returns: A new path, or `self` if the path is already closed, or cannot be closed. func closed() -> Path { @@ -216,18 +210,6 @@ public extension Path { ) } - @available(*, deprecated, renamed: "init(_:)") - init(polygon: Polygon) { - let hasTexcoords = polygon.hasTexcoords - self.init( - unchecked: polygon.vertices.map { - .point($0.position, texcoord: hasTexcoords ? $0.texcoord : nil) - }, - plane: polygon.plane, - subpathIndices: nil - ) - } - /// Creates a path from a set of line segments. /// - Parameter lineSegments: A set of``LineSegment`` to convert to a path. init(_ lineSegments: Set) { @@ -275,7 +257,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 [] @@ -445,6 +427,43 @@ public extension Path { } return vertices } + + /// Applies a uniform inset to the edges of the path. + /// - Parameter distance: The distance by which to inset the path edges. + /// - Returns: A copy of the path, inset by the specified distance. + /// + /// > Note: Passing a negative `distance` will expand the path instead of shrinking it. + func inset(by distance: Double) -> Path { + guard subpaths.count <= 1, points.count >= 2 else { + return Path(subpaths: subpaths.compactMap { $0.inset(by: distance) }) + } + let count = points.count + var p1 = isClosed ? points[count - 2] : ( + count > 2 ? + extrapolate(points[2], points[1], points[0]) : + extrapolate(points[1], points[0]) + ) + var p2 = points[0] + var p1p2 = p2.position - p1.position + var n1: Vector! + return Path((0 ..< count).map { i in + p1 = p2 + p2 = i < count - 1 ? points[i + 1] : + (isClosed ? points[1] : ( + count > 2 ? + extrapolate(points[i - 2], points[i - 1], points[i]) : + extrapolate(points[i - 1], points[i]) + )) + let p0p1 = p1p2 + p1p2 = p2.position - p1.position + let faceNormal = plane?.normal ?? p0p1.cross(p1p2).normalized() + let n0 = n1 ?? p0p1.cross(faceNormal).normalized() + n1 = p1p2.cross(faceNormal).normalized() + // TODO: do we need to inset texcoord as well? If so, by how much? + let normal = (n0 + n1).normalized() + return p1.translated(by: normal * -(distance / n0.dot(normal))) + }) + } } public extension Polygon { @@ -649,4 +668,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/PathPoint.swift b/Sources/PathPoint.swift index f0b6f0d8..f618cfe4 100644 --- a/Sources/PathPoint.swift +++ b/Sources/PathPoint.swift @@ -48,6 +48,25 @@ public struct PathPoint: Hashable, Sendable { public var isCurved: Bool } +extension PathPoint: ExpressibleByArrayLiteral { + public init(arrayLiteral elements: Double...) { + self = .point(Vector(elements)) + } +} + +extension PathPoint: CustomStringConvertible { + public var description: String { + let p = "\(position.x), \(position.y)\(position.z == 0 ? "" : ", \(position.z)")" + let t = texcoord.map { + ", texcoord: [\($0.x), \($0.y)\($0.z == 0 ? "" : ", \($0.z)")]" + } ?? "" + let c = color.map { + ", color: [\($0.r), \($0.g), \($0.b)\($0.a == 1 ? "" : ", \($0.a)")]" + } ?? "" + return "PathPoint.\(isCurved ? "curve" : "point")(\(p)\(t)\(c))" + } +} + extension PathPoint: Codable { /// Creates a new path point by decoding from the given decoder. /// - Parameter decoder: The decoder to read data from. @@ -57,15 +76,15 @@ extension PathPoint: Codable { let y = try container.decode(Double.self) switch container.count { case 2: - self.init(Vector(x, y), texcoord: nil, color: nil, isCurved: false) + self = [x, y] case 3: let isCurved: Bool, position: Vector do { isCurved = try container.decodeIfPresent(Bool.self) ?? false - position = Vector(x, y) + position = [x, y] } catch { isCurved = false - position = try Vector(x, y, container.decode(Double.self)) + position = try [x, y, container.decode(Double.self)] } self.init(position, texcoord: nil, color: nil, isCurved: isCurved) case 4: @@ -73,12 +92,12 @@ extension PathPoint: Codable { let isCurved: Bool, position: Vector, texcoord: Vector? do { isCurved = try container.decodeIfPresent(Bool.self) ?? false - position = Vector(x, y, zOrU) + position = [x, y, zOrU] texcoord = nil } catch { isCurved = false - position = Vector(x, y) - texcoord = try Vector(zOrU, container.decode(Double.self)) + position = [x, y] + texcoord = try [zOrU, container.decode(Double.self)] } self.init(position, texcoord: texcoord, color: nil, isCurved: isCurved) case 5: @@ -87,12 +106,12 @@ extension PathPoint: Codable { let isCurved: Bool, position: Vector, texcoord: Vector? do { isCurved = try container.decodeIfPresent(Bool.self) ?? false - position = Vector(x, y) - texcoord = Vector(zOrU, uOrV) + position = [x, y] + texcoord = [zOrU, uOrV] } catch { isCurved = false - position = Vector(x, y, zOrU) - texcoord = try Vector(uOrV, container.decode(Double.self)) + position = [x, y, zOrU] + texcoord = try [uOrV, container.decode(Double.self)] } self.init(position, texcoord: texcoord, color: nil, isCurved: isCurved) case 6: @@ -324,12 +343,6 @@ public extension PathPoint { point.color = color return point } - - /// Deprecated. - @available(*, deprecated, renamed: "withColor(_:)") - func with(color: Color?) -> PathPoint { - withColor(color) - } } extension PathPoint { diff --git a/Sources/Plane.swift b/Sources/Plane.swift index 335e6c61..6b69d331 100644 --- a/Sources/Plane.swift +++ b/Sources/Plane.swift @@ -47,6 +47,14 @@ public struct Plane: Hashable, Sendable { } self.init(unchecked: normal / length, w: w) } + + /// Creates a plane from a surface normal and a distance from the world origin. + /// - Parameters: + /// - normal: The surface normal of the plane. + /// - w: The perpendicular distance from the world origin to the plane. + init(normal: Direction, w: Double) { + self.init(unchecked: Vector(normal), w: w) + } } extension Plane: Comparable { @@ -107,6 +115,14 @@ public extension Plane { self.init(unchecked: normal / length, pointOnPlane: pointOnPlane) } + /// Creates a plane from a point and surface normal. + /// - Parameters: + /// - normal: The surface normal of the plane. + /// - pointOnPlane: An arbitrary point on the plane. + init(normal: Direction, pointOnPlane: Vector) { + self.init(unchecked: Vector(normal), pointOnPlane: pointOnPlane) + } + /// Creates a plane from a set of points. /// - Parameter points: A set of coplanar points describing a polygon. /// diff --git a/Sources/Polygon+CSG.swift b/Sources/Polygon+CSG.swift index f6ae123b..f052f2a5 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. @@ -44,6 +82,20 @@ public extension Polygon { intersect(with: plane, edges: &edges) return edges } + + /// Reflects each vertex of the polygon along a plane. + /// - Parameter plane: The ``Plane`` against which the vertices are to be reflected. + /// - Returns: A ``Polygon`` representing the reflected vertices. + func reflect(along plane: Plane) -> Self { + Self( + unchecked: vertices.inverted().map { $0.reflect(along: plane) }, + plane: nil, + isConvex: nil, + sanitizeNormals: true, + material: material, + id: id + ) + } } extension Polygon { diff --git a/Sources/Polygon.swift b/Sources/Polygon.swift index 9550bb34..f27c5193 100644 --- a/Sources/Polygon.swift +++ b/Sources/Polygon.swift @@ -161,9 +161,9 @@ public extension Polygon { vertices.contains(where: { $0.texcoord != .zero }) } - /// A Boolean value that indicates whether the polygon includes vertex normals that differ from the face normal. + /// A Boolean value that indicates whether the polygon includes vertex normals. var hasVertexNormals: Bool { - vertices.contains(where: { !$0.normal.isEqual(to: plane.normal) && $0.normal != .zero }) + vertices.contains(where: { $0.normal != .zero }) } /// A Boolean value that indicates whether the polygon includes vertex colors. @@ -224,12 +224,6 @@ public extension Polygon { return polygon } - /// Deprecated. - @available(*, deprecated, renamed: "withMaterial(_:)") - func with(material: Material?) -> Polygon { - withMaterial(material) - } - /// Creates a polygon from an array of vertices. /// - Parameters: /// - vertices: An array of ``Vertex`` that make up the polygon. @@ -312,6 +306,18 @@ public extension Polygon { return merge(unchecked: other, ensureConvex: ensureConvex) } + /// Return a copy of the polygon with transformed vertex positions + func mapVertices(_ transform: (Vertex) -> Vertex) -> Polygon { + Polygon( + unchecked: vertices.map(transform), + plane: nil, + isConvex: nil, + sanitizeNormals: true, + material: material, + id: id + ) + } + /// Return a copy of the polygon without texture coordinates func withoutTexcoords() -> Polygon { mapTexcoords { _ in .zero } @@ -334,7 +340,7 @@ public extension Polygon { /// - Parameter transform: A closure to be applied to each vertex color in the polygon. func mapVertexColors(_ transform: (Color) -> Color?) -> Polygon { Polygon( - unchecked: vertices.mapVertexColors(transform), + unchecked: vertices.mapColors(transform), plane: plane, isConvex: isConvex, sanitizeNormals: false, @@ -356,6 +362,31 @@ public extension Polygon { ) } + /// Applies a uniform inset to the edges of the polygon. + /// - Parameter distance: The distance by which to inset the polygon edges. + /// - Returns: A copy of the polygon, inset by the specified distance. + /// + /// > Note: Passing a negative `distance` will expand the polygon instead of shrinking it. + func inset(by distance: Double) -> Polygon? { + let count = vertices.count + var v1 = vertices[count - 1] + var v2 = vertices[0] + var p1p2 = v2.position - v1.position + var n1: Vector! + return Polygon((0 ..< count).map { i in + v1 = v2 + v2 = i < count - 1 ? vertices[i + 1] : vertices[0] + let p0p1 = p1p2 + p1p2 = v2.position - v1.position + let faceNormal = plane.normal + let n0 = n1 ?? p0p1.cross(faceNormal).normalized() + n1 = p1p2.cross(faceNormal).normalized() + // TODO: do we need to inset texcoord as well? If so, by how much? + let normal = (n0 + n1).normalized() + return v1.translated(by: normal * -(distance / n0.dot(normal))) + }) + } + /// Splits a polygon into two or more convex polygons using the "ear clipping" method. /// - Parameter maxSides: The maximum number of sides each polygon may have. /// - Returns: An array of convex polygons. @@ -406,6 +437,103 @@ public extension Polygon { id: id ) } + + /// Subdivides triangles and quads, leaving other polygons unchanged. + func subdivide() -> [Polygon] { + switch vertices.count { + case 3: + let (a, b, c) = (vertices[0], vertices[1], vertices[2]) + let ab = a.lerp(b, 0.5) + let bc = b.lerp(c, 0.5) + let ca = c.lerp(a, 0.5) + return [ + Polygon( + unchecked: [a, ab, ca], + normal: plane.normal, + isConvex: true, + sanitizeNormals: false, + material: material + ), + Polygon( + unchecked: [ab, b, bc], + normal: plane.normal, + isConvex: true, + sanitizeNormals: false, + material: material + ), + Polygon( + unchecked: [bc, c, ca], + normal: plane.normal, + isConvex: true, + sanitizeNormals: false, + material: material + ), + Polygon( + unchecked: [ab, bc, ca], + normal: plane.normal, + isConvex: true, + sanitizeNormals: false, + material: material + ), + ] + case 4 where isConvex: + let (a, b, c, d) = (vertices[0], vertices[1], vertices[2], vertices[3]) + let ab = a.lerp(b, 0.5) + let bc = b.lerp(c, 0.5) + let cd = c.lerp(d, 0.5) + let da = d.lerp(a, 0.5) + let abcd = ab.lerp(cd, 0.5) + return [ + Polygon( + unchecked: [a, ab, abcd, da], + normal: plane.normal, + isConvex: true, + sanitizeNormals: false, + material: material + ), + Polygon( + unchecked: [ab, b, bc, abcd], + normal: plane.normal, + isConvex: true, + sanitizeNormals: false, + material: material + ), + Polygon( + unchecked: [bc, c, cd, abcd], + normal: plane.normal, + isConvex: true, + sanitizeNormals: false, + material: material + ), + Polygon( + unchecked: [cd, d, da, abcd], + normal: plane.normal, + isConvex: true, + sanitizeNormals: false, + material: material + ), + ] + default: + 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 { @@ -501,6 +629,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] { @@ -589,11 +743,67 @@ extension Collection where Element == Polygon { map { $0.withMaterial(transform($0.material)) } } + /// Return polygons with transformed vertices + func mapVertices(_ transform: (Vertex) -> Vertex) -> [Polygon] { + map { $0.mapVertices(transform) } + } + /// Return polygons with transformed texture coordinates func mapTexcoords(_ transform: (Vector) -> Vector) -> [Polygon] { map { $0.mapTexcoords(transform) } } + /// Inset along face normals + func insetFaces(by distance: Double) -> [Polygon] { + var planesByVertex: [Vector: [Plane]] = [:] + for p in self { + for v in p.vertices { + if !planesByVertex[v.position, default: []].contains(where: { + $0.isEqual(to: p.plane) + }) { + planesByVertex[v.position, default: []].append(p.plane) + } + } + } + let positionsByVertex: [Vector: Vector] = Dictionary( + uniqueKeysWithValues: planesByVertex.map { p, planes in + switch planes.count { + case 2: + let normal = planes.map { $0.normal }.reduce(.zero) { $0 + $1 }.normalized() + let distance = -(distance / planes[0].normal.dot(normal)) + return (p, p + normal * distance) + case 3...: + let planes = planes.map { $0.translated(by: $0.normal * -distance) } + if let line = planes[0].intersection(with: planes[1]), + let p2 = line.intersection(with: planes[2]) + { + return (p, p2) + } else { + fallthrough + } + default: + return (p, p + planes[0].normal * -distance) + } + } + ) + return map { p0 in + Polygon( + unchecked: p0.vertices.map { v in + Vertex( + unchecked: positionsByVertex[v.position] ?? v.position, + v.normal, + v.texcoord, + v.color + ) + }, + plane: p0.plane.translated(by: p0.plane.normal * -distance), + isConvex: nil, + sanitizeNormals: false, + material: p0.material + ) + } + } + /// Flip each polygon along its plane func inverted() -> [Polygon] { map { $0.inverted() } @@ -639,6 +849,11 @@ extension Collection where Element == Polygon { return polygons } + /// Subdivides triangles and quads, leaving other polygons unchanged + func subdivide() -> [Polygon] { + flatMap { $0.subdivide() } + } + /// Group polygons by plane func groupedByPlane() -> [(plane: Plane, polygons: [Polygon])] { let polygons = sorted(by: { $0.plane.w < $1.plane.w }) @@ -1052,7 +1267,7 @@ struct CodableMaterial: Codable { self.value = color } else if let data = try container.decodeIfPresent(Data.self, forKey: .nscoded) { guard let value = try NSKeyedUnarchiver.unarchivedObject( - ofClasses: Polygon.codableClasses, from: data + ofClasses: NSSet(array: Polygon.codableClasses) as! Set, from: data ) as? Polygon.Material else { throw DecodingError.dataCorruptedError( forKey: .nscoded, diff --git a/Sources/Position.swift b/Sources/Position.swift new file mode 100644 index 00000000..8139ce4f --- /dev/null +++ b/Sources/Position.swift @@ -0,0 +1,92 @@ +// +// Position.swift +// Euclid +// +// Created by Nick Lockwood on 22/01/2023. +// Copyright © 2023 Nick Lockwood. All rights reserved. +// +// Created by Nick Lockwood on 21/01/2023. +// Copyright © 2023 Nick Lockwood. All rights reserved. +// +// Distributed under the permissive MIT license +// Get the latest version from here: +// +// https://github.com/nicklockwood/Euclid +// +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to deal +// in the Software without restriction, including without limitation the rights +// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +// copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in all +// copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +// SOFTWARE. +// + +import Foundation + +/// A position in 3D space. +public struct Position: Hashable, Sendable { + /// The X component of the position. + public var x: Double + /// The Y component of the position. + public var y: Double + /// The Z component of the position. + public var z: Double + + /// Creates a position from the values you provide. + /// - Parameters: + /// - x: The X component of the position. + /// - y: The Y component of the position. + /// - z: The Z component of the position. + public init(_ x: Double, _ y: Double, _ z: Double = 0) { + self.x = x + self.y = y + self.z = z + } +} + +extension Position: XYZRepresentable { + public var xyzComponents: (x: Double, y: Double, z: Double) { + (x, y, z) + } + + public init(x: Double = 0, y: Double = 0, z: Double = 0) { + self.init(x, y, z) + } +} + +extension Position: Codable { + /// Creates a new direction by decoding from the given decoder. + /// - Parameter decoder: The decoder to read data from. + public init(from decoder: Decoder) throws { + let container = try decoder.singleValueContainer() + try self.init(container.decode(Vector.self)) + } + + /// Encodes the direction into the given encoder. + /// - Parameter encoder: The encoder to write data to. + public func encode(to encoder: Encoder) throws { + var container = encoder.unkeyedContainer() + try Vector(self).encode(to: &container, skipZ: z == 0) + } +} + +public extension Position { + /// A position located at the origin + static let origin: Position = .init(0, 0, 0) + + /// An array containing the X, Y, and Z components of the direction vector. + var components: [Double] { + [x, y, z] + } +} diff --git a/Sources/Quaternion.swift b/Sources/Quaternion.swift deleted file mode 100644 index 16dd2158..00000000 --- a/Sources/Quaternion.swift +++ /dev/null @@ -1,542 +0,0 @@ -// -// Quaternion.swift -// Euclid -// -// Created by Nick Lockwood on 10/09/2021. -// Copyright © 2021 Nick Lockwood. All rights reserved. -// -// Distributed under the permissive MIT license -// Get the latest version from here: -// -// https://github.com/nicklockwood/Euclid -// -// Permission is hereby granted, free of charge, to any person obtaining a copy -// of this software and associated documentation files (the "Software"), to deal -// in the Software without restriction, including without limitation the rights -// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell -// copies of the Software, and to permit persons to whom the Software is -// furnished to do so, subject to the following conditions: -// -// The above copyright notice and this permission notice shall be included in all -// copies or substantial portions of the Software. -// -// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE -// SOFTWARE. -// - -import Foundation - -#if canImport(simd) - -import simd - -/// An orientation or rotation in 3D space. -/// -/// A quaternion can be created from a from a ``Rotation`` matrix, or directly from an axis vector and -/// angle, or a from a set of 3 Euler angles (pitch, yaw and roll). -/// -/// In addition to being more compact than a 3x3 rotation matrix, quaternions also avoid a -/// problem known as gymbal lock. -@available(*, deprecated, message: "Use Rotation instead") -public struct Quaternion: Sendable { - var storage: simd_quatd - - /// The quaternion X component. - public var x: Double { - set { storage.vector.x = newValue } - get { storage.vector.x } - } - - /// The quaternion Y component. - public var y: Double { - set { storage.vector.y = newValue } - get { storage.vector.y } - } - - /// The quaternion Z component. - public var z: Double { - set { storage.vector.z = newValue } - get { storage.vector.z } - } - - /// The quaternion W component. - public var w: Double { - set { storage.vector.w = newValue } - get { storage.vector.w } - } -} - -@available(*, deprecated) -extension Quaternion: Hashable { - public func hash(into hasher: inout Hasher) { - hasher.combine(storage.vector) - } -} - -@available(*, deprecated) -public extension Quaternion { - /// Creates a quaternion from raw component values. - init(_ x: Double, _ y: Double, _ z: Double, _ w: Double) { - let vector = simd_normalize(simd_double4(x, y, z, w)) - self.init(storage: simd_quatd(vector: vector)) - } - - /// The axis of rotation. - var axis: Vector { - guard abs(w - 1) > epsilon else { - // if angle close to zero, direction is not important - return .unitZ - } - return .init(-simd_axis(storage)) - } - - /// The angle of rotation. - var angle: Angle { - .radians(simd_angle(storage)) - } - - /// The magnitude of the quaternion. - var length: Double { - simd_length(storage) - } - - /// The square of the length of the quaternion. This is less expensive to compute than the length itself. - var lengthSquared: Double { - simd_length_squared(storage.vector) - } - - /// Computes the dot-product of this quaternion and another. - /// - Parameter a: The quaternion with which to compute the dot product. - /// - Returns: The dot product of the two quaternions. - func dot(_ q: Quaternion) -> Double { - simd_dot(storage, q.storage) - } - - /// Returns the normalized quaternion. - /// - Returns: The normalized quaternion (with a length of `1`) or ``zero`` if the length is `0`. - func normalized() -> Quaternion { - if storage.vector == .zero { - return self - } - return .init(storage: simd_normalize(storage)) - } - - /// Performs a spherical interpolation between two quaternions. - /// - Parameters: - /// - q: A quaternion to interpolate with. - /// - t: The normalized extent of interpolation, from 0 to 1. - /// - Returns: The interpolated quaternion. - func slerp(_ q: Quaternion, _ t: Double) -> Quaternion { - .init(storage: simd_slerp(storage, q.storage, t)) - } - - /// Returns the reverse quaternion rotation. - static prefix func - (q: Quaternion) -> Quaternion { - .init(storage: simd_inverse(q.storage)) - } - - /// Returns the sum of two quaternion rotations. - static func + (lhs: Quaternion, rhs: Quaternion) -> Quaternion { - .init(storage: lhs.storage + rhs.storage) - } - - /// Adds the quaternion rotation on the right to the one on the left. - static func += (lhs: inout Quaternion, rhs: Quaternion) { - lhs.storage += rhs.storage - } - - /// Returns the difference between two quaternion rotations,. - static func - (lhs: Quaternion, rhs: Quaternion) -> Quaternion { - .init(storage: lhs.storage - rhs.storage) - } - - /// Subtracts the quaternion rotation on the right from the one on the left. - static func -= (lhs: inout Quaternion, rhs: Quaternion) { - lhs.storage -= rhs.storage - } - - /// Returns the product of two quaternions (i.e. the effect of rotating the left by the right). - static func * (lhs: Quaternion, rhs: Quaternion) -> Quaternion { - .init(storage: lhs.storage * rhs.storage) - } - - /// Multiplies the quaternion rotation on the left by the one on the right. - static func *= (lhs: inout Quaternion, rhs: Quaternion) { - lhs.storage *= rhs.storage - } - - /// Returns a quaternion with its components multiplied by the specified value. - static func * (lhs: Quaternion, rhs: Double) -> Quaternion { - .init(storage: lhs.storage * rhs) - } - - /// Multiplies the components of the quaternion by the specified value. - static func *= (lhs: inout Quaternion, rhs: Double) { - lhs.storage *= rhs - } - - /// Returns a quaternion with its components divided by the specified value. - static func / (lhs: Quaternion, rhs: Double) -> Quaternion { - .init(storage: lhs.storage / rhs) - } - - /// Divides the components of the vector by the specified value. - static func /= (lhs: inout Quaternion, rhs: Double) { - lhs.storage /= rhs - } -} - -@available(*, deprecated) -extension Quaternion { - init(unchecked x: Double, _ y: Double, _ z: Double, _ w: Double) { - self.init(storage: simd_quatd(vector: simd_double4(x, y, z, w))) - assert(isNormalized || lengthSquared == 0) - } - - init(unchecked axis: Vector, angle: Angle) { - assert(axis.isNormalized) - self.init(storage: simd_quatd( - angle: -angle.radians, - axis: .init(axis.x, axis.y, axis.z) - )) - } -} - -#else - -/// An orientation or rotation in 3D space. -/// -/// A quaternion can be created from a from a ``Rotation`` matrix, or directly from an axis vector and -/// angle, or a from a set of 3 Euler angles (pitch, yaw and roll). -/// -/// In addition to being more compact than a 3x3 rotation matrix, quaternions also avoid a -/// problem known as gymbal lock. -@available(*, deprecated, message: "Use Rotation instead") -public struct Quaternion: Hashable, Sendable { - /// The quaternion component values. - public var x, y, z, w: Double - - /// Creates a quaternion from raw component values. - public init(_ x: Double, _ y: Double, _ z: Double, _ w: Double) { - self.x = x - self.y = y - self.z = z - self.w = w - self = normalized() - } -} - -@available(*, deprecated) -public extension Quaternion { - /// The axis of rotation. - var axis: Vector { - let s = sqrt(1 - w * w) - guard s > epsilon else { - // if angle close to zero, direction is not important - return .unitZ - } - return Vector(x, y, z) / -s - } - - /// The angle of rotation. - var angle: Angle { - .radians(2 * acos(w)) - } - - /// The magnitude of the quaternion. - var length: Double { - sqrt(lengthSquared) - } - - /// The square of the length of the quaternion. This is less expensive to compute than the length itself. - var lengthSquared: Double { - dot(self) - } - - /// Computes the dot-product of this quaternion and another. - /// - Parameter a: The quaternion with which to compute the dot product. - /// - Returns: The dot product of the two quaternions. - func dot(_ q: Quaternion) -> Double { - x * q.x + y * q.y + z * q.z + w * q.w - } - - /// Returns the normalized quaternion. - /// - Returns: The normalized quaternion (with a length of `1`) or ``zero`` if the length is `0`. - func normalized() -> Quaternion { - let lengthSquared = self.lengthSquared - if lengthSquared == 0 || lengthSquared == 1 { - return self - } - return self / sqrt(lengthSquared) - } - - /// Performs a spherical linear interpolation between two quaternions. - /// - Parameters: - /// - q: The quaternion to interpolate towards. - /// - t: The normalized extent of interpolation, from 0 to 1. - /// - Returns: The interpolated quaternion. - func slerp(_ q: Quaternion, _ t: Double) -> Quaternion { - let dot = max(-1, min(1, self.dot(q))) - if abs(abs(dot) - 1) < epsilon { - return (self + (q - self) * t).normalized() - } - - let theta = acos(dot) * t - let t1 = self * cos(theta) - let t2 = (q - (self * dot)).normalized() * sin(theta) - return t1 + t2 - } - - /// Returns the reverse quaternion rotation. - static prefix func - (q: Quaternion) -> Quaternion { - .init(unchecked: q.x, q.y, q.z, -q.w) - } - - /// Returns the sum of two quaternion rotations. - static func + (lhs: Quaternion, rhs: Quaternion) -> Quaternion { - .init(unchecked: lhs.x + rhs.x, lhs.y + rhs.y, lhs.z + rhs.z, lhs.w + rhs.w) - } - - /// Adds the quaternion rotation on the right to the one on the left. - static func += (lhs: inout Quaternion, rhs: Quaternion) { - lhs = lhs + rhs - } - - /// Returns the difference between two quaternion rotations,. - static func - (lhs: Quaternion, rhs: Quaternion) -> Quaternion { - .init(unchecked: lhs.x - rhs.x, lhs.y - rhs.y, lhs.z - rhs.z, lhs.w - rhs.w) - } - - /// Subtracts the quaternion rotation on the right from the one on the left. - static func -= (lhs: inout Quaternion, rhs: Quaternion) { - lhs = lhs - rhs - } - - /// Returns the product of two quaternions (i.e. the effect of rotating the left by the right). - static func * (lhs: Quaternion, rhs: Quaternion) -> Quaternion { - .init( - unchecked: - lhs.w * rhs.x + lhs.x * rhs.w + lhs.y * rhs.z - lhs.z * rhs.y, - lhs.w * rhs.y + lhs.y * rhs.w + lhs.z * rhs.x - lhs.x * rhs.z, - lhs.w * rhs.z + lhs.z * rhs.w + lhs.x * rhs.y - lhs.y * rhs.x, - lhs.w * rhs.w - lhs.x * rhs.x - lhs.y * rhs.y - lhs.z * rhs.z - ) - } - - /// Multiplies the quaternion rotation on the left by the one on the right. - static func *= (lhs: inout Quaternion, rhs: Quaternion) { - lhs = lhs * rhs - } - - /// Returns a quaternion with its components multiplied by the specified value. - static func * (lhs: Quaternion, rhs: Double) -> Quaternion { - .init(unchecked: lhs.x * rhs, lhs.y * rhs, lhs.z * rhs, lhs.w * rhs) - } - - /// Multiplies the components of the quaternion by the specified value. - static func *= (lhs: inout Quaternion, rhs: Double) { - lhs.x *= rhs - lhs.y *= rhs - lhs.z *= rhs - lhs.w *= rhs - } - - /// Returns a quaternion with its components divided by the specified value. - static func / (lhs: Quaternion, rhs: Double) -> Quaternion { - .init(unchecked: lhs.x / rhs, lhs.y / rhs, lhs.z / rhs, lhs.w / rhs) - } - - /// Divides the components of the vector by the specified value. - static func /= (lhs: inout Quaternion, rhs: Double) { - lhs.x /= rhs - lhs.y /= rhs - lhs.z /= rhs - lhs.w /= rhs - } -} - -@available(*, deprecated) -extension Quaternion { - init(unchecked x: Double, _ y: Double, _ z: Double, _ w: Double) { - self.x = x - self.y = y - self.z = z - self.w = w - assert(isNormalized || lengthSquared == 0) - } - - init(unchecked axis: Vector, angle: Angle) { - assert(axis.isNormalized) - let r = -angle / 2 - let a = axis * sin(r) - self.init(unchecked: a.x, a.y, a.z, cos(r)) - } -} - -#endif - -@available(*, deprecated) -extension Quaternion: Codable { - private enum CodingKeys: CodingKey { - case x, y, z, w - } - - /// Creates a new quaternion by decoding from the given decoder. - /// - Parameter decoder: The decoder to read data from. - public init(from decoder: Decoder) throws { - if var container = try? decoder.unkeyedContainer() { - switch container.count { - case 0: - self = .identity - default: - try self.init(from: &container) - } - } else { - let container = try decoder.container(keyedBy: CodingKeys.self) - let x = try container.decodeIfPresent(Double.self, forKey: .x) ?? 0 - let y = try container.decodeIfPresent(Double.self, forKey: .y) ?? 0 - let z = try container.decodeIfPresent(Double.self, forKey: .z) ?? 0 - let w = try container.decodeIfPresent(Double.self, forKey: .w) ?? 1 - self.init(x, y, z, w) - } - } - - /// Encodes this quaternion into the given encoder. - /// - Parameter encoder: The encoder to write data to. - public func encode(to encoder: Encoder) throws { - var container = encoder.unkeyedContainer() - if self == .identity { - return - } - try encode(to: &container) - } -} - -@available(*, deprecated) -public extension Quaternion { - /// The zero quaternion. - static let zero = Quaternion(unchecked: 0, 0, 0, 0) - /// The identity quaternion (i.e. no rotation). - static let identity = Quaternion(unchecked: 0, 0, 0, 1) - - /// Creates a quaternion from an axis and angle. - /// - Parameters: - /// - axis: A vector defining the axis of rotation. - /// - angle: The angle of rotation around the axis. - init?(axis: Vector, angle: Angle) { - let length = axis.length - guard length.isFinite, length > epsilon else { - return nil - } - self.init(unchecked: axis / length, angle: angle) - } - - /// Creates a quaternion representing a rotation around the X axis. - /// - Parameter rotation: The angle to rotate by. - static func pitch(_ rotation: Angle) -> Quaternion { - let r = -rotation.radians * 0.5 - return .init(unchecked: sin(r), 0, 0, cos(r)) - } - - /// Creates a quaternion representing a rotation around the Y axis. - /// - Parameter rotation: The angle to rotate by. - static func yaw(_ rotation: Angle) -> Quaternion { - let r = -rotation.radians * 0.5 - return .init(unchecked: 0, sin(r), 0, cos(r)) - } - - /// Creates a quaternion representing a rotation around the Z axis. - /// - Parameter rotation: The angle to rotate by. - static func roll(_ rotation: Angle) -> Quaternion { - let r = -rotation.radians * 0.5 - return .init(unchecked: 0, 0, sin(r), cos(r)) - } - - /// Creates a rotation from Euler angles applied in roll/yaw/pitch order. - /// - Parameters: - /// - roll: The angle of rotation around the Z axis. This is applied first. - /// - yaw: The angle of rotation around the Y axis. This is applied second. - /// - pitch: The angle of rotation around the X axis. This is applied last. - init(roll: Angle = .zero, yaw: Angle = .zero, pitch: Angle = .zero) { - self = .roll(roll) * .yaw(yaw) * .pitch(pitch) - } - - /// Creates a quaternion from a rotation matrix. - /// - Parameter rotation: A rotation matrix. - init(_ rotation: Rotation) { - self = rotation.quaternion - } - - /// Creates a quaternion from raw components. - /// - Parameter components: An array of 4 floating-point values. - init?(_ components: [Double]) { - guard components.count == 4 else { - return nil - } - self.init(components[0], components[1], components[2], components[3]) - } - - /// Quaternion has no effect. - var isIdentity: Bool { - abs(1 - w) < epsilon - } - - /// A Boolean value that indicates whether the quaternion has a length of `1`. - var isNormalized: Bool { - abs(lengthSquared - 1) < epsilon - } - - /// An array containing the raw components of the quaternion. - var components: [Double] { - [x, y, z, w] - } - - /// The angle of rotation around the Z-axis. - var roll: Angle { - -.atan2(y: 2 * (w * z + x * y), x: 1 - 2 * (y * y + z * z)) - } - - /// The angle of rotation around the Y-axis. - var yaw: Angle { - -.asin(min(1, max(-1, 2 * (w * y - z * x)))) - } - - /// The angle of rotation around the X-axis. - var pitch: Angle { - -.atan2(y: 2 * (w * x + y * z), x: 1 - 2 * (x * x + y * y)) - } -} - -@available(*, deprecated) -extension Quaternion: UnkeyedCodable { - func encode(to container: inout UnkeyedEncodingContainer) throws { - try container.encode(x) - try container.encode(y) - try container.encode(z) - try container.encode(w) - } - - init(from container: inout UnkeyedDecodingContainer) throws { - let x = try container.decode(Double.self) - let y = try container.decode(Double.self) - let z = try container.decode(Double.self) - let w = try container.decode(Double.self) - self.init(x, y, z, w) - } -} - -@available(*, deprecated) -extension Quaternion { - /// Approximate equality - func isEqual(to other: Quaternion, withPrecision p: Double = epsilon) -> Bool { - w.isEqual(to: other.w, withPrecision: p) && - x.isEqual(to: other.x, withPrecision: p) && - y.isEqual(to: other.y, withPrecision: p) && - z.isEqual(to: other.z, withPrecision: p) - } -} diff --git a/Sources/Rotation.swift b/Sources/Rotation.swift index 3edc1cf5..c5eb76c6 100644 --- a/Sources/Rotation.swift +++ b/Sources/Rotation.swift @@ -371,6 +371,14 @@ public extension Rotation { self.init(unchecked: axis / length, angle: angle) } + /// Creates a rotation from an axis and angle. + /// - Parameters: + /// - axis: A direction defining the axis of rotation. + /// - end: The angle of rotation around the axis. + init(axis: Direction, angle: Angle) { + self.init(unchecked: Vector(axis), angle: angle) + } + /// Creates a rotation around the X axis. /// - Parameter rotation: The angle to rotate by. static func pitch(_ rotation: Angle) -> Rotation { @@ -484,16 +492,3 @@ extension Rotation { z.isEqual(to: other.z, withPrecision: p) } } - -@available(*, deprecated) -extension Rotation { - var quaternion: Quaternion { - .init(x, y, z, w) - } - - /// Creates a rotation from a quaternion. - /// - Parameter quaternion: A quaternion defining a rotation. - public init(_ quaternion: Quaternion) { - self.init(quaternion.x, quaternion.y, quaternion.z, quaternion.w) - } -} diff --git a/Sources/Stretchable.swift b/Sources/Stretchable.swift index 680510c1..71bcf663 100644 --- a/Sources/Stretchable.swift +++ b/Sources/Stretchable.swift @@ -30,26 +30,26 @@ // /// Protocol for stretchable types. -protocol Stretchable { +public protocol Stretchable { /// Returns a stretched copy of the value. /// - Parameters /// - scaleFactor: A scale factor to apply to the value. /// - along: The axis along which to apply the scale factor. - func stretched(by scaleFactor: Double, along: Line) -> Self + func stretched(by scaleFactor: Double, along: Direction) -> Self } -extension Stretchable { +public extension Stretchable { /// Stretch the value in place. /// - Parameters /// - scaleFactor: A scale factor to apply to the value. /// - along: The axis along which to apply the scale factor. - mutating func stretch(by scaleFactor: Double, along: Line) { + mutating func stretch(by scaleFactor: Double, along: Direction) { self = stretched(by: scaleFactor, along: along) } } extension Path: Stretchable { - func stretched(by scaleFactor: Double, along: Line) -> Path { + public func stretched(by scaleFactor: Double, along: Direction) -> Path { Path( unchecked: points.map { $0.stretched(by: scaleFactor, along: along) }, plane: nil, @@ -59,7 +59,7 @@ extension Path: Stretchable { } extension PathPoint: Stretchable { - func stretched(by scaleFactor: Double, along: Line) -> PathPoint { + public func stretched(by scaleFactor: Double, along: Direction) -> PathPoint { PathPoint( position: position.stretched(by: scaleFactor, along: along), texcoord: texcoord, @@ -70,13 +70,13 @@ extension PathPoint: Stretchable { } extension Vector: Stretchable { - func stretched(by scaleFactor: Double, along: Line) -> Vector { - self + project(onto: along) * (scaleFactor - 1) + public func stretched(by scaleFactor: Double, along: Direction) -> Vector { + self + along * dot(Vector(along)) * (scaleFactor - 1) } } -extension Array where Element: Stretchable { - func stretched(by scaleFactor: Double, along: Line) -> Self { +public extension Array where Element: Stretchable { + func stretched(by scaleFactor: Double, along: Direction) -> Self { map { $0.stretched(by: scaleFactor, along: along) } } } diff --git a/Sources/Transforms.swift b/Sources/Transforms.swift index 0593e3a4..0e88f08e 100644 --- a/Sources/Transforms.swift +++ b/Sources/Transforms.swift @@ -91,22 +91,6 @@ public extension Transformable { self = transformed(by: transform) } - /// Returns a rotated copy of the value. - /// - Parameter quaternion: A rotation to apply to the value. - @_disfavoredOverload - @available(*, deprecated) - func rotated(by quaternion: Quaternion) -> Self { - rotated(by: Rotation(quaternion)) - } - - /// Rotate the value in place. - /// - Parameter quaternion: A rotation to apply to the value. - @_disfavoredOverload - @available(*, deprecated) - mutating func rotate(by quaternion: Quaternion) { - self = rotated(by: quaternion) - } - /// Returns a transformed copy of the value. static func * (lhs: Self, rhs: Transform) -> Self { lhs.transformed(by: rhs) diff --git a/Sources/Vector.swift b/Sources/Vector.swift index 8aca9373..628a7c2c 100644 --- a/Sources/Vector.swift +++ b/Sources/Vector.swift @@ -31,6 +31,37 @@ import Foundation +/// Protocol for types that can be converted to XYZ components. +public protocol XYZConvertible { + /// Get XYZ vector components. + var xyzComponents: (x: Double, y: Double, z: Double) { get } +} + +/// Protocol for types that can be represented by XYZ vector components. +public protocol XYZRepresentable: XYZConvertible { + /// Initialize with XYZ components. + /// - Parameters: + /// - x: The X component of the vector. + /// - y: The Y component of the vector. + /// - z: The Z component of the vector. + init(x: Double, y: Double, z: Double) +} + +public extension XYZRepresentable { + /// Initialize with some XYZConvertible value. + init(_ value: T) { + let components = value.xyzComponents + self.init(x: components.x, y: components.y, z: components.z) + } + + /// Initialize with any XYZConvertible value. + @_disfavoredOverload + init(_ value: XYZConvertible) { + let components = value.xyzComponents + self.init(x: components.x, y: components.y, z: components.z) + } +} + /// A distance or position in 3D space. /// /// > Note: Euclid doesn't have a 2D vector type. When working with primarily 2D shapes, such as @@ -55,20 +86,34 @@ public struct Vector: Hashable, Sendable, AdditiveArithmetic { } } +extension Vector: XYZRepresentable { + public var xyzComponents: (x: Double, y: Double, z: Double) { + (x, y, z) + } + + public init(x: Double = 0, y: Double = 0, z: Double = 0) { + self.init(x, y, z) + } +} + +extension Vector: ExpressibleByArrayLiteral { + public init(arrayLiteral elements: Double...) { + self.init(elements) + } +} + +extension Vector: CustomStringConvertible { + public var description: String { + "Vector(\(x), \(y)\(z == 0 ? "" : ", \(z)"))" + } +} + 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 f8719297..6f191491 100644 --- a/Sources/Vertex.swift +++ b/Sources/Vertex.swift @@ -59,11 +59,55 @@ public struct Vertex: Hashable, Sendable { /// - color: The optional vertex color (defaults to white). public init( _ position: Vector, - _ normal: Vector? = nil, + _ normal: Vector, _ texcoord: Vector? = nil, _ color: Color? = nil ) { - self.init(unchecked: position, normal?.normalized(), texcoord, color) + self.init(unchecked: position, normal.normalized(), texcoord, color) + } + + /// Creates a new vertex. + /// - Parameters: + /// - position: The position of the vertex in 3D space. + /// - normal: The surface normal for the vertex (defaults to nil). + /// - texcoord: The optional texture coordinates for the vertex (defaults to zero). + /// - color: The optional vertex color (defaults to white). + public init( + _ position: Vector, + _ normal: Direction? = nil, + _ texcoord: Vector? = nil, + _ color: Color? = nil + ) { + self.init(unchecked: position, normal.map(Vector.init), texcoord, color) + } +} + +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: ExpressibleByArrayLiteral { + public init(arrayLiteral elements: Double...) { + self.init(Vector(elements)) + } +} + +extension Vertex: CustomStringConvertible { + public var description: String { + let p = "[\(position.x), \(position.y)\(position.z == 0 ? "" : ", \(position.z)")]" + let t = texcoord == .zero ? "" : + ", [\(texcoord.x), \(texcoord.y)\(texcoord.z == 0 ? "" : ", \(texcoord.z)")]" + let n = texcoord == .zero && normal == .zero ? "" : + ", [\(normal.x), \(normal.y), \(normal.z)]" + let c = color == .white ? "" : ", \(color)" + return "Vertex(\(p)\(n)\(t)\(c))" } } @@ -88,7 +132,7 @@ extension Vertex: Codable { let container = try decoder.container(keyedBy: CodingKeys.self) try self.init( container.decode(Vector.self, forKey: .position), - container.decodeIfPresent(Vector.self, forKey: .normal), + container.decodeIfPresent(Direction.self, forKey: .normal), container.decodeIfPresent(Vector.self, forKey: .texcoord), container.decodeIfPresent(Color.self, forKey: .color) ) @@ -170,6 +214,25 @@ public extension Vertex { vertex.color = color ?? .white return vertex } + + /// Reflects the vertex along a plane. + /// - Parameter plane: The ``Plane`` against which the vertices are to be reflected. + /// - Returns: A ``Vertex`` representing the reflected vertex. + func reflect(along plane: Plane) -> Self { + let p = position.project(onto: plane) + let d = position - p + + // https://math.stackexchange.com/questions/13261/how-to-get-a-reflection-vector + // 𝑟=𝑑−2(𝑑⋅𝑛)𝑛 + let n = plane.normal - 2.0 * plane.normal.dot(normal) * normal + + return Self( + p - d, + n, + texcoord, + color + ) + } } extension Vertex { @@ -254,7 +317,7 @@ extension Collection where Element == Vertex { map { $0.withTexcoord(transform($0.texcoord)) } } - func mapVertexColors(_ transform: (Color) -> Color?) -> [Vertex] { + func mapColors(_ transform: (Color) -> Color?) -> [Vertex] { map { $0.withColor(transform($0.color)) } } diff --git a/Tests/CodingTests.swift b/Tests/CodingTests.swift index f1ad147e..6af6d5f6 100644 --- a/Tests/CodingTests.swift +++ b/Tests/CodingTests.swift @@ -955,29 +955,3 @@ class CodingTests: XCTestCase { XCTAssert(try rotation.isEqual(to: decode(encoded))) } } - -@available(*, deprecated) -extension CodingTests { - // MARK: Quaternion - - func testDecodingIdentityQuaternion() { - XCTAssertEqual(try decode("[]"), Quaternion.identity) - XCTAssertEqual(try decode("{}"), Quaternion.identity) - } - - func testEncodingIdentityQuaternion() { - XCTAssertEqual(try encode(Quaternion.identity), "[]") - } - - func testEncodingAndDecodingQuaternion() throws { - let q = Quaternion(axis: .unitX, angle: .radians(2))! - let encoded = try encode(q) - XCTAssert(try q.isEqual(to: decode(encoded))) - } - - func testEncodingAndDecodingPitchYawRollQuaternion() throws { - let q = Quaternion(roll: .degrees(30), yaw: .degrees(20), pitch: .degrees(10)) - let encoded = try encode(q) - XCTAssert(try q.isEqual(to: decode(encoded))) - } -} diff --git a/Tests/MeshExportTests.swift b/Tests/MeshExportTests.swift index a86a08e1..e124f5c9 100644 --- a/Tests/MeshExportTests.swift +++ b/Tests/MeshExportTests.swift @@ -14,92 +14,92 @@ class MeshExportTests: XCTestCase { func testCubeSTL() { let cube = Mesh.cube().translated(by: Vector(0.5, 0.5, 0.5)) - let stl = cube.stlString(name: "Foo") + let stl = cube.stlString(options: .init(name: "Foo", indent: " ")) XCTAssertEqual(stl, """ solid Foo facet normal 1 0 0 - \touter loop - \t\tvertex 1 0 1 - \t\tvertex 1 0 0 - \t\tvertex 1 1 0 - \tendloop + outer loop + vertex 1 0 1 + vertex 1 0 0 + vertex 1 1 0 + endloop endfacet facet normal 1 0 0 - \touter loop - \t\tvertex 1 0 1 - \t\tvertex 1 1 0 - \t\tvertex 1 1 1 - \tendloop + outer loop + vertex 1 0 1 + vertex 1 1 0 + vertex 1 1 1 + endloop endfacet facet normal -1 0 0 - \touter loop - \t\tvertex 0 0 0 - \t\tvertex 0 0 1 - \t\tvertex 0 1 1 - \tendloop + outer loop + vertex 0 0 0 + vertex 0 0 1 + vertex 0 1 1 + endloop endfacet facet normal -1 0 0 - \touter loop - \t\tvertex 0 0 0 - \t\tvertex 0 1 1 - \t\tvertex 0 1 0 - \tendloop + outer loop + vertex 0 0 0 + vertex 0 1 1 + vertex 0 1 0 + endloop endfacet facet normal 0 1 0 - \touter loop - \t\tvertex 0 1 1 - \t\tvertex 1 1 1 - \t\tvertex 1 1 0 - \tendloop + outer loop + vertex 0 1 1 + vertex 1 1 1 + vertex 1 1 0 + endloop endfacet facet normal 0 1 0 - \touter loop - \t\tvertex 0 1 1 - \t\tvertex 1 1 0 - \t\tvertex 0 1 0 - \tendloop + outer loop + vertex 0 1 1 + vertex 1 1 0 + vertex 0 1 0 + endloop endfacet facet normal 0 -1 0 - \touter loop - \t\tvertex 0 0 0 - \t\tvertex 1 0 0 - \t\tvertex 1 0 1 - \tendloop + outer loop + vertex 0 0 0 + vertex 1 0 0 + vertex 1 0 1 + endloop endfacet facet normal 0 -1 0 - \touter loop - \t\tvertex 0 0 0 - \t\tvertex 1 0 1 - \t\tvertex 0 0 1 - \tendloop + outer loop + vertex 0 0 0 + vertex 1 0 1 + vertex 0 0 1 + endloop endfacet facet normal 0 0 1 - \touter loop - \t\tvertex 0 0 1 - \t\tvertex 1 0 1 - \t\tvertex 1 1 1 - \tendloop + outer loop + vertex 0 0 1 + vertex 1 0 1 + vertex 1 1 1 + endloop endfacet facet normal 0 0 1 - \touter loop - \t\tvertex 0 0 1 - \t\tvertex 1 1 1 - \t\tvertex 0 1 1 - \tendloop + outer loop + vertex 0 0 1 + vertex 1 1 1 + vertex 0 1 1 + endloop endfacet facet normal 0 0 -1 - \touter loop - \t\tvertex 1 0 0 - \t\tvertex 0 0 0 - \t\tvertex 0 1 0 - \tendloop + outer loop + vertex 1 0 0 + vertex 0 0 0 + vertex 0 1 0 + endloop endfacet facet normal 0 0 -1 - \touter loop - \t\tvertex 1 0 0 - \t\tvertex 0 1 0 - \t\tvertex 1 1 0 - \tendloop + outer loop + vertex 1 0 0 + vertex 0 1 0 + vertex 1 1 0 + endloop endfacet endsolid Foo """) @@ -149,6 +149,30 @@ class MeshExportTests: XCTestCase { """) } + func testCubeSTLDataWithCustomHeader() { + let cube = Mesh.cube().translated(by: Vector(0.5, 0.5, 0.5)) + let header = "Hello World".data(using: .utf8)! + let stlData = cube.stlData(options: .init(header: header)) + XCTAssertEqual(stlData.count, 80 + 4 + 12 * 50) + XCTAssertEqual(stlData.prefix(header.count), header) + let hex = stlData.reduce(into: "") { $0 += String(format: "%02x", $1) } + XCTAssertEqual(hex, """ + 48656c6c6f20576f726c6400000000000000000000000000000000000000000000000000000000000000000000000000000000000000000\ + 00000000000000000000000000000000000000000000000000c0000000000803f00000000000000000000803f000000000000803f000080\ + 3f00000000000000000000803f0000803f0000000000000000803f00000000000000000000803f000000000000803f0000803f0000803f0\ + 00000000000803f0000803f0000803f0000000080bf000000800000008000000000000000000000000000000000000000000000803f0000\ + 00000000803f0000803f0000000080bf0000008000000080000000000000000000000000000000000000803f0000803f000000000000803\ + f000000000000000000000000803f00000000000000000000803f0000803f0000803f0000803f0000803f0000803f0000803f0000000000\ + 00000000000000803f00000000000000000000803f0000803f0000803f0000803f00000000000000000000803f000000000000000000800\ + 00080bf000000800000000000000000000000000000803f00000000000000000000803f000000000000803f000000000080000080bf0000\ + 00800000000000000000000000000000803f000000000000803f00000000000000000000803f000000000000000000000000803f0000000\ + 0000000000000803f0000803f000000000000803f0000803f0000803f0000803f000000000000000000000000803f000000000000000000\ + 00803f0000803f0000803f0000803f000000000000803f0000803f00000000008000000080000080bf0000803f000000000000000000000\ + 0000000000000000000000000000000803f0000000000000000008000000080000080bf0000803f0000000000000000000000000000803f\ + 000000000000803f0000803f000000000000 + """) + } + // MARK: OBJ export func testCubeOBJ() { @@ -169,12 +193,19 @@ class MeshExportTests: XCTestCase { vt 1 0 vt 0 0 - f 1/1 2/2 3/3 4/4 - f 5/1 6/2 7/3 8/4 - f 7/1 4/2 3/3 8/4 - f 5/1 2/2 1/3 6/4 - f 6/1 1/2 4/3 7/4 - f 2/1 5/2 8/3 3/4 + vn 1 0 0 + vn -1 0 0 + vn 0 1 0 + vn 0 -1 0 + vn 0 0 1 + vn 0 0 -1 + + f 1/1/1 2/2/1 3/3/1 4/4/1 + f 5/1/2 6/2/2 7/3/2 8/4/2 + f 7/1/3 4/2/3 3/3/3 8/4/3 + f 5/1/4 2/2/4 1/3/4 6/4/4 + f 6/1/5 1/2/5 4/3/5 7/4/5 + f 2/1/6 5/2/6 8/3/6 3/4/6 """) } diff --git a/Tests/MeshTests.swift b/Tests/MeshTests.swift index 1e9dcd7a..0b2aeb9a 100644 --- a/Tests/MeshTests.swift +++ b/Tests/MeshTests.swift @@ -342,4 +342,42 @@ class MeshTests: XCTestCase { let sphere = Mesh.sphere().smoothingNormals(forAnglesGreaterThan: .zero) XCTAssertFalse(sphere.hasVertexNormals) } + + func testInvertedMeshContainsPoint() { + let insidePoints = [Vector(-1, -1, -1)] + let outsidePoints = [Vector.zero] + let mesh = Mesh.sphere().inverted() + let bsp = BSP(mesh) { false } + for point in insidePoints { + XCTAssertTrue(mesh.containsPoint(point)) + XCTAssertTrue(bsp.containsPoint(point)) + } + for point in outsidePoints { + XCTAssertFalse(mesh.containsPoint(point)) + XCTAssertFalse(bsp.containsPoint(point)) + } + } + + // MARK: Reflection + + func testQuadReflectionAlongPlane() { + let quad = Polygon(unchecked: [ + Vertex(Vector(-0.5, 1.0, 0.5), .unitY, Vector(0.0, 1.0), .black), + Vertex(Vector(0.5, 1.0, 0.5), .unitY, Vector(1.0, 1.0), .black), + Vertex(Vector(0.5, 1.0, -0.5), .unitY, Vector(1.0, 0.0), .white), + Vertex(Vector(-0.5, 1.0, -0.5), .unitY, Vector(0.0, 0.0), .white), + ]) + + let expected = Polygon(unchecked: [ + Vertex(Vector(-0.5, -1.0, -0.5), -.unitY, Vector(0.0, 0.0), .white), + Vertex(Vector(0.5, -1.0, -0.5), -.unitY, Vector(1.0, 0.0), .white), + Vertex(Vector(0.5, -1.0, 0.5), -.unitY, Vector(1.0, 1.0), .black), + Vertex(Vector(-0.5, -1.0, 0.5), -.unitY, Vector(0.0, 1.0), .black), + ]) + + let reflection = quad.reflect(along: .xz) + + XCTAssertEqual(reflection.plane.normal, -.unitY) + XCTAssertEqual(reflection.vertices, expected.vertices) + } } 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..2aa809b8 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() { @@ -519,6 +532,44 @@ class PathTests: XCTestCase { XCTAssertEqual(vertices[5].normal, Vector(0, -1)) } + // MARK: inset + + func testInsetSquare() { + let path = Path.square() + let result = path.inset(by: 0.25) + XCTAssertEqual(result, .square(size: 0.5)) + } + + func testInsetCircle() { + let path = Path.circle(segments: 4) + let result = path.inset(by: 0.25) + let adjacent = sqrt(pow(0.5, 2) * 2) / 2 + let radius = sqrt(pow(adjacent - 0.25, 2) * 2) + XCTAssertEqual(result, .circle(radius: radius, segments: 4)) + } + + func testInsetLShape() { + let path = Path([ + .point(0, 0), + .point(0, 2), + .point(1, 2), + .point(1, 1), + .point(2, 1), + .point(2, 0), + .point(0, 0), + ]) + let result = path.inset(by: 0.25) + XCTAssertEqual(result, Path([ + .point(0.25, 0.25), + .point(0.25, 1.75), + .point(0.75, 1.75), + .point(0.75, 0.75), + .point(1.75, 0.75), + .point(1.75, 0.25), + .point(0.25, 0.25), + ])) + } + // MARK: Y-axis clipping func testClipClosedClockwiseTriangleToRightOfAxis() { 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]) diff --git a/Tests/PolygonTests.swift b/Tests/PolygonTests.swift index b8928ace..512a537b 100644 --- a/Tests/PolygonTests.swift +++ b/Tests/PolygonTests.swift @@ -9,7 +9,7 @@ @testable import Euclid import XCTest -extension Euclid.Polygon { +extension Euclid.Polygon: ExpressibleByArrayLiteral { /// Convenience constructor for testing init(unchecked vertices: [Vertex], plane: Plane? = nil) { self.init( @@ -26,6 +26,10 @@ extension Euclid.Polygon { let normal = faceNormalForPolygonPoints(points, convex: nil, closed: nil) self.init(unchecked: points.map { Vertex($0, normal) }) } + + public init(arrayLiteral elements: Vector...) { + self.init(unchecked: elements) + } } class PolygonTests: XCTestCase { @@ -927,14 +931,13 @@ class PolygonTests: XCTestCase { } func testPolygonWithCollinearPointsCorrectlyDetessellated() { - let normal = -Vector.unitZ - let polygon = Polygon(unchecked: [ - Vertex(Vector(0, 0), normal), - Vertex(Vector(0.5, 0), normal), - Vertex(Vector(0.5, 1), normal), - Vertex(Vector(-0.5, 1), normal), - Vertex(Vector(-0.5, 0), normal), - ]) + let polygon: Euclid.Polygon = [ + [0, 0], + [0.5, 0], + [0.5, 1], + [-0.5, 1], + [-0.5, 0], + ] let triangles = polygon.triangulate() XCTAssertEqual(triangles.count, 3) let result = triangles.detessellate() @@ -944,53 +947,33 @@ class PolygonTests: XCTestCase { } func testHouseShapedPolygonCorrectlyDetessellated() { - let normal = -Vector.unitZ - let polygon = Polygon(unchecked: [ - Vertex(Vector(0, 0.5), normal), - Vertex(Vector(1, 0), normal), - Vertex(Vector(0.5, 0), normal), - Vertex(Vector(0.5, -1), normal), - Vertex(Vector(-0.5, -1), normal), - Vertex(Vector(-0.5, 0), normal), - Vertex(Vector(-1, 0), normal), - ]) + let polygon: Euclid.Polygon = [ + [0, 0.5], + [1, 0], + [0.5, 0], + [0.5, -1], + [-0.5, -1], + [-0.5, 0], + [-1, 0], + ] let triangles = polygon.triangulate() XCTAssertEqual(triangles.count, 5) let result = triangles.detessellate() XCTAssertEqual(result.count, 1) XCTAssertEqual(result.first?.undirectedEdges, polygon.undirectedEdges) + XCTAssert(result.flatMap { $0.vertices }.allSatisfy { $0.normal == -.unitZ }) XCTAssertEqual(Set(result.first?.vertices ?? []), Set(polygon.vertices)) } func testNonWatertightPolygonsCorrectlyDetessellated() { - let normal = -Vector.unitZ - let triangles = [ - Polygon(unchecked: [ - Vertex(Vector(0, -1), normal), - Vertex(Vector(-2, 0), normal), - Vertex(Vector(2, 0), normal), - ]), - Polygon(unchecked: [ - Vertex(Vector(-2, 0), normal), - Vertex(Vector(0, 1), normal), - Vertex(Vector(0, 0), normal), - ]), - Polygon(unchecked: [ - Vertex(Vector(2, 0), normal), - Vertex(Vector(0, 0), normal), - Vertex(Vector(0, 1), normal), - ]), + let triangles: [Euclid.Polygon] = [ + [[0, -1], [-2, 0], [2, 0]], + [[-2, 0], [0, 1], [0, 0]], + [[2, 0], [0, 0], [0, 1]], ] let result = triangles.detessellate() XCTAssertEqual(result.count, 1) - XCTAssertEqual(result, [ - Polygon(unchecked: [ - Vertex(Vector(0, -1), normal), - Vertex(Vector(-2, 0), normal), - Vertex(Vector(0, 1), normal), - Vertex(Vector(2, 0), normal), - ]), - ]) + XCTAssertEqual(result, [[[0, -1], [-2, 0], [0, 1], [2, 0]]]) } // MARK: area @@ -1114,4 +1097,44 @@ class PolygonTests: XCTestCase { ]) XCTAssert(polygon.isConvex) } + + // MARK: inset + + func testInsetSquare() { + let polygon = Polygon(unchecked: [ + Vector(-1, 1), + Vector(-1, -1), + Vector(1, -1), + Vector(1, 1), + ]) + let expected = Polygon(unchecked: [ + Vector(-0.75, 0.75), + Vector(-0.75, -0.75), + Vector(0.75, -0.75), + Vector(0.75, 0.75), + ]) + let result = polygon.inset(by: 0.25) + XCTAssertEqual(result, expected) + } + + func testInsetLShape() { + let polygon = Polygon(unchecked: [ + Vector(0, 0), + Vector(0, 2), + Vector(1, 2), + Vector(1, 1), + Vector(2, 1), + Vector(2, 0), + ]) + let expected = Polygon(unchecked: [ + Vector(0.25, 0.25), + Vector(0.25, 1.75), + Vector(0.75, 1.75), + Vector(0.75, 0.75), + Vector(1.75, 0.75), + Vector(1.75, 0.25), + ]) + let result = polygon.inset(by: 0.25) + XCTAssertEqual(result, expected) + } } diff --git a/Tests/QuaternionTests.swift b/Tests/QuaternionTests.swift deleted file mode 100644 index a4060625..00000000 --- a/Tests/QuaternionTests.swift +++ /dev/null @@ -1,142 +0,0 @@ -// -// QuaternionTests.swift -// Euclid -// -// Created by Nick Lockwood on 17/10/2022. -// Copyright © 2022 Nick Lockwood. All rights reserved. -// - -@testable import Euclid -import XCTest - -@available(*, deprecated) -class QuaternionTests: XCTestCase { - func testNormalizeZeroQuaternion() { - let q = Quaternion.zero - XCTAssertEqual(q.normalized(), .zero) - } - - func testReverseQuaternionRotation() { - let q = Quaternion(roll: -.pi * 0.5) - let q2 = Quaternion(roll: .pi * 1.5) - let vector = Vector(0.5, 0.5, 0.5) - XCTAssertEqual(vector.rotated(by: q), vector.rotated(by: q2)) - XCTAssertEqual(vector.rotated(by: q).rotated(by: -q), vector) - XCTAssertEqual(vector.rotated(by: q2).rotated(by: -q2), vector) - XCTAssertNotEqual(vector.rotated(by: q), vector.rotated(by: -q)) - XCTAssertNotEqual(vector.rotated(by: q2), vector.rotated(by: -q2)) - } - - func testAxisAngle() { - let q = Quaternion(unchecked: .unitX, angle: .halfPi) - XCTAssertEqual(q.axis, .unitX) - XCTAssert(q.angle.isEqual(to: .halfPi)) - } - - func testAxisAngle2() { - let q = Quaternion(unchecked: .unitY, angle: .pi * 0.75) - XCTAssertEqual(q.axis, .unitY) - XCTAssert(q.angle.isEqual(to: .pi * 0.75)) - } - - func testAxisAngle3() { - let q = Quaternion(unchecked: .unitZ, angle: .halfPi) - XCTAssertEqual(q.axis, .unitZ) - XCTAssert(q.angle.isEqual(to: .halfPi)) - } - - func testAxisAngle4() { - let q = Quaternion(unchecked: .unitZ, angle: .zero) - XCTAssertEqual(q.axis, .unitZ) - XCTAssert(q.angle.isZero) - } - - func testAxisAngleRotation() { - let q = Quaternion(unchecked: .unitZ, angle: .halfPi) - let v = Vector(0, 0.5, 0) - let u = v.rotated(by: q) - let w = v.rotated(by: Rotation(q)) - XCTAssertEqual(u, w) - XCTAssertEqual(u, Vector(0.5, 0, 0)) - } - - func testAxisAngleRotation2() { - let q = Quaternion(unchecked: .unitZ, angle: .halfPi) - let v = Vector(0.5, 0, 0) - let u = v.rotated(by: q) - let w = v.rotated(by: Rotation(q)) - XCTAssertEqual(u, w) - XCTAssertEqual(u, Vector(0, -0.5, 0)) - } - - func testAxisAngleRotation3() { - let q = Quaternion(unchecked: .unitZ, angle: .halfPi) - let v = Vector(0, 0, 0.5) - let u = v.rotated(by: q) - let w = v.rotated(by: Rotation(q)) - XCTAssertEqual(u, w) - XCTAssertEqual(u, Vector(0, 0, 0.5)) - } - - func testQuaternionFromPitch() { - let q = Quaternion(pitch: .halfPi) - XCTAssertEqual(q.pitch.radians, .pi / 2, accuracy: epsilon) - XCTAssertEqual(q.yaw, .zero) - XCTAssertEqual(q.roll, .zero) - let v = Vector(0, 0.5, 0), u = Vector(0, 0, -0.5) - XCTAssertEqual(v.rotated(by: q), u) - } - - func testQuaternionFromYaw() { - let q = Quaternion(yaw: .halfPi) - XCTAssertEqual(q.yaw, .halfPi) - XCTAssertEqual(q.roll, .zero) - XCTAssertEqual(q.pitch, .zero) - let v = Vector(0.5, 0, 0), u = Vector(0, 0, 0.5) - XCTAssertEqual(v.rotated(by: q), u) - } - - func testQuaternionFromRoll() { - let q = Quaternion(roll: .halfPi) - XCTAssertEqual(q.roll.radians, .pi / 2, accuracy: epsilon) - XCTAssertEqual(q.yaw, .zero) - XCTAssertEqual(q.pitch, .zero) - let v = Vector(0, 0.5, 0), u = Vector(0.5, 0, 0) - XCTAssertEqual(v.rotated(by: q), u) - } - - func testQuaternionFromRollYawPitch() { - let roll = Angle.radians(2.31) - let yaw = Angle.radians(0.2) - let pitch = Angle.radians(1.12) - let q = Quaternion(roll: roll, yaw: yaw, pitch: pitch) - XCTAssertEqual(roll.radians, q.roll.radians, accuracy: epsilon) - XCTAssertEqual(yaw.radians, q.yaw.radians, accuracy: epsilon) - XCTAssertEqual(pitch.radians, q.pitch.radians, accuracy: epsilon) - } - - func testQuaternionToAndFromRotation() { - let roll = Angle.radians(2.31) - let yaw = Angle.radians(0.2) - let pitch = Angle.radians(1.12) - let q = Quaternion(roll: roll, yaw: yaw, pitch: pitch) - let r = Rotation(q) - let q2 = Quaternion(r) - XCTAssert(q.isEqual(to: q2)) - XCTAssertEqual(q2.roll.radians, q.roll.radians, accuracy: epsilon) - XCTAssertEqual(q2.yaw.radians, q.yaw.radians, accuracy: epsilon) - XCTAssertEqual(q2.pitch.radians, q.pitch.radians, accuracy: epsilon) - } - - func testQuaternionVectorRotation() { - let q = Quaternion(pitch: .halfPi) - let r = Rotation(pitch: .halfPi) - let r2 = Rotation(q) - let q2 = Quaternion(r) - let v = Vector(0, 0.5, 0), u = Vector(0, 0, -0.5) - XCTAssertEqual(v.rotated(by: q), u) - XCTAssertEqual(v.rotated(by: q2), u) - XCTAssertEqual(v.rotated(by: r), u) - XCTAssertEqual(v.rotated(by: r2), u) - } -} diff --git a/Tests/SceneKitTests.swift b/Tests/SceneKitTests.swift index d5edbe82..5cd02b52 100644 --- a/Tests/SceneKitTests.swift +++ b/Tests/SceneKitTests.swift @@ -111,15 +111,15 @@ class SceneKitTests: XCTestCase { func testExportCube() { let cube = Mesh.cube() let geometry = SCNGeometry(polygons: cube) - XCTAssertEqual(geometry.sources.count, 2) - XCTAssertEqual(geometry.sources.first?.vectorCount, 20) + XCTAssertEqual(geometry.sources.count, 3) + XCTAssertEqual(geometry.sources.first?.vectorCount, 24) } func testExportCubeWithoutTexcoords() { let cube = Mesh.cube().withoutTexcoords() let geometry = SCNGeometry(polygons: cube) - XCTAssertEqual(geometry.sources.count, 1) - XCTAssertEqual(geometry.sources.first?.vectorCount, 8) + XCTAssertEqual(geometry.sources.count, 2) + XCTAssertEqual(geometry.sources.first?.vectorCount, 24) } func testExportSphere() { @@ -136,13 +136,6 @@ class SceneKitTests: XCTestCase { XCTAssertEqual(geometry.sources.first?.vectorCount, 151) } - func testExportSphereWithoutTexcoordsOrNormals() { - let sphere = Mesh.sphere().withoutTexcoords().smoothingNormals(forAnglesGreaterThan: .zero) - let geometry = SCNGeometry(polygons: sphere) - XCTAssertEqual(geometry.sources.count, 1) - XCTAssertEqual(geometry.sources.first?.vectorCount, 114) - } - func testExportMeshWithColors() throws { let mesh = Mesh.lathe(.curve([ .point(.unitY, color: .red), diff --git a/Tests/StretchableTests.swift b/Tests/StretchableTests.swift index 4c03cba8..bf323779 100644 --- a/Tests/StretchableTests.swift +++ b/Tests/StretchableTests.swift @@ -9,36 +9,26 @@ @testable import Euclid import XCTest -private extension Stretchable { - func stretched(by scaleFactor: Double, along: Vector) -> Self { - assert(along.isNormalized) - return stretched(by: scaleFactor, along: Line( - unchecked: .zero, - direction: along.normalized() - )) - } -} - class StretchableTests: XCTestCase { // MARK: Rotation func testStretchPoint() { let p = Vector(1, 1) - let q = p.stretched(by: 1.5, along: .unitY) + let q = p.stretched(by: 1.5, along: .y) XCTAssertEqual(q, Vector(1, 1.5)) - let r = p.stretched(by: 1.5, along: -.unitY) + let r = p.stretched(by: 1.5, along: -.y) XCTAssertEqual(r, Vector(1, 1.5)) - let s = p.stretched(by: 1.5, along: .unitX) + let s = p.stretched(by: 1.5, along: .x) XCTAssertEqual(s, Vector(1.5, 1)) - let t = p.stretched(by: 1.5, along: -.unitX) + let t = p.stretched(by: 1.5, along: -.x) XCTAssertEqual(t, Vector(1.5, 1)) } func testStretchPath() { let p = Path.circle() - let q = p.stretched(by: 1.5, along: .unitY) + let q = p.stretched(by: 1.5, along: .y) XCTAssert(q.isEqual(to: p.scaled(by: Vector(1, 1.5, 1)))) - let r = p.stretched(by: 1.5, along: .unitX) + let r = p.stretched(by: 1.5, along: .x) XCTAssert(r.isEqual(to: p.scaled(by: Vector(1.5, 1, 1)))) } } diff --git a/Tests/TransformTests.swift b/Tests/TransformTests.swift index bf7c2b07..b9cd0b5b 100644 --- a/Tests/TransformTests.swift +++ b/Tests/TransformTests.swift @@ -74,18 +74,6 @@ class TransformTests: XCTestCase { XCTAssertEqual(pitch.radians, r.pitch.radians, accuracy: epsilon) } - @available(*, deprecated) - func testRotationToQuaternion() { - let roll = Angle.radians(2.31) - let yaw = Angle.radians(0.2) - let pitch = Angle.radians(1.12) - let r = Rotation(roll: roll, yaw: yaw, pitch: pitch) - let q = Quaternion(r) - XCTAssertEqual(q.roll.radians, r.roll.radians, accuracy: 0.01) - XCTAssertEqual(q.yaw.radians, r.yaw.radians, accuracy: 0.01) - XCTAssertEqual(q.pitch.radians, r.pitch.radians, accuracy: 0.01) - } - func testRotationDoesntAffectNormalization() { let v = Vector( -0.9667550262674225,