diff --git a/.gitignore b/.gitignore index 5224bcd3..d90f9ae8 100644 --- a/.gitignore +++ b/.gitignore @@ -5,3 +5,4 @@ xcuserdata/ /.swiftpm +/.claude/settings.local.json diff --git a/Sources/Cadova/Abstract Layer/2D/Circle/Arc.swift b/Sources/Cadova/Abstract Layer/2D/Circle/Arc.swift index 2ca29aab..18ab0b96 100644 --- a/Sources/Cadova/Abstract Layer/2D/Circle/Arc.swift +++ b/Sources/Cadova/Abstract Layer/2D/Circle/Arc.swift @@ -9,7 +9,10 @@ import Foundation /// let arcWithDiameter = Arc(range: 0°..<90°, diameter: 10) /// ``` public struct Arc: Shape2D { + /// The angular range of the arc. public let range: Range + + /// The radius of the arc. public let radius: Double /// Creates a new `Arc` instance with the specified range of angles and radius. @@ -30,7 +33,7 @@ public struct Arc: Shape2D { } public var body: any Geometry2D { - @Environment(\.segmentation) var segmentation + @Environment(\.scaledSegmentation) var segmentation Polygon([.zero] + arcPoints(segmentation: segmentation)) } @@ -45,6 +48,9 @@ public struct Arc: Shape2D { } extension Arc: Area { + /// The angular span of the arc. public var angularDistance: Angle { range.length } + + /// The area of the circular sector. public var area: Double { radius * radius * .pi * (angularDistance / 360°) } } diff --git a/Sources/Cadova/Abstract Layer/2D/Circle/Circle.swift b/Sources/Cadova/Abstract Layer/2D/Circle/Circle.swift index bc70ea6a..96620a6b 100644 --- a/Sources/Cadova/Abstract Layer/2D/Circle/Circle.swift +++ b/Sources/Cadova/Abstract Layer/2D/Circle/Circle.swift @@ -12,6 +12,7 @@ public struct Circle { /// The diameter of the circle. public let diameter: Double + /// The radius of the circle (half of the diameter). public var radius: Double { diameter / 2 } /// Creates a new `Circle` instance with the specified diameter. @@ -50,7 +51,7 @@ public struct Circle { extension Circle: Shape2D { public var body: any Geometry2D { - @Environment(\.segmentation) var segmentation + @Environment(\.scaledSegmentation) var segmentation NodeBasedGeometry(.shape(.circle(radius: radius, segmentCount: segmentation.segmentCount(circleRadius: radius)))) } } diff --git a/Sources/Cadova/Abstract Layer/2D/Circle/Ring.swift b/Sources/Cadova/Abstract Layer/2D/Circle/Ring.swift index 978dc962..f25b5925 100644 --- a/Sources/Cadova/Abstract Layer/2D/Circle/Ring.swift +++ b/Sources/Cadova/Abstract Layer/2D/Circle/Ring.swift @@ -10,7 +10,10 @@ import Foundation /// let ring = Ring(outerDiameter: 10, innerDiameter: 4) /// ``` public struct Ring: Shape2D { + /// The outer diameter of the ring. public let outerDiameter: Double + + /// The inner diameter of the ring (the hole). public let innerDiameter: Double /// Creates a new `Ring` instance with the specified outer and inner diameters. @@ -84,6 +87,7 @@ public struct Ring: Shape2D { } extension Ring: Area { + /// The area of the ring (outer circle minus inner circle). public var area: Double { Circle(diameter: outerDiameter).area - Circle(diameter: innerDiameter).area } diff --git a/Sources/Cadova/Abstract Layer/2D/Metrics2D.swift b/Sources/Cadova/Abstract Layer/2D/Metrics2D.swift index b3af4b02..8bb64a4e 100644 --- a/Sources/Cadova/Abstract Layer/2D/Metrics2D.swift +++ b/Sources/Cadova/Abstract Layer/2D/Metrics2D.swift @@ -1,14 +1,33 @@ import Foundation +/// A type that has a measurable area. +/// +/// Conforming types provide an `area` property representing the enclosed surface area. +/// Many 2D shapes in Cadova conform to this protocol, including ``Circle``, ``Rectangle``, +/// ``RegularPolygon``, and others. +/// public protocol Area { + /// The enclosed area of the shape. var area: Double { get } } +/// A type that has a measurable perimeter. +/// +/// Conforming types provide a `perimeter` property representing the total length of +/// the shape's boundary. Many 2D shapes in Cadova conform to this protocol, including +/// ``Circle`` (where it represents circumference), ``Rectangle``, ``RegularPolygon``, and others. +/// public protocol Perimeter { + /// The total length of the shape's boundary. var perimeter: Double { get } } public extension Area { + /// Calculates the volume of a pyramid with this shape as the base. + /// + /// - Parameter height: The height of the pyramid from base to apex. + /// - Returns: The volume of the pyramid. + /// func pyramidVolume(height: Double) -> Double { return (area * height) / 3.0 } diff --git a/Sources/Cadova/Abstract Layer/2D/Polygon/PolygonPoints.swift b/Sources/Cadova/Abstract Layer/2D/Polygon/PolygonPoints.swift index 5ff0dd3b..8dbf16e2 100644 --- a/Sources/Cadova/Abstract Layer/2D/Polygon/PolygonPoints.swift +++ b/Sources/Cadova/Abstract Layer/2D/Polygon/PolygonPoints.swift @@ -10,7 +10,7 @@ internal indirect enum PolygonPoints: Sendable, Hashable, Codable { func points(in environment: EnvironmentValues) -> [Vector2D] { switch self { case .literal (let array): array - case .curve (let curve): curve.curve.points(segmentation: environment.segmentation) + case .curve (let curve): curve.curve.points(segmentation: environment.scaledSegmentation) case .transformed (let polygonPoints, let transform): polygonPoints.points(in: environment) .map { transform.apply(to: $0) } diff --git a/Sources/Cadova/Abstract Layer/2D/RegularPolygon.swift b/Sources/Cadova/Abstract Layer/2D/RegularPolygon.swift index ec8e37a1..45fcecf5 100644 --- a/Sources/Cadova/Abstract Layer/2D/RegularPolygon.swift +++ b/Sources/Cadova/Abstract Layer/2D/RegularPolygon.swift @@ -66,10 +66,12 @@ public extension RegularPolygon { } extension RegularPolygon: Area, Perimeter { + /// The area of the polygon. public var area: Double { Double(sideCount) / 2.0 * pow(circumradius, 2) * sin(360° / Double(sideCount)) } + /// The perimeter of the polygon. public var perimeter: Double { Double(sideCount) * sideLength } diff --git a/Sources/Cadova/Abstract Layer/2D/Stadium.swift b/Sources/Cadova/Abstract Layer/2D/Stadium.swift index 4e4908fa..2eae6db0 100644 --- a/Sources/Cadova/Abstract Layer/2D/Stadium.swift +++ b/Sources/Cadova/Abstract Layer/2D/Stadium.swift @@ -54,11 +54,13 @@ public struct Stadium: Shape2D { } extension Stadium: Area, Perimeter { + /// The area of the stadium. public var area: Double { let diameter = min(size.x, size.y) return Double.pi * (diameter / 2) * (diameter / 2) + (max(size.x, size.y) - diameter) * diameter } + /// The perimeter of the stadium. public var perimeter: Double { let diameter = min(size.x, size.y) return Double.pi * diameter + 2.0 * (max(size.x, size.y) - diameter) diff --git a/Sources/Cadova/Abstract Layer/2D/Text/GlyphRenderer.swift b/Sources/Cadova/Abstract Layer/2D/Text/GlyphRenderer.swift index 0c9c026b..63e24d49 100644 --- a/Sources/Cadova/Abstract Layer/2D/Text/GlyphRenderer.swift +++ b/Sources/Cadova/Abstract Layer/2D/Text/GlyphRenderer.swift @@ -77,7 +77,7 @@ internal class GlyphRenderer { FT_Outline_Decompose(&mutableOutline, &funcs, mutablePointer) } guard composeResult == 0 else { return nil } - return SimplePolygonList(paths.map { SimplePolygon($0.points(segmentation: environment.segmentation)) }) + return SimplePolygonList(paths.map { SimplePolygon($0.points(segmentation: environment.scaledSegmentation)) }) } } diff --git a/Sources/Cadova/Abstract Layer/2D/Text/Text.swift b/Sources/Cadova/Abstract Layer/2D/Text/Text.swift index 95e60403..8eb78be3 100644 --- a/Sources/Cadova/Abstract Layer/2D/Text/Text.swift +++ b/Sources/Cadova/Abstract Layer/2D/Text/Text.swift @@ -32,7 +32,7 @@ public struct Text: Shape2D { public var body: any Geometry2D { @Environment var environment @Environment(\.textAttributes) var textAttributes - @Environment(\.segmentation) var segmentation + @Environment(\.scaledSegmentation) var segmentation let attributes = textAttributes.applyingDefaults() CachedNode(name: "text", parameters: content, attributes, segmentation) { environment, context in diff --git a/Sources/Cadova/Abstract Layer/2D/Text/TextAttributes.swift b/Sources/Cadova/Abstract Layer/2D/Text/TextAttributes.swift index 145a4db2..ad7accf3 100644 --- a/Sources/Cadova/Abstract Layer/2D/Text/TextAttributes.swift +++ b/Sources/Cadova/Abstract Layer/2D/Text/TextAttributes.swift @@ -15,13 +15,15 @@ internal struct TextAttributes: Sendable, Hashable, Codable { var fontFile: URL? var horizontalAlignment: HorizontalTextAlignment? var verticalAlignment: VerticalTextAlignment? + var lineSpacingAdjustment: Double? - init(fontFace: FontFace? = nil, fontSize: Double? = nil, fontFile: URL? = nil, horizontalAlignment: HorizontalTextAlignment? = nil, verticalAlignment: VerticalTextAlignment? = nil) { + init(fontFace: FontFace? = nil, fontSize: Double? = nil, fontFile: URL? = nil, horizontalAlignment: HorizontalTextAlignment? = nil, verticalAlignment: VerticalTextAlignment? = nil, lineSpacingAdjustment: Double? = nil) { self.fontFace = fontFace self.fontSize = fontSize self.fontFile = fontFile self.horizontalAlignment = horizontalAlignment self.verticalAlignment = verticalAlignment + self.lineSpacingAdjustment = lineSpacingAdjustment } func applyingDefaults() -> Self { @@ -30,7 +32,8 @@ internal struct TextAttributes: Sendable, Hashable, Codable { fontSize: fontSize ?? 12, fontFile: fontFile, horizontalAlignment: horizontalAlignment ?? .left, - verticalAlignment: verticalAlignment ?? .lastBaseline + verticalAlignment: verticalAlignment ?? .lastBaseline, + lineSpacingAdjustment: lineSpacingAdjustment ?? 0 ) } } diff --git a/Sources/Cadova/Abstract Layer/2D/Text/TextRendering.swift b/Sources/Cadova/Abstract Layer/2D/Text/TextRendering.swift index 8b0b3a71..87285e62 100644 --- a/Sources/Cadova/Abstract Layer/2D/Text/TextRendering.swift +++ b/Sources/Cadova/Abstract Layer/2D/Text/TextRendering.swift @@ -74,7 +74,8 @@ extension TextAttributes { FT_Done_Face(face) FT_Done_FreeType(library) - let lineHeight = Double(metrics.height) / 64.0 / GlyphRenderer.scaleFactor + let baseLineHeight = Double(metrics.height) / 64.0 / GlyphRenderer.scaleFactor + let lineHeight = baseLineHeight + (lineSpacingAdjustment ?? 0) let ascender = Double(metrics.ascender) / 64.0 / GlyphRenderer.scaleFactor let descender = Double(metrics.descender) / 64.0 / GlyphRenderer.scaleFactor let horizontalAdjustment = horizontalAlignment!.adjustmentFactor diff --git a/Sources/Cadova/Abstract Layer/3D/Box.swift b/Sources/Cadova/Abstract Layer/3D/Box.swift index f2e9e36c..183fefd0 100644 --- a/Sources/Cadova/Abstract Layer/3D/Box.swift +++ b/Sources/Cadova/Abstract Layer/3D/Box.swift @@ -1,7 +1,8 @@ import Foundation -/// A rectangular cuboid shape +/// A rectangular cuboid shape. public struct Box: Geometry { + /// The dimensions of the box along each axis. public let size: Vector3D /// Initializes a new box with specific dimensions and centering options. diff --git a/Sources/Cadova/Abstract Layer/3D/Cylinder.swift b/Sources/Cadova/Abstract Layer/3D/Cylinder.swift index 7bdc32cb..e3b9e3be 100644 --- a/Sources/Cadova/Abstract Layer/3D/Cylinder.swift +++ b/Sources/Cadova/Abstract Layer/3D/Cylinder.swift @@ -11,12 +11,17 @@ import Foundation /// ``` public struct Cylinder: Geometry { + /// The height of the cylinder along the Z axis. public let height: Double + + /// The radius at the bottom of the cylinder (at Z = 0). public let bottomRadius: Double + + /// The radius at the top of the cylinder (at Z = height). public let topRadius: Double public func build(in environment: EnvironmentValues, context: EvaluationContext) async throws -> D3.BuildResult { - @Environment(\.segmentation) var segmentation + @Environment(\.scaledSegmentation) var segmentation let segmentCount = segmentation.segmentCount(circleRadius: max(bottomRadius, topRadius)) if height < .ulpOfOne { diff --git a/Sources/Cadova/Abstract Layer/3D/Import.swift b/Sources/Cadova/Abstract Layer/3D/Import.swift deleted file mode 100644 index 07b494fa..00000000 --- a/Sources/Cadova/Abstract Layer/3D/Import.swift +++ /dev/null @@ -1,148 +0,0 @@ -import Foundation -import ThreeMF -import Manifold3D - -/// Imports geometry from an external 3MF file. -/// -/// Use `Import` to bring in geometry from existing 3MF models, either in full or by selecting specific parts. -/// This is useful for reusing designs, integrating CAD files, or combining Cadova-generated models with external assets. -/// -/// ```swift -/// Import(model: "fixtures/handle.3mf") -/// ``` -/// -/// You can also import individual parts by object name or part number: -/// -/// ```swift -/// Import( -/// model: "fixtures/handle.3mf", -/// parts: [ -/// .name("Knob"), -/// .partNumber("1234-XYZ") -/// ] -/// ) -/// ``` -/// -public struct Import: Shape3D { - private let url: URL - private let parts: [PartIdentifier]? - - /// Creates a new imported shape from a 3MF file URL. - /// - /// - Parameters: - /// - url: The file URL to the 3MF model. - /// - parts: An optional list of part identifiers to import. If omitted, all parts are imported. - /// - public init(model url: URL, parts: [PartIdentifier]? = nil) { - self.url = url - self.parts = parts - } - - /// Creates a new imported shape from a file path. - /// - /// - Parameters: - /// - path: A file path to the 3MF model. Can be relative or absolute. - /// - parts: An optional list of part identifiers to import. If omitted, all parts are imported. - /// - public init(model path: String, parts: [PartIdentifier]? = nil) { - self.init( - model: URL(expandingFilePath: path, extension: nil, relativeTo: nil), - parts: parts - ) - } - - /// Identifies a specific part of a 3MF model to import. - public enum PartIdentifier: CacheKey { - /// Matches an item by the name parameter of its referenced object. - case name (String) - - /// Matches an item by its `partnumber` attribute. - case partNumber (String) - } - - public enum Error: Swift.Error { - /// A requested part was not found in the model. - case missingPart (Import.PartIdentifier) - - var localizedDescription: String { - switch self { - case .missingPart (let partIdentifier): - "A part matching \(partIdentifier) was not found in the model." - } - } - } - - public var body: any Geometry3D { - CachedNode(name: "import", parameters: url, parts) { _, _ in - let loadedModel = try await ModelLoader(url: url).load() - let loadedItems = try loadedModel.loadedItems(for: parts) - return D3.Node.boolean(loadedItems.map { - $0.buildNode(model: loadedModel) - }, type: .union) - } - } -} - -internal extension ModelLoader.LoadedModel { - func loadedItems(for identifiers: [Import.PartIdentifier]?) throws -> [LoadedItem] { - var remainingItems = items - guard let identifiers else { return remainingItems } - - return try identifiers.map { identifier in - guard let itemIndex = remainingItems.firstIndex(where: { $0.matches(identifier) }) else { - throw Import.Error.missingPart(identifier) - } - return remainingItems.remove(at: itemIndex) - } - } -} - -internal extension ModelLoader.LoadedModel.LoadedItem { - func matches(_ identifier: Import.PartIdentifier) -> Bool { - switch identifier { - case .name (let name): rootObject.name == name - case .partNumber (let partNumber): item.partNumber == partNumber - } - } - - func buildNode(model: ModelLoader.LoadedModel) -> D3.Node { - .boolean(components.map { $0.buildNode(model: model) }, type: .union) - } -} - -internal extension ModelLoader.LoadedModel.LoadedComponent { - func buildNode(model: ModelLoader.LoadedModel) -> D3.Node { - let meshNode = D3.Node.shape(.mesh(MeshData(model.meshes[meshIndex].mesh))) - return if let transform = cadovaTransform { - .transform(meshNode, transform: transform) - } else { - meshNode - } - } - - var cadovaTransform: Transform3D? { - guard !transforms.isEmpty else { return nil } - return transforms.map(\.cadovaTransform) - .reduce(Transform3D.identity) { $0.concatenated(with: $1) } - } -} - -internal extension MeshData { - init(_ mesh: ThreeMF.Mesh) { - self.init( - vertices: mesh.vertices.map { Vector3D($0.x, $0.y, $0.z) }, - faces: mesh.triangles.map { [$0.v1, $0.v2, $0.v3] } - ) - } -} - -internal extension ThreeMF.Matrix3D { - var cadovaTransform: Transform3D { - Transform3D([ - [values[0][0], values[1][0], values[2][0], values[3][0]], - [values[0][1], values[1][1], values[2][1], values[3][1]], - [values[0][2], values[1][2], values[2][2], values[3][2]], - [0, 0, 0, 1] - ]) - } -} diff --git a/Sources/Cadova/Abstract Layer/3D/Import/Import.swift b/Sources/Cadova/Abstract Layer/3D/Import/Import.swift new file mode 100644 index 00000000..8be4edc0 --- /dev/null +++ b/Sources/Cadova/Abstract Layer/3D/Import/Import.swift @@ -0,0 +1,110 @@ +import Foundation + +/// Imports geometry from an external 3D model file. +/// +/// Use `Import` to bring in geometry from existing models. Supported formats are detected automatically +/// based on file contents: +/// - **3MF**: Full support including part selection by name or part number +/// - **STL**: Binary and ASCII formats (single mesh, no part selection) +/// +/// ```swift +/// Import(model: "fixtures/handle.3mf") +/// Import(model: "fixtures/bracket.stl") +/// ``` +/// +/// For 3MF files, you can import individual parts by object name or part number: +/// +/// ```swift +/// Import( +/// model: "fixtures/handle.3mf", +/// parts: [ +/// .name("Knob"), +/// .partNumber("1234-XYZ") +/// ] +/// ) +/// ``` +/// +public struct Import: Shape3D { + private let url: URL + private let parts: [PartIdentifier]? + + /// Creates a new imported shape from a model file URL. + /// + /// The file format is detected automatically from the file contents. + /// + /// - Parameters: + /// - url: The file URL to the model. + /// - parts: An optional list of part identifiers to import. Only supported for 3MF files. + /// If omitted, all parts are imported. + /// + public init(model url: URL, parts: [PartIdentifier]? = nil) { + self.url = url + self.parts = parts + } + + /// Creates a new imported shape from a file path. + /// + /// The file format is detected automatically from the file contents. + /// + /// - Parameters: + /// - path: A file path to the model. Can be relative or absolute. + /// - parts: An optional list of part identifiers to import. Only supported for 3MF files. + /// If omitted, all parts are imported. + /// + public init(model path: String, parts: [PartIdentifier]? = nil) { + self.init( + model: URL(expandingFilePath: path, extension: nil, relativeTo: nil), + parts: parts + ) + } + + /// Identifies a specific part of a 3MF model to import. + public enum PartIdentifier: CacheKey { + /// Matches an item by the name parameter of its referenced object. + case name (String) + + /// Matches an item by its `partnumber` attribute. + case partNumber (String) + } + + /// Errors that can occur when importing a model. + public enum Error: Swift.Error { + /// A requested part was not found in the model. + case missingPart (Import.PartIdentifier) + + /// Part selection was requested for a format that does not support it (e.g., STL). + case partsNotSupported + + /// The file format could not be recognized. + case unrecognizedFormat + + var localizedDescription: String { + switch self { + case .missingPart (let partIdentifier): + "A part matching \(partIdentifier) was not found in the model." + case .partsNotSupported: + "Part selection is only supported for 3MF files. STL files contain a single mesh." + case .unrecognizedFormat: + "The file format could not be recognized. Supported formats are 3MF and STL." + } + } + } + + public var body: any Geometry3D { + CachedNode(name: "import", parameters: url, parts) { _, _ in + guard let format = try ModelFileFormat.detect(at: url) else { + throw Error.unrecognizedFormat + } + + switch format { + case .threeMF: + return try await ThreeMFLoader(url: url, parts: parts).load() + case .stlBinary, .stlASCII: + if parts != nil { + throw Error.partsNotSupported + } + return try STLLoader(url: url).load() + } + } + } +} diff --git a/Sources/Cadova/Abstract Layer/3D/Import/ModelFileFormat.swift b/Sources/Cadova/Abstract Layer/3D/Import/ModelFileFormat.swift new file mode 100644 index 00000000..63848f7d --- /dev/null +++ b/Sources/Cadova/Abstract Layer/3D/Import/ModelFileFormat.swift @@ -0,0 +1,64 @@ +import Foundation + +/// Detected format of a 3D model file. +internal enum ModelFileFormat { + case threeMF + case stlBinary + case stlASCII + + /// Detects the format of a model file by examining its contents. + /// + /// - Parameter url: The file URL to examine. + /// - Returns: The detected format, or `nil` if the format is not recognized. + /// + static func detect(at url: URL) throws -> ModelFileFormat? { + let handle = try FileHandle(forReadingFrom: url) + defer { try? handle.close() } + + guard let header = try handle.read(upToCount: 80) else { + return nil + } + + return detectFromHeader(header) + } + + /// Detects the format of a model file by examining its contents. + /// + /// - Parameter data: The file data to examine. + /// - Returns: The detected format, or `nil` if the format is not recognized. + /// + static func detect(from data: Data) -> ModelFileFormat? { + detectFromHeader(data.prefix(80)) + } + + private static func detectFromHeader(_ header: some Collection) -> ModelFileFormat? { + guard header.count >= 4 else { return nil } + + let headerArray = Array(header.prefix(80)) + + // Check for ZIP signature (3MF files are ZIP archives) + // ZIP files start with PK\x03\x04 + if headerArray.starts(with: [0x50, 0x4B, 0x03, 0x04]) { + return .threeMF + } + + // Check for ASCII STL: starts with "solid " followed by a name + // Note: Some binary STL files may also start with "solid" in the header, + // so we need additional heuristics + if let headerString = String(bytes: headerArray, encoding: .ascii), + headerString.lowercased().hasPrefix("solid ") { + // Could be ASCII STL, but binary STL headers can also start with "solid" + // ASCII STL will have "facet normal" appearing after the solid line + // We'll mark it as potentially ASCII and verify later with more data + return .stlASCII + } + + // Default to binary STL if we have at least 80 bytes + // Binary STL has an 80-byte header followed by a 4-byte little-endian triangle count + if headerArray.count >= 80 { + return .stlBinary + } + + return nil + } +} diff --git a/Sources/Cadova/Abstract Layer/3D/Import/STLLoader.swift b/Sources/Cadova/Abstract Layer/3D/Import/STLLoader.swift new file mode 100644 index 00000000..19bc7fa7 --- /dev/null +++ b/Sources/Cadova/Abstract Layer/3D/Import/STLLoader.swift @@ -0,0 +1,202 @@ +import Foundation + +/// Loads STL files (both binary and ASCII formats) into geometry. +internal struct STLLoader { + let url: URL + + /// Loads an STL file and returns a geometry node. + /// + /// - Returns: The loaded geometry node. + /// - Throws: `STLLoader.Error` if the file cannot be parsed. + /// + func load() throws -> D3.Node { + let data = try Data(contentsOf: url) + return try load(from: data) + } + + /// Loads STL data and returns a geometry node. + /// + /// - Parameter data: The STL file contents. + /// - Returns: The loaded geometry node. + /// - Throws: `STLLoader.Error` if the data cannot be parsed. + /// + func load(from data: Data) throws -> D3.Node { + guard let format = ModelFileFormat.detect(from: data) else { + throw Error.unrecognizedFormat + } + + let meshData: MeshData + switch format { + case .stlBinary: + meshData = try loadBinary(from: data) + case .stlASCII: + meshData = try loadASCII(from: data) + case .threeMF: + throw Error.unrecognizedFormat + } + + return D3.Node.shape(.mesh(meshData)) + } + + enum Error: Swift.Error { + case unrecognizedFormat + case invalidBinaryHeader + case invalidTriangleCount + case unexpectedEndOfData + case invalidASCIISyntax(String) + } +} + +// MARK: - Binary STL + +private extension STLLoader { + /// Binary STL format: + /// - 80 bytes: Header (ignored, may contain any text) + /// - 4 bytes: Number of triangles (uint32, little-endian) + /// - For each triangle (50 bytes each): + /// - 12 bytes: Normal vector (3 x float32) + /// - 12 bytes: Vertex 1 (3 x float32) + /// - 12 bytes: Vertex 2 (3 x float32) + /// - 12 bytes: Vertex 3 (3 x float32) + /// - 2 bytes: Attribute byte count (usually 0, ignored) + /// + func loadBinary(from data: Data) throws -> MeshData { + guard data.count >= 84 else { + throw Error.invalidBinaryHeader + } + + let triangleCount = data.withUnsafeBytes { buffer in + buffer.loadUnaligned(fromByteOffset: 80, as: UInt32.self).littleEndian + } + + let expectedSize = 84 + Int(triangleCount) * 50 + guard data.count >= expectedSize else { + throw Error.invalidTriangleCount + } + + var vertices: [Vector3D] = [] + var faces: [[Int]] = [] + vertices.reserveCapacity(Int(triangleCount) * 3) + faces.reserveCapacity(Int(triangleCount)) + + // Using a dictionary to deduplicate vertices + var vertexIndices: [Vector3D: Int] = [:] + vertexIndices.reserveCapacity(Int(triangleCount) * 3) + + try data.withUnsafeBytes { buffer in + var offset = 84 + + for _ in 0.. MeshData { + guard let content = String(data: data, encoding: .utf8) + ?? String(data: data, encoding: .ascii) else { + throw Error.invalidASCIISyntax("Unable to decode file as text") + } + + var vertices: [Vector3D] = [] + var faces: [[Int]] = [] + var vertexIndices: [Vector3D: Int] = [:] + + var currentFaceVertices: [Int] = [] + var inLoop = false + + let lines = content.components(separatedBy: .newlines) + + for line in lines { + let trimmed = line.trimmingCharacters(in: .whitespaces).lowercased() + + if trimmed.hasPrefix("facet normal") || trimmed.hasPrefix("facet") { + currentFaceVertices = [] + } else if trimmed == "outer loop" { + inLoop = true + } else if trimmed.hasPrefix("vertex ") { + guard inLoop else { continue } + + let parts = trimmed.dropFirst(7).split(separator: " ", omittingEmptySubsequences: true) + guard parts.count >= 3, + let x = Double(parts[0]), + let y = Double(parts[1]), + let z = Double(parts[2]) else { + throw Error.invalidASCIISyntax("Invalid vertex: \(line)") + } + + let vertex = Vector3D(x, y, z) + + let index: Int + if let existingIndex = vertexIndices[vertex] { + index = existingIndex + } else { + index = vertices.count + vertices.append(vertex) + vertexIndices[vertex] = index + } + currentFaceVertices.append(index) + } else if trimmed == "endloop" { + inLoop = false + } else if trimmed == "endfacet" { + if currentFaceVertices.count >= 3 { + faces.append(currentFaceVertices) + } + currentFaceVertices = [] + } + } + + return MeshData(vertices: vertices, faces: faces) + } +} diff --git a/Sources/Cadova/Abstract Layer/3D/Import/ThreeMFLoader.swift b/Sources/Cadova/Abstract Layer/3D/Import/ThreeMFLoader.swift new file mode 100644 index 00000000..1229f9e6 --- /dev/null +++ b/Sources/Cadova/Abstract Layer/3D/Import/ThreeMFLoader.swift @@ -0,0 +1,80 @@ +import Foundation +import ThreeMF + +/// Loads 3MF files into mesh data. +internal struct ThreeMFLoader { + let url: URL + let parts: [Import.PartIdentifier]? + + func load() async throws -> D3.Node { + let loadedModel = try await ModelLoader(url: url).load() + let loadedItems = try loadedModel.loadedItems(for: parts) + return D3.Node.boolean(loadedItems.map { + $0.buildNode(model: loadedModel) + }, type: .union) + } +} + +internal extension ModelLoader.LoadedModel { + func loadedItems(for identifiers: [Import.PartIdentifier]?) throws -> [LoadedItem] { + var remainingItems = items + guard let identifiers else { return remainingItems } + + return try identifiers.map { identifier in + guard let itemIndex = remainingItems.firstIndex(where: { $0.matches(identifier) }) else { + throw Import.Error.missingPart(identifier) + } + return remainingItems.remove(at: itemIndex) + } + } +} + +internal extension ModelLoader.LoadedModel.LoadedItem { + func matches(_ identifier: Import.PartIdentifier) -> Bool { + switch identifier { + case .name (let name): rootObject.name == name + case .partNumber (let partNumber): item.partNumber == partNumber + } + } + + func buildNode(model: ModelLoader.LoadedModel) -> D3.Node { + .boolean(components.map { $0.buildNode(model: model) }, type: .union) + } +} + +internal extension ModelLoader.LoadedModel.LoadedComponent { + func buildNode(model: ModelLoader.LoadedModel) -> D3.Node { + let meshNode = D3.Node.shape(.mesh(MeshData(model.meshes[meshIndex].mesh))) + return if let transform = cadovaTransform { + .transform(meshNode, transform: transform) + } else { + meshNode + } + } + + var cadovaTransform: Transform3D? { + guard !transforms.isEmpty else { return nil } + return transforms.map(\.cadovaTransform) + .reduce(Transform3D.identity) { $0.concatenated(with: $1) } + } +} + +internal extension MeshData { + init(_ mesh: ThreeMF.Mesh) { + self.init( + vertices: mesh.vertices.map { Vector3D($0.x, $0.y, $0.z) }, + faces: mesh.triangles.map { [$0.v1, $0.v2, $0.v3] } + ) + } +} + +internal extension ThreeMF.Matrix3D { + var cadovaTransform: Transform3D { + Transform3D([ + [values[0][0], values[1][0], values[2][0], values[3][0]], + [values[0][1], values[1][1], values[2][1], values[3][1]], + [values[0][2], values[1][2], values[2][2], values[3][2]], + [0, 0, 0, 1] + ]) + } +} diff --git a/Sources/Cadova/Abstract Layer/3D/Mesh.swift b/Sources/Cadova/Abstract Layer/3D/Mesh.swift index 65e452d1..2d71c397 100644 --- a/Sources/Cadova/Abstract Layer/3D/Mesh.swift +++ b/Sources/Cadova/Abstract Layer/3D/Mesh.swift @@ -48,6 +48,9 @@ public struct Mesh: Shape3D { var vertices: [Vector3D] = [] var keyIndices: [Vertex: Int] = [:] + vertices.reserveCapacity(faces.count * 2) + keyIndices.reserveCapacity(faces.count * 2) + let indexedFaces = faces.map { $0.map { key in if let index = keyIndices[key] { diff --git a/Sources/Cadova/Abstract Layer/3D/SDF.swift b/Sources/Cadova/Abstract Layer/3D/SDF.swift index 8029494c..2911c5fe 100644 --- a/Sources/Cadova/Abstract Layer/3D/SDF.swift +++ b/Sources/Cadova/Abstract Layer/3D/SDF.swift @@ -1,6 +1,18 @@ import Foundation import Manifold3D +/// A 3D shape defined by sampling a signed distance function (SDF). +/// +/// `LevelSet` creates geometry by evaluating a scalar field over a 3D volume and extracting +/// the surface at a specified threshold (typically zero). This is useful for modeling smooth, +/// organic surfaces like metaballs or procedurally generated shapes. +/// +/// The shape uses the Marching Tetrahedra algorithm internally to extract a manifold surface +/// from the SDF samples. +/// +/// - Note: This is a computationally intensive operation. Use appropriate grid resolution +/// to balance quality and performance. +/// public struct LevelSet: Shape3D { let function: @Sendable (Vector3D) -> Double let bounds: BoundingBox3D diff --git a/Sources/Cadova/Abstract Layer/3D/Sphere.swift b/Sources/Cadova/Abstract Layer/3D/Sphere.swift index 8a3b1fe5..954ee352 100644 --- a/Sources/Cadova/Abstract Layer/3D/Sphere.swift +++ b/Sources/Cadova/Abstract Layer/3D/Sphere.swift @@ -27,7 +27,7 @@ public struct Sphere: Geometry { } public func build(in environment: EnvironmentValues, context: EvaluationContext) async throws -> D3.BuildResult { - @Environment(\.segmentation) var segmentation + @Environment(\.scaledSegmentation) var segmentation return .init(.shape(.sphere( radius: radius, diff --git a/Sources/Cadova/Abstract Layer/3D/Tube.swift b/Sources/Cadova/Abstract Layer/3D/Tube.swift index d832c4ac..01c4ac4d 100644 --- a/Sources/Cadova/Abstract Layer/3D/Tube.swift +++ b/Sources/Cadova/Abstract Layer/3D/Tube.swift @@ -2,8 +2,13 @@ import Foundation /// A hollow, three-dimensional cylinder with specified inner and outer diameters and height. public struct Tube: Shape3D { + /// The outer diameter of the tube. public let outerDiameter: Double + + /// The inner diameter of the tube (the hole). public let innerDiameter: Double + + /// The height of the tube along the Z axis. public let height: Double /// Creates a tube with specified outer and inner diameters and height. diff --git a/Sources/Cadova/Abstract Layer/Environment/Values/Environment+Text.swift b/Sources/Cadova/Abstract Layer/Environment/Values/Environment+Text.swift index b152d346..498172cd 100644 --- a/Sources/Cadova/Abstract Layer/Environment/Values/Environment+Text.swift +++ b/Sources/Cadova/Abstract Layer/Environment/Values/Environment+Text.swift @@ -105,6 +105,25 @@ public extension EnvironmentValues { get { textAttributes.verticalAlignment } set { textAttributes.verticalAlignment = newValue } } + + /// An adjustment to the spacing between lines of text, in millimeters. + /// + /// This value modifies the default line height determined by the font's metrics. + /// A positive value increases the space between lines, while a negative value + /// decreases it. + /// + /// The default is `0`, meaning standard line spacing is used. + /// + /// ```swift + /// Text("Line 1\nLine 2\nLine 3") + /// .withLineSpacing(2) // Add 2mm between lines + /// ``` + /// + /// - SeeAlso: `fontSize` + var lineSpacing: Double { + get { textAttributes.lineSpacingAdjustment ?? 0 } + set { textAttributes.lineSpacingAdjustment = newValue } + } } public extension Geometry { @@ -151,32 +170,64 @@ public extension Geometry { } } } + + /// Adjusts the spacing between lines of text. + /// + /// This modifier changes the vertical distance between lines in multiline text. + /// A positive value increases spacing, while a negative value decreases it. + /// + /// ```swift + /// Text("Hello\nWorld") + /// .withLineSpacing(5) // Add 5mm between lines + /// + /// Text("Compact\nText") + /// .withLineSpacing(-2) // Reduce spacing by 2mm + /// ``` + /// + /// - Parameter adjustment: The amount to adjust line spacing, in millimeters. + /// Positive values increase spacing, negative values decrease it. + /// - Returns: A new geometry with the adjusted line spacing. + func withLineSpacing(_ adjustment: Double) -> D.Geometry { + withEnvironment { + $0.lineSpacing = adjustment + } + } } +/// Horizontal alignment options for text relative to the origin. +/// +/// Use with ``Geometry/withTextAlignment(horizontal:vertical:)`` to control how +/// text is positioned horizontally relative to the X origin. +/// public enum HorizontalTextAlignment: Sendable, Hashable, Codable { - /// Aligns each line of text to the left edge. + /// Places the left edge of the text at the origin. case left - /// Centers each line of text horizontally. + /// Centers the text horizontally on the origin. case center - /// Aligns each line of text to the right edge. + /// Places the right edge of the text at the origin. case right } +/// Vertical alignment options for text relative to the origin. +/// +/// Use with ``Geometry/withTextAlignment(horizontal:vertical:)`` to control how +/// text is positioned vertically relative to the Y origin. +/// public enum VerticalTextAlignment: Sendable, Hashable, Codable { - /// Aligns the baseline of the first line of text to the origin. + /// Places the baseline of the first line at the origin. case firstBaseline - /// Aligns the baseline of the last line of text to the origin. + /// Places the baseline of the last line at the origin. case lastBaseline - /// Aligns the top of the text block to the origin. + /// Places the top of the text (ascender) at the origin. case top - /// Aligns the vertical center of the text block to the origin. + /// Centers the text vertically on the origin. case center - /// Aligns the bottom of the text block to the origin. + /// Places the bottom of the text (descender) at the origin. case bottom } diff --git a/Sources/Cadova/Abstract Layer/Environment/Values/Environment+Transform.swift b/Sources/Cadova/Abstract Layer/Environment/Values/Environment+Transform.swift index a791357c..70749823 100644 --- a/Sources/Cadova/Abstract Layer/Environment/Values/Environment+Transform.swift +++ b/Sources/Cadova/Abstract Layer/Environment/Values/Environment+Transform.swift @@ -29,3 +29,38 @@ public extension EnvironmentValues { setting(key: Self.environmentKey, value:newTransform.concatenated(with: transform)) } } + +public extension EnvironmentValues { + /// A single scalar that summarizes the overall scale of the current transform. + /// + /// This value is suitable for adapting tolerances and thresholds to the local coordinate system. + /// It is computed from the per‑axis scales (ignoring translation) by taking the maximum component. + /// For the identity transform, this is `1.0`. + /// + var scale: Double { + let s = transform.scale + return min(s.x, s.y, s.z) + } + + /// Returns a segmentation adjusted to the current environment scale. + /// + /// - For `.fixed`, the value is returned unchanged. + /// - For `.adaptive(minAngle:minSize:)`, the `minSize` is multiplied by `scale`. + /// + /// This helps keep geometric detail consistent under scaled coordinate systems. + var scaledSegmentation: Segmentation { + switch segmentation { + case .fixed: + return segmentation + case .adaptive(let minAngle, let minSize): + return .adaptive(minAngle: minAngle, minSize: minSize / scale) + } + } + + /// The environment’s tolerance scaled by the current transform’s scalar scale. + /// + /// Useful for adapting tolerances to the local coordinate system. + var scaledTolerance: Double { + tolerance / scale + } +} diff --git a/Sources/Cadova/Abstract Layer/Geometry/GeometryBuilder.swift b/Sources/Cadova/Abstract Layer/Geometry/GeometryBuilder.swift index 5d08316b..d84abf2f 100644 --- a/Sources/Cadova/Abstract Layer/Geometry/GeometryBuilder.swift +++ b/Sources/Cadova/Abstract Layer/Geometry/GeometryBuilder.swift @@ -1,5 +1,13 @@ import Foundation +/// A result builder for composing geometry using a declarative syntax. +/// +/// `GeometryBuilder` enables SwiftUI-style syntax for combining multiple geometries. +/// When multiple geometries are provided, they are automatically combined using a union operation. +/// +/// You typically use this through the ``GeometryBuilder2D`` or ``GeometryBuilder3D`` typealiases, +/// or indirectly through ``Shape2D`` and ``Shape3D`` body properties. +/// @resultBuilder public struct GeometryBuilder { public typealias G = any Geometry @@ -50,4 +58,10 @@ import Foundation } } +/// A result builder that collects geometry into an array without combining them. +/// +/// Unlike ``GeometryBuilder``, which unions multiple geometries into one, `SequenceBuilder` +/// preserves each geometry as a separate element. This is useful for operations that need +/// to process geometries individually. +/// public typealias SequenceBuilder = ArrayBuilder diff --git a/Sources/Cadova/Abstract Layer/Geometry/Protocols/Geometry.swift b/Sources/Cadova/Abstract Layer/Geometry/Protocols/Geometry.swift index cf45588d..9deb47c5 100644 --- a/Sources/Cadova/Abstract Layer/Geometry/Protocols/Geometry.swift +++ b/Sources/Cadova/Abstract Layer/Geometry/Protocols/Geometry.swift @@ -17,5 +17,8 @@ public typealias Geometry2D = Geometry public typealias Geometry3D = Geometry +/// A result builder for composing 2D geometry. public typealias GeometryBuilder2D = GeometryBuilder + +/// A result builder for composing 3D geometry. public typealias GeometryBuilder3D = GeometryBuilder diff --git a/Sources/Cadova/Abstract Layer/Geometry/Protocols/Shape.swift b/Sources/Cadova/Abstract Layer/Geometry/Protocols/Shape.swift index 4fc9cdd7..d5495953 100644 --- a/Sources/Cadova/Abstract Layer/Geometry/Protocols/Shape.swift +++ b/Sources/Cadova/Abstract Layer/Geometry/Protocols/Shape.swift @@ -1,5 +1,9 @@ import Foundation +/// Base protocol for custom shapes. +/// +/// Don't conform to this protocol directly; instead, use ``Shape2D`` or ``Shape3D``. +/// public protocol Shape: Geometry, Transformable where T == D.Transform, Transformed == D.Geometry { @GeometryBuilder var body: any Geometry { get } } diff --git a/Sources/Cadova/Abstract Layer/Geometry/References/Anchors+Public.swift b/Sources/Cadova/Abstract Layer/Geometry/References/Anchors+Public.swift index 00271341..52cc0d62 100644 --- a/Sources/Cadova/Abstract Layer/Geometry/References/Anchors+Public.swift +++ b/Sources/Cadova/Abstract Layer/Geometry/References/Anchors+Public.swift @@ -1,5 +1,25 @@ import Foundation +/// A value used to mark coordinate systems that can be referenced elsewhere in a model. +/// +/// Anchors provide a way to capture a transformation state at one location in your geometry tree +/// and later place other geometry at that same world-space position and orientation. This is useful +/// for attaching parts together, such as placing screws in predefined holes or mounting components +/// at specific locations. +/// +/// You create an anchor once (optionally with a human-readable label for debugging) and then define +/// it on geometry using ``Geometry3D/definingAnchor(_:at:offset:pointing:rotated:)``. Later, you can +/// use ``Geometry3D/anchored(to:)`` to place other geometry at the recorded transforms. +/// +/// - Multiple definitions: +/// - An anchor can be defined multiple times across a geometry tree. Each definition records a +/// separate world transform. When you call `anchored(to:)`, the geometry is duplicated at each +/// recorded location and orientation. +/// +/// - Undefined anchors: +/// - Referencing an anchor that has no definitions produces no geometry and prints a warning when +/// the model is fully built. +/// public struct Anchor: Hashable, Sendable, CustomStringConvertible { internal let id = UUID() internal let label: String? diff --git a/Sources/Cadova/Abstract Layer/Geometry/ResultElement/ResultElement.swift b/Sources/Cadova/Abstract Layer/Geometry/ResultElement/ResultElement.swift index ca18ee90..ff4a5469 100644 --- a/Sources/Cadova/Abstract Layer/Geometry/ResultElement/ResultElement.swift +++ b/Sources/Cadova/Abstract Layer/Geometry/ResultElement/ResultElement.swift @@ -1,7 +1,36 @@ import Foundation +/// A typed, mergeable piece of build metadata, traveling alongside the actual geometry +/// output. +/// +/// Result elements let you attach auxiliary information to the result of building +/// a geometry. They propagate through the pipeline together with the geometry’s +/// node representation. +/// +/// Conforming types must be `Sendable`, and provide: +/// - A default initializer used when a value of this type is requested but not present. +/// - A combining initializer to resolve multiple values of the same type (e.g., when +/// merging results from multiple children). +/// +/// Typical usage: +/// - Use helpers like `withResult(_:)` and `modifyingResult(_:modifier:)` to set or update +/// elements on a geometry. +/// - When multiple values are merged, elements of the same type are combined using +/// `init(combining:)`. +/// public protocol ResultElement: Sendable { + /// Creates a default value for this element type. + /// + /// Called when a build result does not contain a value of this type but one is requested. init() + + /// Creates a value by combining multiple instances of the same element type. + /// + /// This is used when multiple build results are merged and more than one value of this type + /// is present. Implementations should define a stable, deterministic merge policy + /// (such as last-wins, union, intersection, or accumulation) appropriate to the element’s meaning. + /// + /// - Parameter elements: The instances to be combined. init(combining: [Self]) } diff --git a/Sources/Cadova/Abstract Layer/Geometry/ResultElement/ResultModifier.swift b/Sources/Cadova/Abstract Layer/Geometry/ResultElement/ResultModifier.swift index ae47ee26..520ad0bc 100644 --- a/Sources/Cadova/Abstract Layer/Geometry/ResultElement/ResultModifier.swift +++ b/Sources/Cadova/Abstract Layer/Geometry/ResultElement/ResultModifier.swift @@ -23,12 +23,30 @@ struct ResultAndGeometryModifier: Geometry { } public extension Geometry { + /// Attaches or replaces a typed result element on the build result of this geometry. + /// + /// Result elements are typed metadata produced during a build. This method returns a geometry + /// that produces the same primary output as `self`, but with the given element set on the result. + /// If an element of the same type already exists, it is replaced. + /// + /// - Parameter value: The `ResultElement` value to store. + /// - Returns: A geometry that carries the provided result element. func withResult(_ value: E) -> D.Geometry { ResultModifier(body: self) { elements in elements.setting(value) } } + /// Updates a typed result element on the build result of this geometry. + /// + /// If the element is present, it is mutated in place; otherwise a default instance is created, + /// mutated, and stored. The returned geometry produces the same primary output as `self`, + /// but with the updated element attached to the result. + /// + /// - Parameters: + /// - type: The `ResultElement` type to modify. + /// - modifier: A closure that can mutate the element in place. + /// - Returns: A geometry that carries the modified result element. func modifyingResult( _ type: E.Type, modifier: @Sendable @escaping (inout E) -> Void diff --git a/Sources/Cadova/Abstract Layer/Operations/DeformByPatch.swift b/Sources/Cadova/Abstract Layer/Operations/DeformByPatch.swift index f512c6e8..37bf5c90 100644 --- a/Sources/Cadova/Abstract Layer/Operations/DeformByPatch.swift +++ b/Sources/Cadova/Abstract Layer/Operations/DeformByPatch.swift @@ -25,18 +25,17 @@ public extension Geometry3D { /// with its thickness stacked vertically on top of the patch’s surface. /// func deformed(by patch: BezierPatch) -> any Geometry3D { - readingEnvironment(\.segmentation) { _, segmentation in - measuringBounds { geometry, bounds in - let maxLength = max(bounds.size.x, bounds.size.y) + @Environment(\.scaledSegmentation) var segmentation + return measuringBounds { geometry, bounds in + let maxLength = max(bounds.size.x, bounds.size.y) - geometry - .refined(maxEdgeLength: maxLength / Double(segmentation.segmentCount(length: maxLength))) - .warped(operationName: "deformByPatch", cacheParameters: patch) { point in - let uv = ((point - bounds.minimum) / bounds.size).xy - return patch.point(at: uv) + .z(point.z) - } - .simplified() - } + geometry + .refined(maxEdgeLength: maxLength / Double(segmentation.segmentCount(length: maxLength))) + .warped(operationName: "deformByPatch", cacheParameters: patch) { point in + let uv = ((point - bounds.minimum) / bounds.size).xy + return patch.point(at: uv) + .z(point.z) + } + .simplified() } } } diff --git a/Sources/Cadova/Abstract Layer/Operations/DeformByPath.swift b/Sources/Cadova/Abstract Layer/Operations/DeformByPath.swift index e415a83c..ba062a61 100644 --- a/Sources/Cadova/Abstract Layer/Operations/DeformByPath.swift +++ b/Sources/Cadova/Abstract Layer/Operations/DeformByPath.swift @@ -14,7 +14,7 @@ public extension Geometry2D { /// func deformed>(by curve: Curve) -> D.Geometry { measuringBounds { body, bounds in - @Environment(\.segmentation) var segmentation + @Environment(\.scaledSegmentation) var segmentation let min = curve.parameter(matching: bounds.minimum.x, along: .x) ?? curve.domain.lowerBound let max = curve.parameter(matching: bounds.maximum.x, along: .x) ?? curve.domain.upperBound let approximateLength = curve[min...max].approximateLength @@ -55,7 +55,7 @@ public extension Geometry3D { /// func deformed>(by curve: Curve) -> D.Geometry { measuringBounds { body, bounds in - @Environment(\.segmentation) var segmentation + @Environment(\.scaledSegmentation) var segmentation let min = curve.parameter(matching: bounds.minimum.z, along: .z) ?? curve.domain.lowerBound let max = curve.parameter(matching: bounds.maximum.z, along: .z) ?? curve.domain.upperBound let approximateLength = curve[min...max].approximateLength diff --git a/Sources/Cadova/Abstract Layer/Operations/Edge Profiling/Masks/RoundedBoxCornerMask.swift b/Sources/Cadova/Abstract Layer/Operations/Edge Profiling/Masks/RoundedBoxCornerMask.swift index fd351f79..cca99fd3 100644 --- a/Sources/Cadova/Abstract Layer/Operations/Edge Profiling/Masks/RoundedBoxCornerMask.swift +++ b/Sources/Cadova/Abstract Layer/Operations/Edge Profiling/Masks/RoundedBoxCornerMask.swift @@ -94,7 +94,7 @@ internal struct RoundedBoxCornerMask: Shape3D { } var body: any Geometry3D { - @Environment(\.segmentation) var segmentation + @Environment(\.scaledSegmentation) var segmentation @Environment(\.cornerRoundingStyle) var cornerRoundingStyle let segmentCount = max(segmentation.segmentCount(circleRadius: radius) / 4 - 1, 1) diff --git a/Sources/Cadova/Abstract Layer/Operations/EnclosePatch.swift b/Sources/Cadova/Abstract Layer/Operations/EnclosePatch.swift index 6d5e7f99..d1a4e11f 100644 --- a/Sources/Cadova/Abstract Layer/Operations/EnclosePatch.swift +++ b/Sources/Cadova/Abstract Layer/Operations/EnclosePatch.swift @@ -135,7 +135,7 @@ public extension BezierPatch { /// - SeeAlso: ``enclosed(to:)`` /// - SeeAlso: ``enclosed(offset:)`` func enclosed(against plane: Plane) -> any Geometry3D { - readEnvironment(\.segmentation) { segments in + readEnvironment(\.scaledSegmentation) { segments in enclosed(to: .plane(plane), segmentation: segments) } } @@ -154,7 +154,7 @@ public extension BezierPatch { /// - SeeAlso: ``enclosed(against:)`` /// - SeeAlso: ``enclosed(offset:)`` func enclosed(to point: Vector3D) -> any Geometry3D { - readEnvironment(\.segmentation) { segments in + readEnvironment(\.scaledSegmentation) { segments in enclosed(to: .point(point), segmentation: segments) } } @@ -174,7 +174,7 @@ public extension BezierPatch { /// - SeeAlso: ``enclosed(against:)`` /// - SeeAlso: ``enclosed(to:)`` func enclosed(offset: Vector3D) -> any Geometry3D { - readEnvironment(\.segmentation) { segments in + readEnvironment(\.scaledSegmentation) { segments in enclosed(to: .offset(offset), segmentation: segments) } } diff --git a/Sources/Cadova/Abstract Layer/Operations/Extrude/Extrusion.swift b/Sources/Cadova/Abstract Layer/Operations/Extrude/Extrusion.swift index 207fe045..4f30e071 100644 --- a/Sources/Cadova/Abstract Layer/Operations/Extrude/Extrusion.swift +++ b/Sources/Cadova/Abstract Layer/Operations/Extrude/Extrusion.swift @@ -28,17 +28,18 @@ public extension Geometry2D { if twist.isZero { extruded(height: height, twist: twist, scale: topScale, divisions: 0) } else { - measureBoundsIfNonEmpty { _, e, bounds in + measuringBounds { _, bounds in let numRevolutions = abs(twist) / 360° let maxRadius = bounds.maximumDistanceToOrigin @Environment(\.twistSubdivisionThreshold) var maxCrease + @Environment(\.scaledSegmentation) var segmentation let pitch = height / numRevolutions let helixLength = sqrt(pow(maxRadius * 2 * .pi, 2) + pow(pitch, 2)) * numRevolutions - let segmentsPerRevolution = e.segmentation.segmentCount(circleRadius: maxRadius) + let segmentsPerRevolution = segmentation.segmentCount(circleRadius: maxRadius) let twistSegments = Int(Double(segmentsPerRevolution) * numRevolutions) - let lengthSegments = e.segmentation.segmentCount(length: helixLength) + let lengthSegments = segmentation.segmentCount(length: helixLength) let segmentCount = max(twistSegments, lengthSegments) let maxEdgeLength = maxEdgeLength( radius: maxRadius, @@ -84,7 +85,7 @@ public extension Geometry2D { /// ``` /// func revolved(in range: Range = 0°..<360°) -> any Geometry3D { - readEnvironment(\.segmentation) { segmentation in + readEnvironment(\.scaledSegmentation) { segmentation in self.measuringBounds { geometry, bounds in let radius = max(bounds.maximum.x, 0) diff --git a/Sources/Cadova/Abstract Layer/Operations/Extrude/HelixSweep.swift b/Sources/Cadova/Abstract Layer/Operations/Extrude/HelixSweep.swift index 987fdb02..67d27a38 100644 --- a/Sources/Cadova/Abstract Layer/Operations/Extrude/HelixSweep.swift +++ b/Sources/Cadova/Abstract Layer/Operations/Extrude/HelixSweep.swift @@ -22,15 +22,16 @@ public extension Geometry2D { /// - Returns: A 3D geometry representing the 2D shape swept along the helical path. /// func sweptAlongHelix(pitch: Double, height: Double) -> any Geometry3D { - measureBoundsIfNonEmpty { _, e, bounds in + measuringBounds { _, bounds in + @Environment(\.scaledSegmentation) var segmentation let revolutions = height / pitch let outerRadius = bounds.maximum.x let lengthPerRev = outerRadius * 2 * .pi let helixLength = sqrt(pow(lengthPerRev, 2) + pow(pitch, 2)) * revolutions let totalSegments = Int(max( - Double(e.segmentation.segmentCount(circleRadius: outerRadius)) * revolutions, - Double(e.segmentation.segmentCount(length: helixLength)) + Double(segmentation.segmentCount(circleRadius: outerRadius)) * revolutions, + Double(segmentation.segmentCount(length: helixLength)) )) extruded(height: lengthPerRev * revolutions, divisions: totalSegments) diff --git a/Sources/Cadova/Abstract Layer/Operations/Extrude/Sweep.swift b/Sources/Cadova/Abstract Layer/Operations/Extrude/Sweep.swift index a32f794a..ed3e5e06 100644 --- a/Sources/Cadova/Abstract Layer/Operations/Extrude/Sweep.swift +++ b/Sources/Cadova/Abstract Layer/Operations/Extrude/Sweep.swift @@ -46,7 +46,7 @@ internal struct Sweep: Shape3D { var body: any Geometry3D { @Environment(\.maxTwistRate) var maxTwistRate - @Environment(\.segmentation) var segmentation + @Environment(\.scaledSegmentation) var segmentation CachedNodeTransformer( body: shape, name: "sweep", parameters: path, reference, target, maxTwistRate, segmentation diff --git a/Sources/Cadova/Abstract Layer/Operations/Follow2D.swift b/Sources/Cadova/Abstract Layer/Operations/Follow2D.swift index 34082f07..85365412 100644 --- a/Sources/Cadova/Abstract Layer/Operations/Follow2D.swift +++ b/Sources/Cadova/Abstract Layer/Operations/Follow2D.swift @@ -27,7 +27,7 @@ internal struct FollowPath2D>: Shape2D { let path: Path var body: any Geometry2D { - @Environment(\.segmentation) var segmentation + @Environment(\.scaledSegmentation) var segmentation geometry.measuringBounds { body, bounds in let pathLength = path.approximateLength diff --git a/Sources/Cadova/Abstract Layer/Operations/Follow3D.swift b/Sources/Cadova/Abstract Layer/Operations/Follow3D.swift index 1b80b865..f81be217 100644 --- a/Sources/Cadova/Abstract Layer/Operations/Follow3D.swift +++ b/Sources/Cadova/Abstract Layer/Operations/Follow3D.swift @@ -51,7 +51,7 @@ internal struct FollowPath3D: Shape3D { var body: any Geometry3D { @Environment var environment @Environment(\.maxTwistRate) var maxTwistRate - @Environment(\.segmentation) var segmentation + @Environment(\.scaledSegmentation) var segmentation if path.isEmpty { Empty() diff --git a/Sources/Cadova/Abstract Layer/Operations/Loft/Loft+Build.swift b/Sources/Cadova/Abstract Layer/Operations/Loft/Loft+Build.swift index 53753996..d225adae 100644 --- a/Sources/Cadova/Abstract Layer/Operations/Loft/Loft+Build.swift +++ b/Sources/Cadova/Abstract Layer/Operations/Loft/Loft+Build.swift @@ -5,8 +5,8 @@ extension Loft { let layerNodes = try await layers.asyncMap { LayerNode( z: $0.z, - function: $0.shapingFunction, - node: try await context.buildResult(for: $0.geometry, in: environment).node + transition: $0.transition, + node: try await context.buildResult(for: $0.geometry(), in: environment).node ) } @@ -17,14 +17,14 @@ extension Loft { let layerTrees = try await layerNodes.asyncMap { TreeLayer( z: $0.z, - function: $0.function, + transition: $0.transition, tree: try await context.result(for: $0.node).concrete.polygonTree() ) } // Always use resampled loft. Apply per-layer override or default Loft.shapingFunction. let resamplingLayers = layerTrees.map { $0.resamplingLayer(with: shapingFunction) } - let geometry = await Loft.resampledLoft(resamplingLayers: resamplingLayers, in: environment) + let geometry = await Loft.resampledLoft(resamplingLayers: resamplingLayers, in: environment, context: context) return try await context.result(for: geometry, in: environment).concrete } @@ -33,18 +33,19 @@ extension Loft { internal struct LayerNode: CacheKey { let z: Double - let function: ShapingFunction? + let transition: LayerTransition? let node: D2.Node } // Internal helper to bridge from built 2D polygon trees to resampling layers internal struct TreeLayer { let z: Double - let function: ShapingFunction? + let transition: LayerTransition? let tree: PolygonTree func resamplingLayer(with defaultFunction: ShapingFunction) -> Loft.ResamplingLayer { - Loft.ResamplingLayer(z: z, function: function ?? defaultFunction, tree: tree) + let resolvedTransition = transition ?? .interpolated(defaultFunction) + return Loft.ResamplingLayer(z: z, transition: resolvedTransition, tree: tree) } } } diff --git a/Sources/Cadova/Abstract Layer/Operations/Loft/Loft+Resampling.swift b/Sources/Cadova/Abstract Layer/Operations/Loft/Loft+Resampling.swift index 121a6190..8aff4395 100644 --- a/Sources/Cadova/Abstract Layer/Operations/Loft/Loft+Resampling.swift +++ b/Sources/Cadova/Abstract Layer/Operations/Loft/Loft+Resampling.swift @@ -4,18 +4,83 @@ import Manifold3D internal extension Loft { struct ResamplingLayer { let z: Double - let function: ShapingFunction + let transition: LayerTransition let tree: PolygonTree + + var shapingFunction: ShapingFunction? { + if case .interpolated(let function) = transition { + return function + } + return nil + } + } + + static func resampledLoft(resamplingLayers: [ResamplingLayer], in environment: EnvironmentValues, context: EvaluationContext) async -> any Geometry3D { + // Find segments that use convex hull transitions + var convexHullSegments: [(lowerIndex: Int, upperIndex: Int)] = [] + var interpolatedRanges: [Range] = [] + var currentRangeStart = 0 + + for i in 1.. currentRangeStart { + interpolatedRanges.append(currentRangeStart.. currentRangeStart { + interpolatedRanges.append(currentRangeStart..= 2 { + let segmentLayers = Array(resamplingLayers[range]) + let geometry = await resampledLoftSegment(resamplingLayers: segmentLayers, in: environment) + geometries.append(geometry) + } + } + + // Process convex hull segments + for (lowerIndex, upperIndex) in convexHullSegments { + let lowerLayer = resamplingLayers[lowerIndex] + let upperLayer = resamplingLayers[upperIndex] + let geometry = convexHullSegment(lower: lowerLayer, upper: upperLayer) + geometries.append(geometry) + } + + // Combine all segments + if geometries.count == 1 { + return geometries[0] + } else { + return Union(geometries) + } } - static func resampledLoft(resamplingLayers: [ResamplingLayer], in environment: EnvironmentValues) async -> any Geometry3D { + private static func convexHullSegment(lower: ResamplingLayer, upper: ResamplingLayer) -> any Geometry3D { + // Collect all vertices from both layers at their respective Z heights + let lowerPoints = lower.tree.flattened().vertices(at: lower.z) + let upperPoints = upper.tree.flattened().vertices(at: upper.z) + let allPoints = lowerPoints + upperPoints + return allPoints.convexHull() + } + + private static func resampledLoftSegment(resamplingLayers: [ResamplingLayer], in environment: EnvironmentValues) async -> any Geometry3D { var groups = buildPolygonGroups(layerTrees: resamplingLayers.map(\.tree)) for (index, layerPolygons) in groups.enumerated() { // Determine target count based on longest perimeter let maxPerimeter = layerPolygons.polygons.map(\.perimeter).max()! - let targetCount = environment.segmentation.segmentCount(length: maxPerimeter) + let targetCount = environment.scaledSegmentation.segmentCount(length: maxPerimeter) var newPolygons = SimplePolygonList(layerPolygons.polygons.map { $0.resampled(count: targetCount) }) @@ -78,7 +143,7 @@ internal extension Loft { layers: [ResamplingLayer], environment: EnvironmentValues ) -> [(polygons: SimplePolygonList, zLevels: [Double])] { - let segmentation = environment.segmentation + let segmentation = environment.scaledSegmentation var refinedGroups: [(polygons: SimplePolygonList, zLevels: [Double])] = [] for polygons in polygonGroups { @@ -92,35 +157,49 @@ internal extension Loft { let layer1 = layers[i] let interpolatedLayers: [(polygon: SimplePolygon, z: Double)] - switch segmentation { - case .fixed(let count): - interpolatedLayers = (1..) { - let zStart = layer0.z + (layer1.z - layer0.z) * range.lowerBound - let zEnd = layer0.z + (layer1.z - layer0.z) * range.upperBound - let pStart = lower.blended(with: upper, t: layer1.function(range.lowerBound)) - let pEnd = lower.blended(with: upper, t: layer1.function(range.upperBound)) - - if pStart.needsSubdivision(next: pEnd, z0: zStart, z1: zEnd, minLength: minLength) { - let tMid = range.mid - subdivide(range: range.lowerBound..) { + let zStart = layer0.z + (layer1.z - layer0.z) * range.lowerBound + let zEnd = layer0.z + (layer1.z - layer0.z) * range.upperBound + let pStart = lower.blended(with: upper, t: function(range.lowerBound)) + let pEnd = lower.blended(with: upper, t: function(range.upperBound)) + + if pStart.needsSubdivision(next: pEnd, z0: zStart, z1: zEnd, minLength: minLength) { + let tMid = range.mid + subdivide(range: range.lowerBound.. any Geometry3D { + LoftVisualization(layers: layers) + } +} + +fileprivate struct LoftVisualization: Shape3D { + let layers: [Loft.Layer] + + var body: any Geometry3D { + @Environment(\.visualizationOptions.scale) var scale = 1.0 + let thickness = 0.001 * scale + + Union { + for (index, layer) in layers.enumerated() { + layer.geometry() + .extruded(height: thickness) + .translated(z: layer.z - thickness / 2) + .colored(Color.layerColors[index % Color.layerColors.count], alpha: 0.7) + } + } + .inPart(named: "Visualized Loft Layers", type: .visual) + } +} + +fileprivate extension Color { + static let layerColors: [Color] = [.red, .blue, .green, .orange, .purple, .cyan, .magenta, .yellow] +} diff --git a/Sources/Cadova/Abstract Layer/Operations/Loft/Loft.swift b/Sources/Cadova/Abstract Layer/Operations/Loft/Loft.swift index e149fa56..9bf44aab 100644 --- a/Sources/Cadova/Abstract Layer/Operations/Loft/Loft.swift +++ b/Sources/Cadova/Abstract Layer/Operations/Loft/Loft.swift @@ -1,5 +1,33 @@ import Foundation +/// Specifies how a loft layer transitions from the previous layer. +/// +/// This enum provides control over the geometric operation used to connect two adjacent +/// layers in a loft. By default, layers are connected via shape interpolation, but you +/// can also specify a convex hull connection for certain segments. +/// +public enum LayerTransition: Hashable, Sendable, Codable { + /// Interpolates between the previous layer's shape and this layer's shape using + /// the specified shaping function. + /// + /// The shaping function controls the rate of interpolation. For example, `.linear` + /// produces evenly spaced intermediate cross-sections, while `.easeIn` or `.easeOut` + /// can create more organic transitions. + /// + case interpolated(ShapingFunction) + + /// Connects the previous layer to this layer using a 3D convex hull. + /// + /// Instead of interpolating intermediate cross-sections, this creates the smallest + /// convex shape that contains both layers. This is useful for creating tapered or + /// faceted transitions between shapes, especially when both shapes are convex. + /// + /// - Note: The convex hull operation ignores holes in the shapes. The result will + /// be a solid convex polyhedron connecting the outer boundaries of both layers. + /// + case convexHull +} + /// A 3D shape constructed by interpolating between a series of 2D cross-sections layered at different Z positions. /// /// Lofting is a modeling technique that creates a smooth transition between multiple 2D shapes across different @@ -84,12 +112,18 @@ public struct Loft: Geometry { precondition(self.layers.count >= 2, "Loft requires at least two layers") } + /// A result builder for composing loft layers. public typealias LayerBuilder = ArrayBuilder + /// A single cross-section in a lofted shape. + /// + /// Each layer defines a 2D shape at a specific Z height. Layers are created using the + /// ``layer(z:interpolation:shape:)`` function within a ``Loft`` builder. + /// public struct Layer: Sendable { internal let z: Double - internal let shapingFunction: ShapingFunction? - internal let geometry: any Geometry2D + internal let transition: LayerTransition? + internal let geometry: @Sendable () -> any Geometry2D } } @@ -107,7 +141,24 @@ public func layer( interpolation shapingFunction: ShapingFunction? = nil, @GeometryBuilder2D shape: @Sendable @escaping () -> any Geometry2D ) -> Loft.Layer { - Loft.Layer(z: z, shapingFunction: shapingFunction, geometry: shape()) + Loft.Layer(z: z, transition: shapingFunction.map { .interpolated($0) }, geometry: shape) +} + +/// Creates a single layer in a lofted shape at the specified Z height with a specified transition type. +/// This function is intended to be used inside a `Loft` builder to define each horizontal cross-section. +/// +/// - Parameters: +/// - z: The Z height at which to place the 2D shape. +/// - transition: The transition type that controls how this layer connects to the previous one. +/// Use `.interpolated(_:)` for shape interpolation or `.convexHull` for a convex hull connection. +/// - shape: A builder that returns the 2D geometry to use for this layer. +/// +public func layer( + z: Double, + interpolation transition: LayerTransition, + @GeometryBuilder2D shape: @Sendable @escaping () -> any Geometry2D +) -> Loft.Layer { + Loft.Layer(z: z, transition: transition, geometry: shape) } /// Creates two layers spanning a Z range using the same 2D shape. @@ -130,10 +181,33 @@ public func layer( interpolation shapingFunction: ShapingFunction? = nil, @GeometryBuilder2D shape: @Sendable @escaping () -> any Geometry2D ) -> [Loft.Layer] { - let content = shape() - return [ - Loft.Layer(z: range.lowerBound, shapingFunction: shapingFunction, geometry: content), - Loft.Layer(z: range.upperBound, shapingFunction: .linear, geometry: content) + [ + Loft.Layer(z: range.lowerBound, transition: shapingFunction.map { .interpolated($0) }, geometry: shape), + Loft.Layer(z: range.upperBound, transition: .interpolated(.linear), geometry: shape) + ] +} + +/// Creates two layers spanning a Z range using the same 2D shape with a specified transition type. +/// +/// This convenience overload generates a pair of `Loft.Layer` entries from a single shape: +/// one at `range.lowerBound` using the provided transition, and one at `range.upperBound` using +/// a linear interpolation. This is useful when you want a straight shape across the specified interval. +/// +/// - Parameters: +/// - range: The Z range defining the lower and upper bounds where the shape will be placed. +/// - transition: The transition type that controls how this layer connects to the previous one. +/// Use `.interpolated(_:)` for shape interpolation or `.convexHull` for a convex hull connection. +/// - shape: A builder that returns the 2D geometry to use for both layers. +/// - Returns: Two `Loft.Layer` values, one at the lower bound and one at the upper bound. +/// +public func layer( + z range: Range, + interpolation transition: LayerTransition, + @GeometryBuilder2D shape: @Sendable @escaping () -> any Geometry2D +) -> [Loft.Layer] { + [ + Loft.Layer(z: range.lowerBound, transition: transition, geometry: shape), + Loft.Layer(z: range.upperBound, transition: .interpolated(.linear), geometry: shape) ] } diff --git a/Sources/Cadova/Abstract Layer/Operations/Offsetting/Offset.swift b/Sources/Cadova/Abstract Layer/Operations/Offsetting/Offset.swift index e0ed3524..10917156 100644 --- a/Sources/Cadova/Abstract Layer/Operations/Offsetting/Offset.swift +++ b/Sources/Cadova/Abstract Layer/Operations/Offsetting/Offset.swift @@ -50,7 +50,7 @@ public extension Geometry2D { /// - Returns: A new geometry object that is the result of the offset operation. /// func offset(amount: Double, style: LineJoinStyle) -> any Geometry2D { - readEnvironment(\.miterLimit, \.segmentation) { miterLimit, segmentation in + readEnvironment(\.miterLimit, \.scaledSegmentation) { miterLimit, segmentation in GeometryNodeTransformer(body: self) { .offset( $0, diff --git a/Sources/Cadova/Abstract Layer/Operations/Twist.swift b/Sources/Cadova/Abstract Layer/Operations/Twist.swift index d0f0657d..3b61aa05 100644 --- a/Sources/Cadova/Abstract Layer/Operations/Twist.swift +++ b/Sources/Cadova/Abstract Layer/Operations/Twist.swift @@ -9,7 +9,8 @@ public extension Geometry3D { /// - Parameter amount: The total twist applied from bottom to top, expressed as an `Angle`. /// - Returns: A new geometry with the twist deformation applied. func twisted(by amount: Angle) -> any Geometry3D { - measureBoundsIfNonEmpty { geometry, e, bounds in + measuringBounds { geometry, bounds in + @Environment(\.scaledSegmentation) var segmentation let height = bounds.size.z let radius = bounds.bounds2D.maximumDistanceToOrigin let totalRevolutions = amount / 360° @@ -18,8 +19,8 @@ public extension Geometry3D { let helixLength = sqrt(pow(radius * 2 * .pi, 2) + pow(pitch, 2)) * totalRevolutions let totalSegments = max( - Double(e.segmentation.segmentCount(circleRadius: radius)) * totalRevolutions, - Double(e.segmentation.segmentCount(length: helixLength)) + Double(segmentation.segmentCount(circleRadius: radius)) * totalRevolutions, + Double(segmentation.segmentCount(length: helixLength)) ) geometry diff --git a/Sources/Cadova/Abstract Layer/Operations/WhileMasked.swift b/Sources/Cadova/Abstract Layer/Operations/WhileMasked.swift index b06c4d90..f206f93b 100644 --- a/Sources/Cadova/Abstract Layer/Operations/WhileMasked.swift +++ b/Sources/Cadova/Abstract Layer/Operations/WhileMasked.swift @@ -71,7 +71,7 @@ public extension Geometry2D { ) -> D.Geometry { measuringBounds { _, bounds in whileMasked(inverted: inverted, using: { - bounds.within(x: x, y: y).mask + bounds.within(x: x, y: y, margin: 1).mask }, do: operations) } } @@ -101,7 +101,7 @@ public extension Geometry3D { ) -> D.Geometry { measuringBounds { _, bounds in whileMasked(inverted: inverted, using: { - bounds.within(x: x, y: y, z: z).mask + bounds.within(x: x, y: y, z: z, margin: 1).mask }, do: operations) } } diff --git a/Sources/Cadova/Abstract Layer/Operations/Within.swift b/Sources/Cadova/Abstract Layer/Operations/Within.swift index 976a2cfb..c6691e41 100644 --- a/Sources/Cadova/Abstract Layer/Operations/Within.swift +++ b/Sources/Cadova/Abstract Layer/Operations/Within.swift @@ -25,7 +25,7 @@ public extension Geometry2D { y: (any WithinRange)? = nil ) -> any Geometry2D { measuringBounds { body, bounds in - body.intersecting { bounds.within(x: x, y: y).mask } + body.intersecting { bounds.within(x: x, y: y, margin: 1).mask } } } } @@ -55,7 +55,7 @@ public extension Geometry3D { z: (any WithinRange)? = nil ) -> any Geometry3D { measuringBounds { body, bounds in - body.intersecting { bounds.within(x: x, y: y, z: z).mask } + body.intersecting { bounds.within(x: x, y: y, z: z, margin: 1).mask } } } } @@ -87,7 +87,7 @@ public extension Geometry2D { @GeometryBuilder do operations: @escaping @Sendable (D.Geometry) -> D.Geometry ) -> any Geometry2D { measuringBounds { body, bounds in - let mask = bounds.within(x: x, y: y).mask + let mask = bounds.within(x: x, y: y, margin: 1).mask body .subtracting(mask) .adding { @@ -127,7 +127,7 @@ public extension Geometry3D { @GeometryBuilder do operations: @escaping @Sendable (D.Geometry) -> D.Geometry ) -> any Geometry3D { measuringBounds { _, bounds in - let mask = bounds.within(x: x, y: y, z: z).mask + let mask = bounds.within(x: x, y: y, z: z, margin: 1).mask self .subtracting(mask) diff --git a/Sources/Cadova/Abstract Layer/Operations/Wrap.swift b/Sources/Cadova/Abstract Layer/Operations/Wrap.swift index 576e02ee..0050ab29 100644 --- a/Sources/Cadova/Abstract Layer/Operations/Wrap.swift +++ b/Sources/Cadova/Abstract Layer/Operations/Wrap.swift @@ -21,10 +21,11 @@ public extension Geometry3D { /// maximum X extent as one full turn of the circle. /// func wrappedAroundCylinder(diameter: Double? = nil) -> any Geometry3D { - measureBoundsIfNonEmpty { geometry, e, bounds in + measuringBounds { geometry, bounds in + @Environment(\.scaledSegmentation) var segmentation let innerRadius = (diameter ?? bounds.maximum.x / .pi) / 2 let maximumRadius = innerRadius + bounds.maximum.z - let segmentLength = (maximumRadius * 2 * .pi) / Double(e.segmentation.segmentCount(circleRadius: maximumRadius)) + let segmentLength = (maximumRadius * 2 * .pi) / Double(segmentation.segmentCount(circleRadius: maximumRadius)) let innerCircumference = innerRadius * 2 * .pi geometry @@ -63,10 +64,11 @@ public extension Geometry2D { /// the resulting wrapped geometry faces upward. /// func wrappedAroundCircle(radius: Double? = nil) -> any Geometry2D { - measureBoundsIfNonEmpty { geometry, e, bounds in + measuringBounds { geometry, bounds in + @Environment(\.scaledSegmentation) var segmentation let innerRadius = radius ?? bounds.maximum.x / .pi let maximumRadius = innerRadius + bounds.maximum.y - let segmentLength = (maximumRadius * 2 * .pi) / Double(e.segmentation.segmentCount(circleRadius: maximumRadius)) + let segmentLength = (maximumRadius * 2 * .pi) / Double(segmentation.segmentCount(circleRadius: maximumRadius)) let innerCircumference = innerRadius * 2 * .pi geometry @@ -156,7 +158,8 @@ public extension Geometry3D { /// This creates a thin, curved shell wrapping around a sphere. /// func wrappedAroundSphere(radius: Double? = nil) -> any Geometry3D { - measureBoundsIfNonEmpty { geometry, e, bounds in + measuringBounds { geometry, bounds in + @Environment(\.scaledSegmentation) var segmentation let naturalCircumference = bounds.maximum.x let baseRadius = radius ?? (naturalCircumference / .pi / 2.0) let circumference = baseRadius * 2.0 * .pi @@ -164,7 +167,7 @@ public extension Geometry3D { let yExtent = max(bounds.maximum.y, -bounds.minimum.y) let maximumRadius = baseRadius + bounds.maximum.z - let sphereSegmentLength = maximumRadius * 2 * .pi / Double(e.segmentation.segmentCount(circleRadius: maximumRadius)) / circumferenceScale + let sphereSegmentLength = maximumRadius * 2 * .pi / Double(segmentation.segmentCount(circleRadius: maximumRadius)) / circumferenceScale geometry .refined(maxEdgeLength: sphereSegmentLength) diff --git a/Sources/Cadova/Abstract Layer/Visualization/BoundingBoxVisualization.swift b/Sources/Cadova/Abstract Layer/Visualization/BoundingBoxVisualization.swift index 284b0bd5..d2cc1058 100644 --- a/Sources/Cadova/Abstract Layer/Visualization/BoundingBoxVisualization.swift +++ b/Sources/Cadova/Abstract Layer/Visualization/BoundingBoxVisualization.swift @@ -16,18 +16,38 @@ public extension BoundingBox3D { } public extension Geometry3D { - /// Overlays the geometry’s bounding box as a thin 3D frame for debugging. + /// Overlays the geometry's bounding box as a thin 3D frame for debugging. + /// + /// This is useful for visualizing the region affected by range-based spatial APIs + /// like ``within(x:y:z:)`` or ``EdgeCriteria/within(x:y:z:)``. + /// + /// ```swift + /// // Visualize the entire bounding box + /// geometry.visualizingBounds() + /// + /// // Visualize a portion of the bounding box + /// box.cuttingEdgeProfile(.fillet(radius: 2), along: .sharp().within(z: 0...)) + /// .visualizingBounds(z: 0...) // Shows the region where edges are selected + /// ``` + /// + /// - Parameters: + /// - x: Optional range along the x-axis. If `nil`, uses the geometry's full x extent. + /// - y: Optional range along the y-axis. If `nil`, uses the geometry's full y extent. + /// - z: Optional range along the z-axis. If `nil`, uses the geometry's full z extent. /// /// - Thickness is driven by the visualization scale in the environment /// (`withVisualizationScale(_:)`). /// - Color uses the visualization primary color (`withVisualizationColor(_:)`). - /// - Adds a visual-only part named “Visualized Bounding Box”. + /// - Adds a visual-only part named "Visualized Bounding Box". /// - /// See EnvironmentValues for how environment values flow through geometry. - func visualizingBounds() -> any Geometry3D { + func visualizingBounds( + x: (any WithinRange)? = nil, + y: (any WithinRange)? = nil, + z: (any WithinRange)? = nil + ) -> any Geometry3D { measuringBounds { geometry, bounds in geometry - BoundingBoxVisualization(box: bounds) + BoundingBoxVisualization(box: bounds.within(x: x, y: y, z: z, margin: 0)) } } } diff --git a/Sources/Cadova/Concrete Layer/Build/Model/Model.swift b/Sources/Cadova/Concrete Layer/Build/Model/Model.swift index 76772225..1997d828 100644 --- a/Sources/Cadova/Concrete Layer/Build/Model/Model.swift +++ b/Sources/Cadova/Concrete Layer/Build/Model/Model.swift @@ -1,5 +1,19 @@ import Foundation +/// A model that can be exported to a file. +/// +/// Use `Model` to build geometry and write it to disk in formats like 3MF, STL, or SVG. +/// The model is created and exported in a single step using an async initializer. +/// +/// ```swift +/// await Model("my-part") { +/// Box(x: 10, y: 10, z: 5) +/// } +/// ``` +/// +/// Models can also be grouped within a ``Project`` to share environment settings and metadata +/// across multiple output files. +/// public struct Model: Sendable { let name: String diff --git a/Sources/Cadova/Concrete Layer/Build/Options/Metadata.swift b/Sources/Cadova/Concrete Layer/Build/Options/Metadata.swift index 2afe53aa..dc8a4bde 100644 --- a/Sources/Cadova/Concrete Layer/Build/Options/Metadata.swift +++ b/Sources/Cadova/Concrete Layer/Build/Options/Metadata.swift @@ -1,13 +1,47 @@ import Foundation +/// Descriptive information embedded in exported model files. +/// +/// Use `Metadata` within a ``Model`` or ``Project`` to attach attribution, licensing, +/// and other descriptive fields to the output file. The 3MF format supports all of these +/// fields; other formats may ignore them or support only a subset. +/// +/// ```swift +/// await Model("my-part") { +/// Metadata(title: "Widget", author: "Jane Doe", license: "MIT") +/// Box(10) +/// } +/// ``` +/// public struct Metadata: Sendable { + /// A short title for the model. let title: String? + + /// A longer description providing context or usage notes. let description: String? + + /// The name of the creator or designer. let author: String? + + /// A license string indicating usage or redistribution terms. let license: String? + + /// A date string, typically in ISO 8601 format. let date: String? + + /// An identifier or URL of the application that generated the model. let application: String? + /// Creates metadata with the specified fields. + /// + /// - Parameters: + /// - title: A short title for the model. + /// - description: A longer description providing context or usage notes. + /// - author: The name of the creator or designer. + /// - license: A license string indicating usage or redistribution terms. + /// - date: A date string, typically in ISO 8601 format. + /// - application: An identifier or URL of the application that generated the model. + /// public init( title: String? = nil, description: String? = nil, diff --git a/Sources/Cadova/Concrete Layer/Build/Options/ModelOptions.swift b/Sources/Cadova/Concrete Layer/Build/Options/ModelOptions.swift index 28154bd6..6c544944 100644 --- a/Sources/Cadova/Concrete Layer/Build/Options/ModelOptions.swift +++ b/Sources/Cadova/Concrete Layer/Build/Options/ModelOptions.swift @@ -1,5 +1,6 @@ import Foundation +/// Configuration options for model building and export. public struct ModelOptions: Sendable, ExpressibleByArrayLiteral { private let items: [any ModelOptionItem] diff --git a/Sources/Cadova/Dimensionality.swift b/Sources/Cadova/Dimensionality.swift index 00960f45..f8082bb8 100644 --- a/Sources/Cadova/Dimensionality.swift +++ b/Sources/Cadova/Dimensionality.swift @@ -1,6 +1,14 @@ import Foundation import Manifold3D +/// A marker protocol that distinguishes between 2D and 3D geometry. +/// +/// This protocol is part of Cadova's internal type system and is not intended for direct use. +/// It provides associated types that vary between two and three dimensions, enabling +/// type-safe operations that work generically across both. +/// +/// - SeeAlso: ``D2`` for 2D geometry, ``D3`` for 3D geometry. +/// public protocol Dimensionality: SendableMetatype { typealias Geometry = any Cadova.Geometry associatedtype Concrete: Manifold3D.Geometry, ConcreteGeometry where Concrete.D == Self @@ -23,7 +31,12 @@ internal extension Dimensionality { typealias Curve = ParametricCurve } -// 2D-related types +/// The two-dimensional space. +/// +/// `D2` is a marker type used by Cadova's type system to distinguish 2D geometry from 3D. +/// You typically don't interact with this type directly; instead, use concrete 2D types +/// like ``Circle``, ``Rectangle``, or ``Polygon``. +/// public struct D2: Dimensionality { public typealias Concrete = CrossSection @@ -34,7 +47,12 @@ public struct D2: Dimensionality { private init() {} } -// 3D-related types +/// The three-dimensional space. +/// +/// `D3` is a marker type used by Cadova's type system to distinguish 3D geometry from 2D. +/// You typically don't interact with this type directly; instead, use concrete 3D types +/// like ``Box``, ``Sphere``, or ``Cylinder``. +/// public struct D3: Dimensionality { public typealias Concrete = Manifold diff --git a/Sources/Cadova/Node Layer/GeometryNode+Shapes.swift b/Sources/Cadova/Node Layer/GeometryNode+Shapes.swift index 65ce8b9d..52bdaea5 100644 --- a/Sources/Cadova/Node Layer/GeometryNode+Shapes.swift +++ b/Sources/Cadova/Node Layer/GeometryNode+Shapes.swift @@ -2,14 +2,14 @@ import Foundation import Manifold3D extension GeometryNode { - public enum PrimitiveShape2D: Hashable, Sendable, Codable { + internal enum PrimitiveShape2D: Hashable, Sendable, Codable { case rectangle (size: Vector2D) case circle (radius: Double, segmentCount: Int) case polygons (SimplePolygonList, fillRule: FillRule) case convexHull (points: [Vector2D]) } - public enum PrimitiveShape3D: Hashable, Sendable, Codable { + internal enum PrimitiveShape3D: Hashable, Sendable, Codable { case box (size: Vector3D) case sphere (radius: Double, segmentCount: Int) case cylinder (bottomRadius: Double, topRadius: Double, height: Double, segmentCount: Int) diff --git a/Sources/Cadova/Node Layer/GeometryNode.swift b/Sources/Cadova/Node Layer/GeometryNode.swift index 017f2a5c..f20d0b2f 100644 --- a/Sources/Cadova/Node Layer/GeometryNode.swift +++ b/Sources/Cadova/Node Layer/GeometryNode.swift @@ -38,15 +38,15 @@ internal struct GeometryNode: Sendable, Hashable { } } -extension GeometryNode { - public enum Projection: Hashable, Sendable, Codable { +internal extension GeometryNode { + enum Projection: Hashable, Sendable, Codable { case full case slice (z: Double) } } -extension GeometryNode { - public enum Extrusion: Hashable, Sendable, Codable { +internal extension GeometryNode { + enum Extrusion: Hashable, Sendable, Codable { case linear (height: Double, twist: Angle = 0°, divisions: Int = 0, scaleTop: Vector2D = [1,1]) case rotational (angle: Angle, segments: Int) @@ -171,7 +171,7 @@ extension GeometryNode { } } -public enum BooleanOperationType: String, Hashable, Sendable, Codable { +internal enum BooleanOperationType: String, Hashable, Sendable, Codable { case union case difference case intersection diff --git a/Sources/Cadova/Values/Alignment/AxisAlignment.swift b/Sources/Cadova/Values/Alignment/AxisAlignment.swift index aab270d7..b82eae28 100644 --- a/Sources/Cadova/Values/Alignment/AxisAlignment.swift +++ b/Sources/Cadova/Values/Alignment/AxisAlignment.swift @@ -1,7 +1,20 @@ import Foundation +/// Specifies alignment along a single axis. +/// +/// Use `AxisAlignment` to indicate where geometry should be positioned relative to its +/// bounding box along one axis. This is typically combined with ``GeometryAlignment`` to +/// specify alignment across multiple axes. +/// public enum AxisAlignment: Equatable, Hashable, Sendable { - case min, mid, max + /// Align to the minimum edge (e.g., left, front, or bottom). + case min + + /// Align to the center. + case mid + + /// Align to the maximum edge (e.g., right, back, or top). + case max /// A normalized fraction representing the alignment position along an axis. /// diff --git a/Sources/Cadova/Values/Alignment/GeometryAlignment.swift b/Sources/Cadova/Values/Alignment/GeometryAlignment.swift index 88f05cb3..d4aec557 100644 --- a/Sources/Cadova/Values/Alignment/GeometryAlignment.swift +++ b/Sources/Cadova/Values/Alignment/GeometryAlignment.swift @@ -35,14 +35,31 @@ public struct GeometryAlignment: Equatable, Sendable { self.values = values } + /// Creates a 2D alignment with the specified values for each axis. + /// + /// - Parameters: + /// - x: The alignment along the X axis, or `nil` for no alignment. + /// - y: The alignment along the Y axis, or `nil` for no alignment. + /// public init(x: AxisAlignment? = nil, y: AxisAlignment? = nil) where D == D2 { values = .init(x: x, y: y) } + /// Creates a 3D alignment with the specified values for each axis. + /// + /// - Parameters: + /// - x: The alignment along the X axis, or `nil` for no alignment. + /// - y: The alignment along the Y axis, or `nil` for no alignment. + /// - z: The alignment along the Z axis, or `nil` for no alignment. + /// public init(x: AxisAlignment? = nil, y: AxisAlignment? = nil, z: AxisAlignment? = nil) where D == D3 { values = .init(x: x, y: y, z: z) } + /// Creates an alignment with the same value for all axes. + /// + /// - Parameter value: The alignment to apply to all axes, or `nil` for no alignment. + /// public init(all value: AxisAlignment?) { values = .init { _ in value } } @@ -53,10 +70,18 @@ public struct GeometryAlignment: Equatable, Sendable { } } + /// Returns the alignment for the specified axis. public subscript(axis: D.Axis) -> AxisAlignment? { values[axis] } + /// Returns a copy with the alignment for one axis changed. + /// + /// - Parameters: + /// - axis: The axis to modify. + /// - newValue: The new alignment for that axis. + /// - Returns: A new alignment with the specified axis updated. + /// public func with(axis: D.Axis, as newValue: AxisAlignment) -> Self { .init(values.map { $0 == axis ? newValue : $1 }) } @@ -74,7 +99,10 @@ public struct GeometryAlignment: Equatable, Sendable { } } +/// A 2D geometry alignment. public typealias GeometryAlignment2D = GeometryAlignment + +/// A 3D geometry alignment. public typealias GeometryAlignment3D = GeometryAlignment internal extension [GeometryAlignment2D] { diff --git a/Sources/Cadova/Values/Axis/Axis.swift b/Sources/Cadova/Values/Axis/Axis.swift index bb2384c4..609e57f0 100644 --- a/Sources/Cadova/Values/Axis/Axis.swift +++ b/Sources/Cadova/Values/Axis/Axis.swift @@ -1,7 +1,14 @@ import Foundation +/// A Cartesian axis in 2D or 3D space. +/// +/// This protocol defines common axis operations. You typically work with the concrete types +/// ``Axis2D`` and ``Axis3D`` rather than this protocol directly. +/// public protocol Axis: Equatable, Hashable, CaseIterable, Sendable, Codable { associatedtype D: Dimensionality where D.Axis == Self + + /// The zero-based index of this axis (0 for X, 1 for Y, 2 for Z). var index: Int { get } } diff --git a/Sources/Cadova/Values/BoundingBox.swift b/Sources/Cadova/Values/BoundingBox.swift index 1fa36b52..e4df933f 100644 --- a/Sources/Cadova/Values/BoundingBox.swift +++ b/Sources/Cadova/Values/BoundingBox.swift @@ -176,19 +176,19 @@ extension BoundingBox3D { } fileprivate extension BoundingBox { - func partialBox(from: Double?, to: Double?, in axis: D.Axis) -> BoundingBox { + func partialBox(from: Double?, to: Double?, in axis: D.Axis, margin: Double) -> BoundingBox { BoundingBox( - minimum: minimum.with(axis, as: from ?? minimum[axis] - 1), - maximum: maximum.with(axis, as: to ?? maximum[axis] + 1) + minimum: minimum.with(axis, as: from ?? minimum[axis] - margin), + maximum: maximum.with(axis, as: to ?? maximum[axis] + margin) ) } } internal extension BoundingBox2D { - func within(x: (any WithinRange)? = nil, y: (any WithinRange)? = nil) -> Self { + func within(x: (any WithinRange)? = nil, y: (any WithinRange)? = nil, margin: Double) -> Self { self - .partialBox(from: x?.min, to: x?.max, in: .x) - .partialBox(from: y?.min, to: y?.max, in: .y) + .partialBox(from: x?.min, to: x?.max, in: .x, margin: margin) + .partialBox(from: y?.min, to: y?.max, in: .y, margin: margin) } var mask: any Geometry2D { @@ -197,11 +197,11 @@ internal extension BoundingBox2D { } internal extension BoundingBox3D { - func within(x: (any WithinRange)? = nil, y: (any WithinRange)? = nil, z: (any WithinRange)? = nil) -> Self { + func within(x: (any WithinRange)? = nil, y: (any WithinRange)? = nil, z: (any WithinRange)? = nil, margin: Double) -> Self { self - .partialBox(from: x?.min, to: x?.max, in: .x) - .partialBox(from: y?.min, to: y?.max, in: .y) - .partialBox(from: z?.min, to: z?.max, in: .z) + .partialBox(from: x?.min, to: x?.max, in: .x, margin: margin) + .partialBox(from: y?.min, to: y?.max, in: .y, margin: margin) + .partialBox(from: z?.min, to: z?.max, in: .z, margin: margin) } var mask: any Geometry3D { diff --git a/Sources/Cadova/Values/Corners, Edges and Sides/CornerRoundingStyle.swift b/Sources/Cadova/Values/Corners, Edges and Sides/CornerRoundingStyle.swift index 1769dc29..ed990de6 100644 --- a/Sources/Cadova/Values/Corners, Edges and Sides/CornerRoundingStyle.swift +++ b/Sources/Cadova/Values/Corners, Edges and Sides/CornerRoundingStyle.swift @@ -80,7 +80,7 @@ internal struct FilletCorner: Shape2D { let size: Vector2D var body: any Geometry2D { - @Environment(\.segmentation) var segmentation + @Environment(\.scaledSegmentation) var segmentation @Environment(\.cornerRoundingStyle) var style if size.x > 0, size.y > 0 { diff --git a/Sources/Cadova/Values/Curves/Bezier/BezierPath.swift b/Sources/Cadova/Values/Curves/Bezier/BezierPath.swift index 6246957e..d959efe3 100644 --- a/Sources/Cadova/Values/Curves/Bezier/BezierPath.swift +++ b/Sources/Cadova/Values/Curves/Bezier/BezierPath.swift @@ -1,6 +1,9 @@ import Foundation +/// A 2D Bezier path. public typealias BezierPath2D = BezierPath + +/// A 3D Bezier path. public typealias BezierPath3D = BezierPath /// A `BezierPath` represents a sequence of connected Bezier curves, forming a path. @@ -91,16 +94,27 @@ extension BezierPath: ParametricCurve { } } + /// Whether the path contains no curves. public var isEmpty: Bool { curves.isEmpty } public var sampleCountForLengthApproximation: Int { 10 } + /// Creates a 2D path by transforming each point. + /// + /// - Parameter transformer: A closure that converts each point to 2D. + /// - Returns: A new 2D Bezier path with transformed points. + /// public func mapPoints(_ transformer: (V) -> Vector2D) -> BezierPath2D { map(transformer) } + /// Creates a 3D path by transforming each point. + /// + /// - Parameter transformer: A closure that converts each point to 3D. + /// - Returns: A new 3D Bezier path with transformed points. + /// public func mapPoints(_ transformer: (V) -> Vector3D) -> BezierPath3D { map(transformer) } @@ -109,6 +123,13 @@ extension BezierPath: ParametricCurve { BezierPathDerivativeView(derivative: derivative) } + /// Returns points sampled along a parameter subrange. + /// + /// - Parameters: + /// - range: The parameter range to sample within. + /// - segmentation: Controls the sampling density. + /// - Returns: An array of points covering the specified range. + /// public func points(in range: ClosedRange, segmentation: Segmentation) -> [V] { subpath(in: range).points(segmentation: segmentation) } diff --git a/Sources/Cadova/Values/Curves/Bezier/Path Builder/BezierPath.Builder.swift b/Sources/Cadova/Values/Curves/Bezier/Path Builder/BezierPath.Builder.swift index f60de440..9ca203ab 100644 --- a/Sources/Cadova/Values/Curves/Bezier/Path Builder/BezierPath.Builder.swift +++ b/Sources/Cadova/Values/Curves/Bezier/Path Builder/BezierPath.Builder.swift @@ -1,8 +1,35 @@ import Foundation public extension BezierPath { + /// A result builder type for constructing Bezier paths from component functions. typealias Builder = ArrayBuilder.Component> + /// Creates a Bezier path using a declarative builder syntax. + /// + /// This initializer enables a DSL-style approach to constructing paths using global functions + /// like ``line(x:y:)``, ``curve(controlX:controlY:endX:endY:)``, and ``clockwiseArc(center:angle:)``. + /// + /// - Parameters: + /// - from: The starting point of the path. Defaults to the origin. + /// - defaultMode: The default positioning mode for coordinates. When set to `.absolute`, + /// coordinate values represent absolute positions. When set to `.relative`, values + /// represent offsets from the current point. Defaults to `.absolute`. + /// - builder: A closure that returns an array of path components using the builder syntax. + /// + /// - Example: + /// ```swift + /// let path = BezierPath2D(from: [10, 4], mode: .relative) { + /// line(x: 22, y: 1) + /// line(x: 2) + /// curve( + /// controlX: 7, controlY: 12, + /// endX: 77, endY: 18 + /// ) + /// } + /// ``` + /// + /// - SeeAlso: ``PathBuilderPositioning`` + /// init(from: V = .zero, mode defaultMode: PathBuilderPositioning = .absolute, @Builder builder: () -> [Component]) { var path = BezierPath(startPoint: from) for component in builder() { @@ -11,6 +38,15 @@ public extension BezierPath { self = path } + /// A single segment or operation that can be added to a Bezier path. + /// + /// Components represent individual path segments such as lines, curves, or arcs. + /// They are created using global functions like ``line(x:y:)``, ``curve(controlX:controlY:endX:endY:)``, + /// and ``clockwiseArc(center:angle:)``, and are combined using the ``BezierPath/Builder`` syntax. + /// + /// Each component can have its positioning mode overridden using the ``relative`` or ``absolute`` + /// properties, regardless of the path's default mode. + /// struct Component { internal let appendAction: (BezierPath, PathBuilderPositioning) -> BezierPath @@ -45,12 +81,52 @@ public extension BezierPath { } } + /// Returns a copy of this component that interprets all coordinates as relative offsets. + /// + /// Use this to override the path's default positioning mode for a specific component. + /// + /// - Example: + /// ```swift + /// BezierPath2D(from: [0, 0], mode: .absolute) { + /// line(x: 100, y: 0) // Absolute: goes to (100, 0) + /// line(x: 10, y: 10).relative // Relative: moves by (10, 10) to (110, 10) + /// } + /// ``` + /// public var relative: Component { withDefaultMode(.relative) } + + /// Returns a copy of this component that interprets all coordinates as absolute positions. + /// + /// Use this to override the path's default positioning mode for a specific component. + /// + /// - Example: + /// ```swift + /// BezierPath2D(from: [0, 0], mode: .relative) { + /// line(x: 10, y: 10) // Relative: moves by (10, 10) to (10, 10) + /// line(x: 50, y: 50).absolute // Absolute: goes to (50, 50) + /// } + /// ``` + /// public var absolute: Component { withDefaultMode(.absolute) } } } +/// Specifies how coordinate values in path builder functions are interpreted. +/// +/// This enum controls whether numeric values represent absolute positions in the coordinate +/// system or relative offsets from the current path position. +/// +/// - SeeAlso: ``BezierPath/init(from:mode:builder:)`` +/// public enum PathBuilderPositioning: Sendable { + /// Coordinate values represent absolute positions in the coordinate system. + /// + /// For example, `line(x: 100, y: 50)` draws a line to the point (100, 50). case absolute + + /// Coordinate values represent offsets from the current path position. + /// + /// For example, `line(x: 10, y: 5)` draws a line 10 units right and 5 units up + /// from the current position. case relative } diff --git a/Sources/Cadova/Values/Curves/Bezier/Path Builder/ComponentFunctions.swift b/Sources/Cadova/Values/Curves/Bezier/Path Builder/ComponentFunctions.swift index 3e0aec6a..2138dc0e 100644 --- a/Sources/Cadova/Values/Curves/Bezier/Path Builder/ComponentFunctions.swift +++ b/Sources/Cadova/Values/Curves/Bezier/Path Builder/ComponentFunctions.swift @@ -1,27 +1,112 @@ import Foundation +// MARK: - Generic + +/// Creates a straight line segment to a specified point. +/// +/// This generic function works with both 2D and 3D paths. The point is interpreted +/// according to the path's positioning mode (absolute or relative). +/// +/// - Parameter point: The destination point for the line segment. +/// - Returns: A path component representing a straight line to the given point. +/// +/// - SeeAlso: ``line(x:y:)`` for 2D paths with individual coordinate control. +/// - SeeAlso: ``line(x:y:z:)`` for 3D paths with individual coordinate control. +/// public func line(_ point: V) -> BezierPath.Component { .init([.init(point)]) } +/// Creates a line segment that continues in the direction of the previous segment. +/// +/// This function extends the path in the same direction as the preceding curve's end tangent. +/// It is useful for creating smooth transitions where the path should continue straight +/// for a given distance before changing direction. +/// +/// - Parameter distance: The length of the line segment to add. +/// - Returns: A path component representing a line continuing in the current direction. +/// +/// - Precondition: The path must have at least one existing segment to determine the direction. +/// public func continuousLine(distance: Double) -> BezierPath.Component { .init(continuousDistance: distance, []) } +/// Creates a Bezier curve segment with the specified control points. +/// +/// The number of control points determines the curve order: +/// - 1 control point: Linear segment (equivalent to a line) +/// - 2 control points: Quadratic Bezier curve (one control point, one end point) +/// - 3 control points: Cubic Bezier curve (two control points, one end point) +/// +/// The last point in the array is always the curve's end point. Points are interpreted +/// according to the path's positioning mode (absolute or relative). +/// +/// - Parameter controlPoints: An array of control points defining the curve shape and end point. +/// - Returns: A path component representing a Bezier curve. +/// public func curve(_ controlPoints: [V]) -> BezierPath.Component { .init(controlPoints.map(PathBuilderVector.init)) } +/// Creates a Bezier curve segment with the specified control points. +/// +/// The number of control points determines the curve order: +/// - 1 control point: Linear segment (equivalent to a line) +/// - 2 control points: Quadratic Bezier curve (one control point, one end point) +/// - 3 control points: Cubic Bezier curve (two control points, one end point) +/// +/// The last point is always the curve's end point. Points are interpreted +/// according to the path's positioning mode (absolute or relative). +/// +/// - Parameter controlPoints: Variadic control points defining the curve shape and end point. +/// - Returns: A path component representing a Bezier curve. +/// public func curve(_ controlPoints: V...) -> BezierPath.Component { .init(controlPoints.map(PathBuilderVector.init)) } +/// Creates a Bezier curve that continues smoothly from the previous segment. +/// +/// This function creates a curve whose initial direction matches the end tangent of the +/// preceding segment, ensuring a smooth (C1 continuous) transition. The first control point +/// is automatically placed at the specified distance along the previous segment's direction. +/// +/// - Parameters: +/// - distance: The distance from the current point to the first (implicit) control point, +/// placed along the direction of the previous segment's end tangent. +/// - controlPoints: Additional control points and the end point for the curve. +/// - Returns: A path component representing a smooth continuation curve. +/// +/// - Precondition: The path must have at least one existing segment to determine the direction. +/// public func continuousCurve(distance: Double, controlPoints: [V]) -> BezierPath.Component { .init(continuousDistance: distance, controlPoints.map(PathBuilderVector.init)) } // MARK: - 2D +/// Creates a 2D line segment with individual coordinate control. +/// +/// Each coordinate can be specified as: +/// - A raw `Double` value, interpreted according to the path's default positioning mode +/// - A value with explicit positioning using `.relative` or `.absolute` suffixes +/// - `.unchanged` to keep the coordinate at its current value +/// +/// - Parameters: +/// - x: The X coordinate of the destination point. Defaults to `.unchanged`. +/// - y: The Y coordinate of the destination point. Defaults to `.unchanged`. +/// - Returns: A 2D path component representing a straight line. +/// +/// - Example: +/// ```swift +/// BezierPath2D(from: [10, 4], mode: .relative) { +/// line(x: 22, y: 1) // Move by (22, 1) relative to current point +/// line(x: 2) // Move by (2, 0) keeping Y unchanged +/// line(y: 76) // Move by (0, 76) keeping X unchanged +/// } +/// ``` +/// public func line( x: any PathBuilderValue = .unchanged, y: any PathBuilderValue = .unchanged @@ -29,6 +114,29 @@ public func line( .init([.init(x, y)]) } +/// Creates a 2D quadratic Bezier curve with one control point. +/// +/// A quadratic Bezier curve is defined by the current point, one control point that +/// influences the curve's shape, and an end point. The curve is pulled toward the +/// control point but does not pass through it. +/// +/// Each coordinate can be specified as a raw `Double`, with `.relative`/`.absolute` suffixes, +/// or as `.unchanged`. +/// +/// - Parameters: +/// - x1: The X coordinate of the control point. +/// - y1: The Y coordinate of the control point. +/// - endX: The X coordinate of the end point. +/// - endY: The Y coordinate of the end point. +/// - Returns: A 2D path component representing a quadratic Bezier curve. +/// +/// - Example: +/// ```swift +/// BezierPath2D(from: [0, 0]) { +/// curve(controlX: 50, controlY: 100, endX: 100, endY: 0) +/// } +/// ``` +/// public func curve( controlX x1: any PathBuilderValue, controlY y1: any PathBuilderValue, endX: any PathBuilderValue, endY: any PathBuilderValue @@ -36,6 +144,21 @@ public func curve( .init([.init(x1, y1), .init(endX, endY)]) } +/// Creates a 2D quadratic Bezier curve that continues smoothly from the previous segment. +/// +/// The curve's first control point is automatically placed along the previous segment's +/// end tangent direction, ensuring a smooth (C1 continuous) transition. This creates +/// a quadratic curve with the control point implicitly defined. +/// +/// - Parameters: +/// - distance: The distance from the current point to the implicit control point, +/// placed along the previous segment's end tangent direction. +/// - endX: The X coordinate of the end point. +/// - endY: The Y coordinate of the end point. +/// - Returns: A 2D path component representing a smooth continuation curve. +/// +/// - Precondition: The path must have at least one existing segment. +/// public func continuousCurve( distance: Double, endX: any PathBuilderValue, endY: any PathBuilderValue @@ -43,6 +166,38 @@ public func continuousCurve( .init(continuousDistance: distance, [.init(endX, endY)]) } +/// Creates a 2D cubic Bezier curve with two control points. +/// +/// A cubic Bezier curve is defined by the current point, two control points that +/// shape the curve, and an end point. The curve is pulled toward both control points +/// but typically does not pass through them. +/// +/// Cubic curves provide more flexibility than quadratic curves, allowing for +/// S-shaped curves and more complex paths. +/// +/// Each coordinate can be specified as a raw `Double`, with `.relative`/`.absolute` suffixes, +/// or as `.unchanged`. +/// +/// - Parameters: +/// - x1: The X coordinate of the first control point. +/// - y1: The Y coordinate of the first control point. +/// - x2: The X coordinate of the second control point. +/// - y2: The Y coordinate of the second control point. +/// - endX: The X coordinate of the end point. +/// - endY: The Y coordinate of the end point. +/// - Returns: A 2D path component representing a cubic Bezier curve. +/// +/// - Example: +/// ```swift +/// BezierPath2D(from: [0, 0]) { +/// curve( +/// controlX: 25, controlY: 100, +/// controlX: 75, controlY: 100, +/// endX: 100, endY: 0 +/// ) +/// } +/// ``` +/// public func curve( controlX x1: any PathBuilderValue, controlY y1: any PathBuilderValue, controlX x2: any PathBuilderValue, controlY y2: any PathBuilderValue, @@ -51,6 +206,23 @@ public func curve( .init([.init(x1, y1), .init(x2, y2), .init(endX, endY)]) } +/// Creates a 2D cubic Bezier curve that continues smoothly from the previous segment. +/// +/// The curve's first control point is automatically placed along the previous segment's +/// end tangent direction, ensuring a smooth (C1 continuous) transition. You specify only +/// the second control point and the end point. +/// +/// - Parameters: +/// - distance: The distance from the current point to the first (implicit) control point, +/// placed along the previous segment's end tangent direction. +/// - x2: The X coordinate of the second control point. +/// - y2: The Y coordinate of the second control point. +/// - endX: The X coordinate of the end point. +/// - endY: The Y coordinate of the end point. +/// - Returns: A 2D path component representing a smooth continuation cubic curve. +/// +/// - Precondition: The path must have at least one existing segment. +/// public func continuousCurve( distance: Double, controlX x2: any PathBuilderValue, controlY y2: any PathBuilderValue, @@ -67,14 +239,73 @@ internal func arc(center: PathBuilderVector, angle: Angle, clockwise: } } +/// Creates a clockwise arc around a center point, sweeping through the specified angle. +/// +/// The arc starts at the current path position and sweeps clockwise around the given center +/// for the specified angular distance. The radius is determined by the distance from the +/// current point to the center. +/// +/// When the path uses absolute positioning, the `angle` parameter specifies the absolute +/// end angle of the arc. When using relative positioning, it specifies how far to rotate +/// from the current position. +/// +/// - Parameters: +/// - center: The center point of the arc. +/// - angle: The angle to sweep through (absolute or relative depending on path mode). +/// - Returns: A 2D path component representing a clockwise arc. +/// +/// - SeeAlso: ``counterclockwiseArc(center:angle:)`` +/// - SeeAlso: ``clockwiseArc(centerX:centerY:angle:)`` +/// public func clockwiseArc(center: Vector2D, angle: Angle) -> BezierPath2D.Component { arc(center: .init(center), angle: angle, clockwise: true) } +/// Creates a counterclockwise arc around a center point, sweeping through the specified angle. +/// +/// The arc starts at the current path position and sweeps counterclockwise around the given +/// center for the specified angular distance. The radius is determined by the distance from +/// the current point to the center. +/// +/// When the path uses absolute positioning, the `angle` parameter specifies the absolute +/// end angle of the arc. When using relative positioning, it specifies how far to rotate +/// from the current position. +/// +/// - Parameters: +/// - center: The center point of the arc. +/// - angle: The angle to sweep through (absolute or relative depending on path mode). +/// - Returns: A 2D path component representing a counterclockwise arc. +/// +/// - SeeAlso: ``clockwiseArc(center:angle:)`` +/// - SeeAlso: ``counterclockwiseArc(centerX:centerY:angle:)`` +/// public func counterclockwiseArc(center: Vector2D, angle: Angle) -> BezierPath2D.Component { arc(center: .init(center), angle: angle, clockwise: false) } +/// Creates a clockwise arc with individual coordinate control for the center. +/// +/// This variant allows specifying the center point using individual X and Y coordinates, +/// each of which can use different positioning modes or be left unchanged from the current +/// position. +/// +/// - Parameters: +/// - centerX: The X coordinate of the arc's center. Defaults to `.unchanged`. +/// - centerY: The Y coordinate of the arc's center. Defaults to `.unchanged`. +/// - angle: The angle to sweep through. +/// - Returns: A 2D path component representing a clockwise arc. +/// +/// - Example: +/// ```swift +/// BezierPath2D(from: [-5, 0], mode: .relative) { +/// line(y: 10) +/// clockwiseArc(centerX: 5, angle: 180°) // Arc around point 5 units to the right +/// line(y: -10) +/// } +/// ``` +/// +/// - SeeAlso: ``clockwiseArc(center:angle:)`` +/// public func clockwiseArc( centerX: any PathBuilderValue = .unchanged, centerY: any PathBuilderValue = .unchanged, @@ -83,6 +314,20 @@ public func clockwiseArc( arc(center: .init(centerX, centerY), angle: angle, clockwise: true) } +/// Creates a counterclockwise arc with individual coordinate control for the center. +/// +/// This variant allows specifying the center point using individual X and Y coordinates, +/// each of which can use different positioning modes or be left unchanged from the current +/// position. +/// +/// - Parameters: +/// - centerX: The X coordinate of the arc's center. Defaults to `.unchanged`. +/// - centerY: The Y coordinate of the arc's center. Defaults to `.unchanged`. +/// - angle: The angle to sweep through. +/// - Returns: A 2D path component representing a counterclockwise arc. +/// +/// - SeeAlso: ``counterclockwiseArc(center:angle:)`` +/// public func counterclockwiseArc( centerX: any PathBuilderValue = .unchanged, centerY: any PathBuilderValue = .unchanged, @@ -94,6 +339,27 @@ public func counterclockwiseArc( // MARK: - 3D +/// Creates a 3D line segment with individual coordinate control. +/// +/// Each coordinate can be specified as: +/// - A raw `Double` value, interpreted according to the path's default positioning mode +/// - A value with explicit positioning using `.relative` or `.absolute` suffixes +/// - `.unchanged` to keep the coordinate at its current value +/// +/// - Parameters: +/// - x: The X coordinate of the destination point. Defaults to `.unchanged`. +/// - y: The Y coordinate of the destination point. Defaults to `.unchanged`. +/// - z: The Z coordinate of the destination point. Defaults to `.unchanged`. +/// - Returns: A 3D path component representing a straight line. +/// +/// - Example: +/// ```swift +/// BezierPath3D(from: [0, 0, 0], mode: .relative) { +/// line(x: 10, y: 5, z: 2) // Move by (10, 5, 2) +/// line(z: 10) // Move up 10 units, keeping X and Y unchanged +/// } +/// ``` +/// public func line( x: any PathBuilderValue = .unchanged, y: any PathBuilderValue = .unchanged, @@ -102,6 +368,24 @@ public func line( .init([.init(x, y, z)]) } +/// Creates a 3D quadratic Bezier curve with one control point. +/// +/// A quadratic Bezier curve is defined by the current point, one control point that +/// influences the curve's shape, and an end point. The curve is pulled toward the +/// control point but does not pass through it. +/// +/// Each coordinate can be specified as a raw `Double`, with `.relative`/`.absolute` suffixes, +/// or as `.unchanged`. +/// +/// - Parameters: +/// - x1: The X coordinate of the control point. +/// - y1: The Y coordinate of the control point. +/// - z1: The Z coordinate of the control point. +/// - endX: The X coordinate of the end point. +/// - endY: The Y coordinate of the end point. +/// - endZ: The Z coordinate of the end point. +/// - Returns: A 3D path component representing a quadratic Bezier curve. +/// public func curve( controlX x1: any PathBuilderValue, controlY y1: any PathBuilderValue, controlZ z1: any PathBuilderValue, endX: any PathBuilderValue, endY: any PathBuilderValue, endZ: any PathBuilderValue @@ -109,6 +393,21 @@ public func curve( .init([.init(x1, y1, z1), .init(endX, endY, endZ)]) } +/// Creates a 3D quadratic Bezier curve that continues smoothly from the previous segment. +/// +/// The curve's first control point is automatically placed along the previous segment's +/// end tangent direction, ensuring a smooth (C1 continuous) transition. +/// +/// - Parameters: +/// - distance: The distance from the current point to the implicit control point, +/// placed along the previous segment's end tangent direction. +/// - endX: The X coordinate of the end point. +/// - endY: The Y coordinate of the end point. +/// - endZ: The Z coordinate of the end point. +/// - Returns: A 3D path component representing a smooth continuation curve. +/// +/// - Precondition: The path must have at least one existing segment. +/// public func continuousCurve( distance: Double, endX: any PathBuilderValue, endY: any PathBuilderValue, endZ: any PathBuilderValue @@ -116,6 +415,27 @@ public func continuousCurve( .init(continuousDistance: distance, [.init(endX, endY, endZ)]) } +/// Creates a 3D cubic Bezier curve with two control points. +/// +/// A cubic Bezier curve is defined by the current point, two control points that +/// shape the curve, and an end point. Cubic curves provide more flexibility than +/// quadratic curves, allowing for S-shaped curves and more complex 3D paths. +/// +/// Each coordinate can be specified as a raw `Double`, with `.relative`/`.absolute` suffixes, +/// or as `.unchanged`. +/// +/// - Parameters: +/// - x1: The X coordinate of the first control point. +/// - y1: The Y coordinate of the first control point. +/// - z1: The Z coordinate of the first control point. +/// - x2: The X coordinate of the second control point. +/// - y2: The Y coordinate of the second control point. +/// - z2: The Z coordinate of the second control point. +/// - endX: The X coordinate of the end point. +/// - endY: The Y coordinate of the end point. +/// - endZ: The Z coordinate of the end point. +/// - Returns: A 3D path component representing a cubic Bezier curve. +/// public func curve( controlX x1: any PathBuilderValue, controlY y1: any PathBuilderValue, controlZ z1: any PathBuilderValue, controlX x2: any PathBuilderValue, controlY y2: any PathBuilderValue, controlZ z2: any PathBuilderValue, @@ -124,6 +444,25 @@ public func curve( .init([.init(x1, y1, z1), .init(x2, y2, z2), .init(endX, endY, endZ)]) } +/// Creates a 3D cubic Bezier curve that continues smoothly from the previous segment. +/// +/// The curve's first control point is automatically placed along the previous segment's +/// end tangent direction, ensuring a smooth (C1 continuous) transition. You specify only +/// the second control point and the end point. +/// +/// - Parameters: +/// - distance: The distance from the current point to the first (implicit) control point, +/// placed along the previous segment's end tangent direction. +/// - x2: The X coordinate of the second control point. +/// - y2: The Y coordinate of the second control point. +/// - z2: The Z coordinate of the second control point. +/// - endX: The X coordinate of the end point. +/// - endY: The Y coordinate of the end point. +/// - endZ: The Z coordinate of the end point. +/// - Returns: A 3D path component representing a smooth continuation cubic curve. +/// +/// - Precondition: The path must have at least one existing segment. +/// public func continuousCurve( distance: Double, controlX x2: any PathBuilderValue, controlY y2: any PathBuilderValue, controlZ z2: any PathBuilderValue, diff --git a/Sources/Cadova/Values/Curves/Bezier/Path Builder/PathBuilderValue.swift b/Sources/Cadova/Values/Curves/Bezier/Path Builder/PathBuilderValue.swift index 8488a1ff..dcf377fb 100644 --- a/Sources/Cadova/Values/Curves/Bezier/Path Builder/PathBuilderValue.swift +++ b/Sources/Cadova/Values/Curves/Bezier/Path Builder/PathBuilderValue.swift @@ -1,18 +1,73 @@ import Foundation +/// A type that can be used as a coordinate value in path builder functions. +/// +/// This protocol enables flexible coordinate specification in path construction. Values +/// conforming to this protocol can represent absolute positions, relative offsets, or +/// indicate that a coordinate should remain unchanged. +/// +/// `Double` conforms to this protocol and provides additional modifiers: +/// - Use a raw `Double` to specify a value that follows the path's default positioning mode +/// - Use `.relative` suffix to explicitly mark a value as a relative offset +/// - Use `.absolute` suffix to explicitly mark a value as an absolute position +/// - Use `.unchanged` to keep the coordinate at its current value +/// +/// - Example: +/// ```swift +/// BezierPath2D(from: [0, 0], mode: .absolute) { +/// line(x: 100, y: 50) // Both absolute (follows default) +/// line(x: 10.relative, y: 20) // X is relative (+10), Y is absolute (20) +/// line(x: .unchanged, y: 100) // X stays at 10, Y goes to 100 +/// } +/// ``` +/// +/// - SeeAlso: ``PathBuilderPositioning`` +/// public protocol PathBuilderValue: Sendable {} extension Double: PathBuilderValue { + /// Returns this value marked as a relative offset. + /// + /// When used in a path builder function, this value will be interpreted as an offset + /// from the current position, regardless of the path's default positioning mode. + /// + /// - Example: + /// ```swift + /// line(x: 10.relative, y: 5.relative) // Move by (10, 5) from current position + /// ``` + /// public var relative: any PathBuilderValue { PositionedValue(value: self, mode: .relative) } + /// Returns this value marked as an absolute position. + /// + /// When used in a path builder function, this value will be interpreted as an absolute + /// coordinate in the path's coordinate system, regardless of the path's default + /// positioning mode. + /// + /// - Example: + /// ```swift + /// line(x: 100.absolute, y: 50.absolute) // Go to point (100, 50) + /// ``` + /// public var absolute: any PathBuilderValue { PositionedValue(value: self, mode: .absolute) } } public extension PathBuilderValue where Self == Double { + /// A sentinel value indicating that a coordinate should remain at its current value. + /// + /// Use this when you want to change only some coordinates while leaving others unchanged. + /// This is equivalent to `0.relative` but communicates intent more clearly. + /// + /// - Example: + /// ```swift + /// line(x: 100, y: .unchanged) // Move to X=100, keep Y at current value + /// line(x: .unchanged, y: 50) // Keep X, move to Y=50 + /// ``` + /// static var unchanged: any PathBuilderValue { 0.relative } } diff --git a/Sources/Cadova/Values/Curves/InterpolatingCurve.swift b/Sources/Cadova/Values/Curves/InterpolatingCurve.swift index 746d68d7..c93abfae 100644 --- a/Sources/Cadova/Values/Curves/InterpolatingCurve.swift +++ b/Sources/Cadova/Values/Curves/InterpolatingCurve.swift @@ -26,6 +26,7 @@ public struct InterpolatingCurve: ParametricCurve, Sendable, Hashable /// Number of distinct support points used for wrapping at the seam. private var wrappedSupportCount: Int { isClosed ? (points.count - 1) : points.count } + /// Always returns `false` since an interpolating curve requires at least two points. public var isEmpty: Bool { false } /// Domain measured in segment units (e.g. `1.25` is 25% into the second segment). @@ -35,6 +36,12 @@ public struct InterpolatingCurve: ParametricCurve, Sendable, Hashable public var sampleCountForLengthApproximation: Int { (points.count - 1) * 4 } + /// Returns the point on the curve at parameter `u`. + /// + /// - Parameter u: A parameter value where the integer part indicates the segment index + /// and the fractional part indicates position within that segment. + /// - Returns: The interpolated point on the curve. + /// public func point(at u: Double) -> V { let uClamped = u.clamped(to: domain) let segmentIndex = min(Int(floor(uClamped)), max(points.count - 2, 0)) @@ -76,10 +83,22 @@ public struct InterpolatingCurve: ParametricCurve, Sendable, Hashable // MARK: - Sampling + /// Returns points sampled along the entire curve. + /// + /// - Parameter segmentation: Controls the sampling density and strategy. + /// - Returns: An array of points from start to end of the curve. + /// public func points(segmentation: Segmentation) -> [V] { points(in: domain, segmentation: segmentation) } + /// Returns points sampled along a parameter subrange. + /// + /// - Parameters: + /// - range: The parameter range to sample within. + /// - segmentation: Controls the sampling density and strategy. + /// - Returns: An array of points covering the specified range. + /// public func points(in range: ClosedRange, segmentation: Segmentation) -> [V] { let span = range.clamped(to: domain) switch segmentation { @@ -134,10 +153,20 @@ public struct InterpolatingCurve: ParametricCurve, Sendable, Hashable InterpolatingCurveDerivativeView(curve: self) } + /// Creates a 2D curve by transforming each control point. + /// + /// - Parameter transformer: A closure that converts each point to 2D. + /// - Returns: A new 2D interpolating curve with transformed points. + /// public func mapPoints(_ transformer: (V) -> Vector2D) -> InterpolatingCurve { InterpolatingCurve(through: points.map(transformer)) } + /// Creates a 3D curve by transforming each control point. + /// + /// - Parameter transformer: A closure that converts each point to 3D. + /// - Returns: A new 3D interpolating curve with transformed points. + /// public func mapPoints(_ transformer: (V) -> Vector3D) -> InterpolatingCurve { InterpolatingCurve(through: points.map(transformer)) } diff --git a/Sources/Cadova/Values/Curves/ParametricCurve.swift b/Sources/Cadova/Values/Curves/ParametricCurve.swift index 3a209727..43678090 100644 --- a/Sources/Cadova/Values/Curves/ParametricCurve.swift +++ b/Sources/Cadova/Values/Curves/ParametricCurve.swift @@ -1,80 +1,153 @@ import Foundation -/// A curve evaluated by a single scalar parameter `u`. +/// A parametric curve evaluated by a single scalar parameter `u`. /// -/// - SeeAlso: `BezierPath` +/// Conforming types represent geometric curves that can be sampled, measured, +/// and converted to 2D or 3D vector spaces. Unless otherwise specified, `u` grows +/// in the curve's "forward" direction and the curve is considered defined over +/// its `domain`. /// +/// - Notes: +/// - Implementations may choose to clamp, extrapolate, or otherwise define behavior +/// for parameter values outside of `domain`. Each conformer should document its policy. +/// - Some curves can be "empty" (e.g., a subcurve whose bounds collapse to a single value, +/// or a degenerate curve with zero length). See `isEmpty`. +/// - Tangent directions are provided via `derivativeView` for efficient repeated evaluation. +/// - SeeAlso: `SplineCurve`, `Subcurve`, `CurveSample`, `Segmentation` public protocol ParametricCurve: Sendable, Hashable, Codable { associatedtype V: Vector associatedtype Curve2D: ParametricCurve associatedtype Curve3D: ParametricCurve typealias Axis = V.D.Axis - // add dpcs-does this curve represent an actual curve with a length? A bezier path without curves inside does not, for example. - // A subcurve with the same lowerBound and upperBounds also does not + /// Indicates whether this curve represents a non‑trivial geometric curve. + /// + /// Implementations should return `true` when the curve has no measurable extent + /// (for example, a subcurve whose lower and upper bounds are equal, or a curve + /// with zero length/degenerate data) and `false` otherwise. var isEmpty: Bool { get } /// The parameter interval over which the curve is naturally defined. /// - /// - Note: Conformers may choose to extrapolate outside this range. + /// Conformers may clamp or extrapolate outside this range. Callers should not + /// assume any particular behavior for out‑of‑range values unless the conformer’s + /// documentation specifies it. var domain: ClosedRange { get } - /// Returns the point at parameter `u`. + /// Evaluates the curve position at parameter `u`. /// - /// - Parameter u: The parameter value. Values outside `domain` are allowed; - /// behavior (clamp/extrapolate/wrap) is curve-specific. + /// - Parameter u: The parameter value. Values outside `domain` are permitted, but + /// behavior is curve‑specific (e.g., clamp, extrapolate, or mirror). + /// - Returns: The point on the curve corresponding to `u`. func point(at u: Double) -> V - /// Returns a set of points sampled along the curve. + /// Returns a set of points sampled along the entire curve. + /// + /// - Parameter segmentation: Controls the sampling density and strategy (fixed or adaptive). + /// - Returns: An ordered array of points from start to end of `domain`. The first point + /// corresponds to `domain.lowerBound`, and the last to `domain.upperBound`. /// - /// - Parameter segmentation: Controls sampling density. + /// - SeeAlso: `points(in:segmentation:)`, `Segmentation` func points(segmentation: Segmentation) -> [V] - // add docs + /// Returns a set of points sampled along a parameter subrange. + /// + /// - Parameters: + /// - range: The parameter subrange to sample. Implementations should clamp this + /// range to `domain`. If the clamped range is empty, the result may be empty or + /// contain a single point, depending on the conformer. + /// - segmentation: Controls the sampling density and strategy (fixed or adaptive). + /// - Returns: An ordered array of points covering `range ∩ domain`. func points(in range: ClosedRange, segmentation: Segmentation) -> [V] - /// Solves for a parameter `u` whose point has the given coordinate value - /// along an axis (only valid when the curve is monotone in that axis). + /// Solves for a parameter `u` whose point’s coordinate matches `value` along an axis. + /// + /// This method is only valid when the curve is monotone in the given axis over the + /// relevant interval. If the curve is not monotone or no solution exists, `nil` is returned. /// /// - Parameters: - /// - value: Target coordinate value. - /// - axis: Axis whose coordinate is matched. - /// - Returns: The parameter `u` if a solution is found, otherwise `nil`. + /// - value: The target coordinate value to match. + /// - axis: The axis whose coordinate is matched. + /// - Returns: A parameter `u` such that `point(at: u)[axis] == value`, if found; otherwise `nil`. + /// Implementations may return a `u` outside `domain` if extrapolation is supported. func parameter(matching value: Double, along axis: Axis) -> Double? - /// Returns rich samples along the curve. + /// Returns rich samples along the curve suitable for framing, length accumulation, and analysis. /// - /// - Note: The first sample’s `distance` must be `0`. Each - /// subsequent sample’s `distance` is the accumulated arc length - /// measured from that first sample (i.e., the start of this extraction), - /// not from the start of the curve’s domain. + /// The returned array must be ordered by increasing `u`. The first sample’s `distance` must be `0`. + /// Each subsequent sample’s `distance` is the accumulated arc length measured from that first sample + /// (i.e., the start of this extraction), not from the start of the curve’s overall `domain`. + /// + /// - Parameter segmentation: Controls the sampling density and strategy (fixed or adaptive). + /// - Returns: An array of `CurveSample` values including parameter `u`, position, unit tangent + /// direction, and accumulated distance from the first sample. func samples(segmentation: Segmentation) -> [CurveSample] - // add docs. this is for efficently repeatedly evaluating tangents along the curve + /// Provides efficient access to curve derivatives for repeated tangent evaluation. + /// + /// This view is intended to avoid recomputing derivative structures when querying + /// tangents at many parameter values. The returned tangents should be unit directions, + /// or at minimum consistent with `Direction` semantics for the vector space. var derivativeView: any CurveDerivativeView { get } - /// Calculates the total length of the curve. + /// Approximates the total arc length of the curve. /// - /// - Parameter segmentation: The desired level of detail for the generated points, which influences the accuracy - /// of the length calculation. More detailed segmentation results in more points being generated, leading to a - /// more accurate length approximation. - /// - Returns: A `Double` value representing the total length of the curve. - /// + /// - Parameter segmentation: The desired level of detail for the generated points, + /// which influences the accuracy of the length calculation. More detailed segmentation + /// produces a more accurate approximation at the cost of performance. + /// - Returns: The approximate arc length of the curve over `domain`. func length(segmentation: Segmentation) -> Double - // get a subrange of the curve as a new curve + /// Extracts a subcurve defined by a parameter range. + /// + /// The resulting subcurve preserves the original parameterization (i.e., it does not + /// reparameterize to a normalized 0…1 range) and usually clamps the requested range to `domain`. + /// + /// - Parameter range: Any `RangeExpression` over `Double` describing the desired subrange. + /// - Returns: A `Subcurve` view over the base curve. If the resulting range is empty, + /// the subcurve may be empty (see `isEmpty`). subscript(range: any RangeExpression) -> Subcurve { get } - // apply an operation to a curve's points + /// Creates a 2D curve by transforming each point of this curve. + /// + /// - Parameter transformer: A function that maps a point in `V` to `Vector2D`. + /// - Returns: A new curve of type `Curve2D` with transformed points and equivalent parameterization. func mapPoints(_ transformer: (V) -> Vector2D) -> Curve2D + + /// Creates a 3D curve by transforming each point of this curve. + /// + /// - Parameter transformer: A function that maps a point in `V` to `Vector3D`. + /// - Returns: A new curve of type `Curve3D` with transformed points and equivalent parameterization. func mapPoints(_ transformer: (V) -> Vector3D) -> Curve3D + /// A hint for coarse length approximations or preview sampling. + /// + /// Conformers may return a representative sample count that callers can use to balance + /// performance and quality when an explicit `Segmentation` is not available. This value + /// is not a strict requirement and may be ignored by clients with their own strategies. var sampleCountForLengthApproximation: Int { get } + /// Optional set of labeled control points for UI/inspection. + /// + /// When available, this provides the underlying control vertices used to define the curve, + /// along with optional labels (e.g., indices or weights). Curves that are not control‑point‑based + /// may return `nil`. var labeledControlPoints: [(V, label: String?)]? { get } } +/// A lightweight interface for evaluating derivatives of a parametric curve. +/// +/// Implementations should return tangent directions consistent with the parent curve’s +/// parameterization. Unless documented otherwise, `tangent(at:)` should produce a unit +/// direction or a value compatible with `Direction` semantics. +/// +/// - SeeAlso: `ParametricCurve.derivativeView` public protocol CurveDerivativeView { associatedtype V: Vector + + /// Returns the tangent direction at parameter `u`. + /// + /// Implementations should define behavior for values outside the curve’s `domain` + /// (e.g., clamp or extrapolate) consistent with the parent curve. func tangent(at u: Double) -> Direction } diff --git a/Sources/Cadova/Values/Curves/Spline Curve/SplineCurve.swift b/Sources/Cadova/Values/Curves/Spline Curve/SplineCurve.swift index 1ce48b13..b3aa461f 100644 --- a/Sources/Cadova/Values/Curves/Spline Curve/SplineCurve.swift +++ b/Sources/Cadova/Values/Curves/Spline Curve/SplineCurve.swift @@ -114,13 +114,13 @@ public struct SplineCurve: Sendable, Hashable, Codable { } extension SplineCurve: ParametricCurve { - /// Samples points along the curve using a `Segmentation`. + /// Returns points sampled along a parameter subrange. /// /// - Parameters: - /// - segmentation: The segmentation strategy. - /// - /// For `.fixed`, samples `n` segments uniformly in parameter space. - /// For `.adaptive`, recursively subdivides parameter intervals based on chord length. + /// - range: The parameter range to sample within. + /// - segmentation: The sampling strategy. For `.fixed`, samples uniformly in parameter space. + /// For `.adaptive`, recursively subdivides based on chord length. + /// - Returns: An array of points covering the specified range. /// public func points(in range: ClosedRange, segmentation: Segmentation) -> [V] { let span = range.clamped(to: domain) @@ -158,21 +158,34 @@ extension SplineCurve: ParametricCurve { } + /// Always returns `false` since a spline curve requires at least one control point. public var isEmpty: Bool { false } + public var sampleCountForLengthApproximation: Int { controlPoints.count * 3 } + /// The parameter range over which the curve is defined. public var domain: ClosedRange { knots[degree]...knots[knots.count - degree - 1] } - + public var derivativeView: any CurveDerivativeView { SplineCurveDerivativeView(splineCurve: self) } + /// Creates a 2D curve by transforming each control point. + /// + /// - Parameter transformer: A closure that converts each point to 2D. + /// - Returns: A new 2D spline curve with transformed points. + /// public func mapPoints(_ transformer: (V) -> Vector2D) -> SplineCurve { map(transformer) } + /// Creates a 3D curve by transforming each control point. + /// + /// - Parameter transformer: A closure that converts each point to 3D. + /// - Returns: A new 3D spline curve with transformed points. + /// public func mapPoints(_ transformer: (V) -> Vector3D) -> SplineCurve { map(transformer) } diff --git a/Sources/Cadova/Values/Curves/Subcurve.swift b/Sources/Cadova/Values/Curves/Subcurve.swift index e4276f39..7ee74e9b 100644 --- a/Sources/Cadova/Values/Curves/Subcurve.swift +++ b/Sources/Cadova/Values/Curves/Subcurve.swift @@ -1,8 +1,21 @@ import Foundation +/// A view into a portion of an existing parametric curve. +/// +/// A subcurve references a base curve and restricts evaluation to a specific parameter range. +/// It preserves the original parameterization (does not remap to 0...1). +/// +/// You typically create subcurves using the subscript operator on any ``ParametricCurve``: +/// ```swift +/// let fullCurve = BezierPath2D(...) +/// let firstHalf = fullCurve[0...0.5] +/// ``` +/// public struct Subcurve: ParametricCurve { public typealias V = Base.V let base: Base + + /// The parameter range this subcurve covers. public let domain: ClosedRange public var isEmpty: Bool { domain.length > 0 } diff --git a/Sources/Cadova/Values/Material.swift b/Sources/Cadova/Values/Material.swift index 56c94ada..18505b43 100644 --- a/Sources/Cadova/Values/Material.swift +++ b/Sources/Cadova/Values/Material.swift @@ -25,15 +25,24 @@ public struct Material: Hashable, Sendable, Codable { self.physicalProperties = properties } + /// Physically based rendering (PBR) properties using the metallic/roughness model. + /// + /// These properties define how a material interacts with light, allowing you to create + /// realistic metals, plastics, and other surface types. + /// public struct PhysicalProperties: Hashable, Sendable, Codable { + /// How metallic the surface appears, from 0 (non-metallic) to 1 (fully metallic). let metallicness: Double + + /// How rough the surface appears, from 0 (smooth and reflective) to 1 (fully matte). let roughness: Double - /// Initializes PBR properties using a metallic/roughness model. + /// Creates PBR properties using a metallic/roughness model. /// /// - Parameters: /// - metallicness: A value between 0 (non-metallic) and 1 (fully metallic). /// - roughness: A value between 0 (smooth and reflective) and 1 (fully matte). + /// init(metallicness: Double, roughness: Double) { self.metallicness = metallicness self.roughness = roughness diff --git a/Sources/Cadova/Values/MeshData.swift b/Sources/Cadova/Values/MeshData.swift index bee556cc..f137eba0 100644 --- a/Sources/Cadova/Values/MeshData.swift +++ b/Sources/Cadova/Values/MeshData.swift @@ -1,7 +1,7 @@ import Foundation import Manifold3D -public struct MeshData: Sendable, Hashable, Codable { +internal struct MeshData: Sendable, Hashable, Codable { internal let vertices: [Vector3D] internal let faces: [Face] diff --git a/Sources/Cadova/Values/Segmentation.swift b/Sources/Cadova/Values/Segmentation.swift index 741b02e8..8889efe0 100644 --- a/Sources/Cadova/Values/Segmentation.swift +++ b/Sources/Cadova/Values/Segmentation.swift @@ -78,4 +78,4 @@ public enum Segmentation: Sendable, Hashable, Codable { return Int(ceil(max(length / minSize, 5))) } } - } +} diff --git a/Sources/Cadova/Values/ShapingFunction+Internal.swift b/Sources/Cadova/Values/ShapingFunction+Internal.swift index 163c2e3a..3f7cbb9d 100644 --- a/Sources/Cadova/Values/ShapingFunction+Internal.swift +++ b/Sources/Cadova/Values/ShapingFunction+Internal.swift @@ -13,6 +13,7 @@ extension ShapingFunction { case bezier case circularEaseIn case circularEaseOut + case sine case mix case custom } @@ -43,6 +44,8 @@ extension ShapingFunction { hasher.combine(Kind.circularEaseIn) case .circularEaseOut: hasher.combine(Kind.circularEaseOut) + case .sine: + hasher.combine(Kind.sine) case .mix (let a, let b, let weight): hasher.combine(Kind.mix) hasher.combine(a) @@ -69,7 +72,8 @@ extension ShapingFunction { (.smoothstep, .smoothstep), (.smootherstep, .smootherstep), (.circularEaseIn, .circularEaseIn), - (.circularEaseOut, .circularEaseOut): + (.circularEaseOut, .circularEaseOut), + (.sine, .sine): return true case (.custom(let aKey, _), .custom(let bKey, _)): @@ -123,6 +127,8 @@ extension ShapingFunction.Curve: Codable { self = .circularEaseIn case .circularEaseOut: self = .circularEaseOut + case .sine: + self = .sine case .mix: self = .mix( @@ -165,6 +171,8 @@ extension ShapingFunction.Curve: Codable { try container.encode(ShapingFunction.Kind.circularEaseIn, forKey: .kind) case .circularEaseOut: try container.encode(ShapingFunction.Kind.circularEaseOut, forKey: .kind) + case .sine: + try container.encode(ShapingFunction.Kind.sine, forKey: .kind) case .mix(let a, let b, let weight): try container.encode(a, forKey: .a) diff --git a/Sources/Cadova/Values/ShapingFunction.swift b/Sources/Cadova/Values/ShapingFunction.swift index b0a55904..a42ef77e 100644 --- a/Sources/Cadova/Values/ShapingFunction.swift +++ b/Sources/Cadova/Values/ShapingFunction.swift @@ -1,7 +1,26 @@ import Foundation -/// A shaping function maps a value in the range 0...1 to a new value in the same range, commonly used for easing and -/// interpolation. +/// A function that maps values from 0...1 to 0...1, used for easing and interpolation. +/// +/// Shaping functions control how values transition between start and end points. They are used +/// throughout Cadova for operations like lofting, sweeping, and other interpolated transformations. +/// +/// Use one of the built-in functions like ``linear``, ``easeIn``, ``easeOut``, or ``smoothstep``, +/// or create a custom function with ``bezier(_:_:)`` or ``custom(name:parameters:function:)``. +/// +/// ```swift +/// // Use a shaping function to control loft interpolation +/// Loft { +/// layer(z: 0) { Circle(diameter: 10) } +/// layer(z: 20, interpolation: .easeInOut) { Circle(diameter: 20) } +/// } +/// ``` +/// +/// You can call shaping functions directly: +/// ```swift +/// let eased = ShapingFunction.easeInOut(0.5) // Returns ~0.5 +/// ``` +/// public struct ShapingFunction: Sendable, Hashable, Codable { internal let curve: Curve @@ -22,12 +41,18 @@ public struct ShapingFunction: Sendable, Hashable, Codable { case .smootherstep: { $0 * $0 * $0 * ($0 * (6 * $0 - 15) + 10) } case .circularEaseIn: { 1 - sqrt(1 - $0 * $0) } case .circularEaseOut: { sqrt(1 - (1 - $0) * (1 - $0)) } + case .sine: { (1 - cos($0 * .pi)) / 2 } case .bezier (let curve): { curve.point(at: curve.t(for: $0, in: .x) ?? $0).y } case .mix (let a, let b, let weight): { (1 - weight) * a.function($0) + weight * b.function($0) } case .custom (_, let function): function } } + /// Evaluates the shaping function at the given input value. + /// + /// - Parameter input: A value typically in the range 0...1. + /// - Returns: The shaped output value. + /// public func callAsFunction(_ input: Double) -> Double { function(input) } @@ -45,6 +70,7 @@ internal extension ShapingFunction { case smootherstep case circularEaseIn case circularEaseOut + case sine case bezier (BezierCurve) case mix (ShapingFunction, ShapingFunction, Double) case custom (cacheKey: LabeledCacheKey, function: @Sendable (Double) -> Double) @@ -119,6 +145,14 @@ public extension ShapingFunction { ShapingFunction(curve: .circularEaseOut) } + /// A sine-based shaping function using a half cosine wave. + /// + /// Produces smooth acceleration and deceleration with continuous derivatives at all points. + /// This creates a natural-feeling ease-in-out effect based on trigonometric functions. + static var sine: Self { + ShapingFunction(curve: .sine) + } + /// A cubic Bézier-based shaping function mapping input from 0 to 1 onto the curve defined by two control points. /// /// The resulting function is suitable for easing, interpolation, and other shaping purposes. diff --git a/Sources/Cadova/Values/Transforms/2D/Transform2D.swift b/Sources/Cadova/Values/Transforms/2D/Transform2D.swift index bda6fda4..d18a29eb 100644 --- a/Sources/Cadova/Values/Transforms/2D/Transform2D.swift +++ b/Sources/Cadova/Values/Transforms/2D/Transform2D.swift @@ -96,6 +96,26 @@ public struct Transform2D: Transform { if self[2,1] != 0.0 { return false } return true } + + /// Per-axis scale of the linear 2×2 part (ignoring translation). + /// + /// Computed as the Euclidean norms of the two column vectors of the upper-left 2×2 submatrix. + /// For the identity transform, this is [1, 1]. + /// + /// Notes: + /// - If the transform contains shear, these values are approximations based on column norms. + /// - Reflections are handled via absolute magnitudes. + public var scale: Vector2D { + if isIdentity { return Vector2D(1, 1) } + + let c0x = self[0,0], c0y = self[1,0] + let c1x = self[0,1], c1y = self[1,1] + + let sx = sqrt(c0x * c0x + c0y * c0y) + let sy = sqrt(c1x * c1x + c1y * c1y) + + return Vector2D(sx, sy) + } } public extension Transform2D { diff --git a/Sources/Cadova/Values/Transforms/3D/Transform3D.swift b/Sources/Cadova/Values/Transforms/3D/Transform3D.swift index 01c3ef49..02e46c90 100644 --- a/Sources/Cadova/Values/Transforms/3D/Transform3D.swift +++ b/Sources/Cadova/Values/Transforms/3D/Transform3D.swift @@ -100,6 +100,28 @@ public struct Transform3D: Transform { if self[3,2] != 0.0 { return false } return true } + + /// Per-axis scale of the linear 3×3 part (ignoring translation). + /// + /// Computed as the Euclidean norms of the three column vectors of the upper-left 3×3 submatrix. + /// For the identity transform, this is [1, 1, 1]. + /// + /// Notes: + /// - If the transform contains shear, these values are approximations based on column norms. + /// - Reflections are handled via absolute magnitudes. + public var scale: Vector3D { + if isIdentity { return Vector3D(1, 1, 1) } + + let c0x = self[0,0], c0y = self[1,0], c0z = self[2,0] + let c1x = self[0,1], c1y = self[1,1], c1z = self[2,1] + let c2x = self[0,2], c2y = self[1,2], c2z = self[2,2] + + let sx = sqrt(c0x * c0x + c0y * c0y + c0z * c0z) + let sy = sqrt(c1x * c1x + c1y * c1y + c1z * c1z) + let sz = sqrt(c2x * c2x + c2y * c2y + c2z * c2z) + + return Vector3D(sx, sy, sz) + } } public extension Transform3D { diff --git a/Sources/Cadova/Values/Transforms/Transform.swift b/Sources/Cadova/Values/Transforms/Transform.swift index c9da7d53..530ca8e8 100644 --- a/Sources/Cadova/Values/Transforms/Transform.swift +++ b/Sources/Cadova/Values/Transforms/Transform.swift @@ -1,5 +1,10 @@ import Foundation +/// An affine transformation matrix for 2D or 3D space. +/// +/// This protocol defines common transformation operations. You typically work with the concrete +/// types ``Transform2D`` and ``Transform3D`` rather than this protocol directly. +/// public protocol Transform: Sendable, Hashable, Codable, Transformable where T == Self, Transformed == Self { associatedtype D: Dimensionality typealias V = D.Vector @@ -26,6 +31,7 @@ public protocol Transform: Sendable, Hashable, Codable, Transformable where T == var transform3D: Transform3D { get } var isIdentity: Bool { get } + var scale: V { get } } public extension Transform { diff --git a/Sources/Cadova/Values/Vectors/Vector.swift b/Sources/Cadova/Values/Vectors/Vector.swift index 429a3c73..df9506f7 100644 --- a/Sources/Cadova/Values/Vectors/Vector.swift +++ b/Sources/Cadova/Values/Vectors/Vector.swift @@ -1,5 +1,10 @@ import Foundation +/// A geometric vector in 2D or 3D space. +/// +/// This protocol defines common vector operations. You typically work with the concrete types +/// ``Vector2D`` and ``Vector3D`` rather than this protocol directly. +/// public protocol Vector: Hashable, Sendable, Codable, CustomDebugStringConvertible, Collection where Element == Double { associatedtype D: Dimensionality where D.Vector == Self @@ -109,6 +114,14 @@ internal extension Vector { } } +/// Linearly interpolates between two vectors. +/// +/// - Parameters: +/// - a: The starting vector. +/// - b: The ending vector. +/// - t: The interpolation factor, where 0 returns `a` and 1 returns `b`. +/// - Returns: The interpolated vector. +/// public func lerp(_ a: V, _ b: V, t: Double) -> V { a + (b - a) * t } diff --git a/Tests/Tests/2D.swift b/Tests/Tests/2D.swift index 6a7b7e25..6996e6d4 100644 --- a/Tests/Tests/2D.swift +++ b/Tests/Tests/2D.swift @@ -2,7 +2,7 @@ import Testing @testable import Cadova struct Geometry2DTests { - @Test func basic2D() async throws { + @Test func `2D boolean operations produce correct geometry`() async throws { try await Union { Rectangle(Vector2D(30, 10)) .aligned(at: .centerY) @@ -19,7 +19,7 @@ struct Geometry2DTests { .expectEquals(goldenFile: "2d/basics") } - @Test func circular() async throws { + @Test func `circular shapes and overhang methods work correctly`() async throws { try await Union { Circle(diameter: 8) .scaled(x: 2) @@ -40,7 +40,7 @@ struct Geometry2DTests { .expectEquals(goldenFile: "2d/circular") } - @Test func roundedRectangle() async throws { + @Test func `rectangle corners can be rounded with edge profiles`() async throws { try await Rectangle(x: 10, y: 10) .cuttingEdgeProfile(.fillet(radius: 5), on: .bottomLeft) .cuttingEdgeProfile(.fillet(radius: 3), on: .bottomRight) diff --git a/Tests/Tests/3D.swift b/Tests/Tests/3D.swift index d9d2a0af..0e3ed03f 100644 --- a/Tests/Tests/3D.swift +++ b/Tests/Tests/3D.swift @@ -3,7 +3,7 @@ import Testing @testable import Cadova struct Geometry3DTests { - @Test func basic3D() async throws { + @Test func `3D boolean operations produce correct geometry`() async throws { try await Box([20, 20, 20]) .aligned(at: .center) .intersecting { @@ -17,14 +17,14 @@ struct Geometry3DTests { .expectEquals(goldenFile: "3d/basics") } - @Test func empty3D() async throws { + @Test func `empty boolean operations have no effect`() async throws { try await Box([10, 20, 30]) .subtracting {} .adding {} .expectEquals(goldenFile: "3d/empty") } - @Test func roundedBoxes() async throws { + @Test func `box corners and edges can be rounded`() async throws { try await Stack(.x, spacing: 1) { Box([10, 8, 5]) .roundingBoxCorners(radius: 2) @@ -39,7 +39,7 @@ struct Geometry3DTests { .expectEquals(goldenFile: "3d/rounded-box") } - @Test func cylinders() async throws { + @Test func `cylinders support various dimension specifications`() async throws { try await Stack(.y, spacing: 1) { Cylinder(bottomRadius: 3, topRadius: 6, height: 10) Cylinder(largerDiameter: 10, apexAngle: 10°, height: 20) diff --git a/Tests/Tests/Anchors.swift b/Tests/Tests/Anchors.swift index 809cf821..54738508 100644 --- a/Tests/Tests/Anchors.swift +++ b/Tests/Tests/Anchors.swift @@ -2,7 +2,7 @@ import Testing @testable import Cadova struct AnchorTests { - @Test func anchor() async throws { + @Test func `geometry can be positioned at an anchor point`() async throws { let boxRightSide = Anchor("right side of box") let geometry = Stack(.z, alignment: .center) { @@ -21,7 +21,7 @@ struct AnchorTests { #expect(try await geometry.bounds ≈ .init(minimum: [-5, -5, 0], maximum: [15, 5, 14])) } - @Test func multipleDefinitions() async throws { + @Test func `anchor can be defined at multiple points on a shape`() async throws { let sphereSurface = Anchor("sphere's surface") let geometry = Box(1) @@ -44,7 +44,7 @@ struct AnchorTests { #expect(try await geometry.bounds ≈ .init(minimum: .zero, maximum: [14, 14, 15])) } - @Test func usedBeforeDefinition() async throws { + @Test func `anchor can be used before it is defined`() async throws { let rightAnchor = Anchor("sphere's right side") let geometry = Box(1) diff --git a/Tests/Tests/BezierCurve.swift b/Tests/Tests/BezierCurve.swift index ca527558..2e393f06 100644 --- a/Tests/Tests/BezierCurve.swift +++ b/Tests/Tests/BezierCurve.swift @@ -2,7 +2,7 @@ import Testing @testable import Cadova struct BezierCurveTests { - @Test func quadraticCurvePoints() { + @Test func `quadratic curve generates correct sample points`() { let curve = BezierPath2D.Curve(controlPoints: [ [2.5, 8], [19.3, 25], [27, 10] ]) @@ -11,7 +11,7 @@ struct BezierCurveTests { #expect(points ≈ [[2.5, 8], [5.769, 11.08], [8.856, 13.52], [11.761, 15.32], [14.484, 16.48], [17.025, 17], [19.384, 16.88], [21.561, 16.12], [23.556, 14.72], [25.369, 12.68], [27, 10]]) } - @Test func cubicCurvePoints() { + @Test func `cubic curve generates correct sample points`() { let curve = BezierPath2D.Curve(controlPoints: [ [12.5, 8], [19.3, 25], [30.2, 12], [27, 10] ]) @@ -20,7 +20,7 @@ struct BezierCurveTests { #expect(points ≈ [[12.5, 8], [14.6448, 12.241], [16.9264, 14.928], [19.2356, 16.307], [21.4632, 16.624], [23.5, 16.125], [25.2368, 15.056], [26.5644, 13.663], [27.3736, 12.192], [27.5552, 10.889], [27, 10]]) } - @Test func quartic3DCurvePoints() { + @Test func `quartic 3D curve generates correct sample points`() { let curve = BezierPath3D.Curve(controlPoints: [ [11, 12.5, 8], [19.3, 56, 25], [30.2, 41.5, 12], [-12, 3.5, 20], [19, 27, 10] ]) diff --git a/Tests/Tests/BezierPatch.swift b/Tests/Tests/BezierPatch.swift index 37de1269..b20585dc 100644 --- a/Tests/Tests/BezierPatch.swift +++ b/Tests/Tests/BezierPatch.swift @@ -2,7 +2,7 @@ import Testing @testable import Cadova struct BezierPatchTests { - @Test func basic() async throws { + @Test func `bezier patch can be enclosed into solid geometry`() async throws { let patch = BezierPatch(controlPoints: [ [ [0, 0, 0], [1, 0, 0.8], [2, 0, -0.2], [3, 0, 0] ], [ [0, 1, 0.5], [1, 1, 1.5], [2, 1, 0.3], [3, 1, -0.4] ], diff --git a/Tests/Tests/BezierPath.swift b/Tests/Tests/BezierPath.swift index 8893246c..ab6d3c0c 100644 --- a/Tests/Tests/BezierPath.swift +++ b/Tests/Tests/BezierPath.swift @@ -14,18 +14,18 @@ struct BezierPathTests { linearPath = BezierPath3D(linesBetween: linearPoints) } - @Test func quadraticPointsFixed() { + @Test func `quadratic path with fixed segmentation produces correct points`() { let points = quadraticPath.points(segmentation: .fixed(5)) #expect(points ≈ [[39.1, 150], [31.456, 133.6], [23.724, 160.4], [15.904, 230.4], [7.996, 343.6], [0, 500], [118.12, 346.336], [216.48, 219.504], [295.08, 119.504], [353.92, 46.336], [393, 0]]) } - @Test func quadraticPointsDynamic() { + @Test func `quadratic path with adaptive segmentation produces correct points`() { let points = quadraticPath.points(segmentation: .adaptive(minAngle: 10°, minSize: 20)) #expect(points ≈ [[39.1, 150], [34.3328, 134.688], [29.5312, 136.25], [24.6953, 154.688], [22.2645, 170.234], [19.825, 190], [18.6021, 201.465], [17.377, 213.984], [16.1497, 227.559], [14.9203, 242.188], [13.6888, 257.871], [12.4551, 274.609], [11.2192, 292.402], [9.98125, 311.25], [8.74111, 331.152], [8.12024, 341.499], [7.49883, 352.109], [6.87688, 362.983], [6.25439, 374.121], [5.63137, 385.522], [5.00781, 397.188], [4.38372, 409.116], [3.75908, 421.309], [3.13391, 433.765], [2.5082, 446.484], [1.88196, 459.468], [1.25518, 472.715], [0.627856, 486.226], [0, 500], [9.9397, 487.029], [19.7588, 474.221], [29.4573, 461.578], [39.0352, 449.098], [48.4924, 436.781], [57.8291, 424.629], [67.0452, 412.64], [76.1406, 400.816], [85.1155, 389.155], [93.9697, 377.657], [102.703, 366.324], [111.316, 355.154], [119.809, 344.148], [128.181, 333.306], [136.432, 322.627], [144.562, 312.112], [152.573, 301.762], [160.462, 291.574], [168.231, 281.551], [175.879, 271.691], [183.406, 261.996], [190.813, 252.463], [198.1, 243.095], [205.266, 233.891], [212.311, 224.85], [219.235, 215.973], [226.039, 207.26], [232.723, 198.71], [239.285, 190.324], [245.728, 182.103], [252.049, 174.044], [258.25, 166.15], [270.29, 150.853], [281.848, 136.21], [292.923, 122.223], [303.516, 108.891], [313.626, 96.2135], [323.254, 84.1914], [332.399, 72.8244], [341.062, 62.1125], [349.243, 52.0557], [356.941, 42.6539], [364.157, 33.9072], [370.891, 25.8156], [382.91, 11.5977], [393, 0]]) } - @Test func circle() async throws { + @Test func `circular arc produces correct control points and area`() async throws { let path = BezierPath2D(startPoint: [10, 0]) .addingArc(center: .zero, to: 360°, clockwise: false) @@ -43,7 +43,7 @@ struct BezierPathTests { #expect(floor(m.area) ≈ 314) } - @Test func arc() async throws { + @Test func `partial arc produces correct control points and bounds`() async throws { let path = BezierPath2D(startPoint: [10, 0]) .addingArc(center: [1, 2], to: 100°, clockwise: false) diff --git a/Tests/Tests/BezierPathBuilder.swift b/Tests/Tests/BezierPathBuilder.swift index 9ddf6a99..c404be3b 100644 --- a/Tests/Tests/BezierPathBuilder.swift +++ b/Tests/Tests/BezierPathBuilder.swift @@ -2,7 +2,7 @@ import Testing @testable import Cadova struct BezierPathBuilderTests { - @Test func testAbsolute() { + @Test func `builder with absolute coordinates matches manual construction`() { let builderPath = BezierPath2D(from: [10, 4]) { line(x: 22, y: 1) line(x: 2) @@ -30,7 +30,7 @@ struct BezierPathBuilderTests { #expect(builderPath ≈ manualPath) } - @Test func relativeArc() async throws { + @Test func `builder supports relative arc commands`() async throws { let builderPath = BezierPath2D(from: [-5, 0], mode: .relative) { line(y: 10) clockwiseArc(centerX: 5, angle: 180°) @@ -45,7 +45,7 @@ struct BezierPathBuilderTests { #expect(builderPath ≈ manualPath) } - @Test func testRelative() { + @Test func `builder with relative coordinates matches manual construction`() { let builderPath = BezierPath2D(from: [10, 4], mode: .relative) { line(x: 22, y: 1) line(x: 2) diff --git a/Tests/Tests/Bounds.swift b/Tests/Tests/Bounds.swift index d5d229fd..4500cdbd 100644 --- a/Tests/Tests/Bounds.swift +++ b/Tests/Tests/Bounds.swift @@ -2,21 +2,21 @@ import Testing @testable import Cadova struct BoundsTests { - @Test func basicAlignment2D() async throws { + @Test func `2D shape alignment affects bounds`() async throws { let geometry = Rectangle([10, 4]) .aligned(at: .centerX, .top) #expect(try await geometry.bounds ≈ .init(minimum: [-5, -4], maximum: [5, 0])) } - @Test func conflictingAlignment() async throws { + @Test func `conflicting alignments are resolved left to right`() async throws { let geometry = Rectangle([50, 20]) .aligned(at: .minX, .centerX, .centerY, .maxX) #expect(try await geometry.bounds ≈ .init(minimum: [-50, -10], maximum: [0, 10])) } - @Test func repeatedAlignment() async throws { + @Test func `repeated alignments accumulate correctly`() async throws { let geometry = Box([10, 8, 12]) .aligned(at: .minX) .aligned(at: .maxY) @@ -27,7 +27,7 @@ struct BoundsTests { #expect(try await geometry.bounds ≈ .init(minimum: [-5, -4, 0], maximum: [5, 4, 12])) } - @Test func transformedBounds() async throws { + @Test func `bounds are correctly calculated after transforms`() async throws { let base = Box([10, 8, 12]) .rotated(x: 90°) .translated(y: 12) @@ -40,7 +40,7 @@ struct BoundsTests { #expect(try await extended.bounds ≈ .init(minimum: .zero, maximum: [28, 12, 10])) } - @Test func stack() async throws { + @Test func `stack computes combined bounds of children`() async throws { let geometry = Stack(.x, spacing: 1, alignment: .min) { Box([10, 8, 12]) RegularPolygon(sideCount: 8, apothem: 3) diff --git a/Tests/Tests/Cache.swift b/Tests/Tests/Cache.swift index 65866cbc..0e99209b 100644 --- a/Tests/Tests/Cache.swift +++ b/Tests/Tests/Cache.swift @@ -7,7 +7,7 @@ struct GeometryCacheTests { let sphere = Sphere(diameter: 1) let box = Box(4) - @Test func basics() async throws { + @Test func `cache stores and reuses evaluation results`() async throws { _ = try await context.concrete(for: sphere) await #expect(context.cache3D.count == 1) @@ -20,7 +20,7 @@ struct GeometryCacheTests { await #expect(context.cache3D.count == 3) } - @Test func differentEnvironments() async throws { + @Test func `different environments produce separate cache entries`() async throws { _ = try await context.concrete(for: sphere) await #expect(context.cache3D.count == 1) @@ -28,7 +28,7 @@ struct GeometryCacheTests { await #expect(context.cache3D.count == 2) } - @Test func warp() async throws { + @Test func `warp operations with same parameters share cache`() async throws { let scale = 2.0 let warp1 = box.warped(operationName: "test", cacheParameters: scale) { Vector3D($0.x + $0.z * scale, $0.y, $0.z) @@ -48,7 +48,7 @@ struct GeometryCacheTests { await #expect(context.cache3D.count == 2) } - @Test func split() async throws { + @Test func `split operation creates expected cache entries`() async throws { await #expect(context.cache3D.count == 0) let split1 = box.split(along: .z(2).rotated(x: 10°)) { g1, g2 in @@ -59,8 +59,7 @@ struct GeometryCacheTests { await #expect(context.cache3D.count == 4) // box, 2x trims, split union } - // CachedConcreteTransformer should preserve elements - @Test func hullPreservesParts() async throws { + @Test func `convex hull preserves part information`() async throws { let model = Sphere(diameter: 10) .convexHull(adding: [0, 0, 20]) .adding { @@ -73,8 +72,7 @@ struct GeometryCacheTests { #expect(partNames == ["box"]) } - // CachedConcreteArrayTransformer should preserve elements - @Test func splitPreservesParts() async throws { + @Test func `split preserves part information`() async throws { let model = Sphere(diameter: 10) .adding { Box(5) diff --git a/Tests/Tests/CircularOverhang.swift b/Tests/Tests/CircularOverhang.swift index 5b7bebcb..cd40ff0b 100644 --- a/Tests/Tests/CircularOverhang.swift +++ b/Tests/Tests/CircularOverhang.swift @@ -2,7 +2,7 @@ import Testing @testable import Cadova struct CircularOverhangTests { - @Test func styles() async throws { + @Test func `circular overhang methods produce correct geometry`() async throws { try await Circle(diameter: 10) .overhangSafe() .extruded(height: 1) diff --git a/Tests/Tests/Deform.swift b/Tests/Tests/Deform.swift index 773d6b82..2ea25ebf 100644 --- a/Tests/Tests/Deform.swift +++ b/Tests/Tests/Deform.swift @@ -3,7 +3,7 @@ import Testing @testable import Cadova struct DeformTests { - @Test func basic2D() async throws { + @Test func `2D shape can be deformed along bezier path`() async throws { let deformation = Rectangle(x: 50, y: 10) .deformed(by: BezierPath2D(from: [5, 0]) { curve(controlX: 30, controlY: 50, endX: 45, endY: 0) @@ -14,7 +14,7 @@ struct DeformTests { #expect(m.boundingBox ≈ .init(minimum: [0, -20.8497], maximum: [50, 34.999])) } - @Test func basic2Din3D() async throws { + @Test func `3D shape can be deformed along 2D bezier path`() async throws { let deformation = Box(x: 100, y: 3, z: 20) .deformed(by: BezierPath2D { curve(controlX: 50, controlY: 50, endX: 100, endY: 0) diff --git a/Tests/Tests/Examples.swift b/Tests/Tests/Examples.swift index a956f791..2982d2f2 100644 --- a/Tests/Tests/Examples.swift +++ b/Tests/Tests/Examples.swift @@ -3,14 +3,14 @@ import Foundation @testable import Cadova struct ExampleTests { - @Test func example1() async throws { + @Test func `rotated box produces correct geometry`() async throws { try await Box([10, 20, 5]) .aligned(at: .centerY) .rotated(y: -20°, z: 45°) .expectEquals(goldenFile: "examples/example1") } - @Test func example2() async throws { + @Test func `twisted extrusion with boolean subtraction produces correct geometry`() async throws { try await Circle(diameter: 10) .withSegmentation(count: 3) .translated(x: 2) @@ -44,7 +44,7 @@ struct ExampleTests { } } - @Test func example3() async throws { + @Test func `stacked star shapes produce correct geometry`() async throws { try await Stack(.x, spacing: 1, alignment: .centerY) { Star(pointCount: 5, radius: 10, pointRadius: 1, centerSize: 4) Star(pointCount: 6, radius: 8, pointRadius: 0, centerSize: 2) @@ -52,7 +52,7 @@ struct ExampleTests { .expectEquals(goldenFile: "examples/example3") } - @Test func example4() async throws { + @Test func `star shape swept along bezier path produces correct geometry`() async throws { let path = BezierPath2D { curve([10, 65], [50, -20], [60, 50]) } diff --git a/Tests/Tests/GeometryExpressionCodable.swift b/Tests/Tests/GeometryExpressionCodable.swift index d9153599..ebf590a5 100644 --- a/Tests/Tests/GeometryExpressionCodable.swift +++ b/Tests/Tests/GeometryExpressionCodable.swift @@ -3,7 +3,7 @@ import Foundation @testable import Cadova struct GeometryNodeCodableTests { - @Test func testCodableRoundTrip2D() throws { + @Test func `2D geometry node encodes and decodes correctly`() throws { let node: GeometryNode = .boolean([ .shape(.rectangle(size: .init(x: 10, y: 5))), .transform( @@ -32,7 +32,7 @@ struct GeometryNodeCodableTests { } @Test - func testCodableRoundTrip3D() throws { + func `3D geometry node encodes and decodes correctly`() throws { let node = GeometryNode.boolean([ .shape(.box(size: .init(x: 5, y: 5, z: 1))), .transform( diff --git a/Tests/Tests/GeometryExpressionSimplification.swift b/Tests/Tests/GeometryExpressionSimplification.swift index a5094a80..687768a5 100644 --- a/Tests/Tests/GeometryExpressionSimplification.swift +++ b/Tests/Tests/GeometryExpressionSimplification.swift @@ -11,12 +11,12 @@ struct GeometryNodeSimplificationTests { let emptyUnion2D = GeometryNode.boolean([], type: .union) let emptyUnion3D = GeometryNode.boolean([], type: .union) - @Test func emptyUnion() { + @Test func `empty union simplifies to empty`() { #expect(emptyUnion2D.isEmpty) #expect(emptyUnion3D.isEmpty) } - @Test func singleUnion() { + @Test func `single child union simplifies to child`() { let singleUnion2D = GeometryNode.boolean([rectangle], type: .union) #expect(singleUnion2D == rectangle) @@ -24,7 +24,7 @@ struct GeometryNodeSimplificationTests { #expect(singleUnion3D == box) } - @Test func emptyBaseDifference() { + @Test func `difference with empty base simplifies correctly`() { let emptyBase2D = GeometryNode.boolean([.empty, rectangle], type: .difference) #expect(emptyBase2D.isEmpty) @@ -38,7 +38,7 @@ struct GeometryNodeSimplificationTests { #expect(nonEmptyBase3D == box) } - @Test func emptyChildrenDifference() { + @Test func `difference with empty children simplifies to base`() { let emptyChildren2D = GeometryNode.boolean([rectangle, .empty, .empty], type: .difference) #expect(emptyChildren2D == rectangle) @@ -46,12 +46,12 @@ struct GeometryNodeSimplificationTests { #expect(emptyChildren3D == box) } - @Test func zeroSizes() { + @Test func `zero-sized shapes simplify to empty`() { #expect(zeroBox.isEmpty) #expect(zeroCircle.isEmpty) } - @Test func zeroSizesInUnion() { + @Test func `union of zero-sized shapes simplifies to empty`() { let zeroUnion2D = GeometryNode.boolean([zeroCircle, .empty], type: .union) #expect(zeroUnion2D.isEmpty) @@ -59,7 +59,7 @@ struct GeometryNodeSimplificationTests { #expect(zeroUnion3D.isEmpty) } - @Test func emptyOperands() { + @Test func `operations on empty operands simplify to empty`() { let emptyOffset = GeometryNode.offset(.empty, amount: 1.0, joinStyle: .miter, miterLimit: 4.0, segmentCount: 8) #expect(emptyOffset.isEmpty) @@ -76,7 +76,7 @@ struct GeometryNodeSimplificationTests { #expect(emptyConvexHull.isEmpty) } - @Test func nestedEmptyChildren() { + @Test func `nested empty children simplify correctly`() { let nestedUnion = GeometryNode.boolean([ .empty, emptyUnion3D, diff --git a/Tests/Tests/Import.swift b/Tests/Tests/Import.swift index 8b31f5bc..43a5225d 100644 --- a/Tests/Tests/Import.swift +++ b/Tests/Tests/Import.swift @@ -3,7 +3,7 @@ import Testing @testable import Cadova struct ImportTests { - @Test func importCubeGears() async throws { + @Test func `3MF file can be imported with part filtering`() async throws { let modelURL = Bundle.module.url(forResource: "cube_gears", withExtension: "3mf", subdirectory: "resources")! try await Import(model: modelURL) @@ -22,4 +22,82 @@ struct ImportTests { } .triggerEvaluation() } + + @Test func `3MF export and import preserves geometry`() async throws { + let geometry: any Geometry3D = Box(x: 10, y: 20, z: 30) + .subtracting { + Cylinder(diameter: 5, height: 100) + } + + let originalMeasurements = try await geometry.measurements + + // Export to 3MF + let tempURL = FileManager.default.temporaryDirectory + .appendingPathComponent("cadova-test-\(UUID().uuidString).3mf") + defer { try? FileManager.default.removeItem(at: tempURL) } + + let context = EvaluationContext() + let result = try await context.buildResult(for: geometry.withDefaultSegmentation(), in: .defaultEnvironment) + let provider = ThreeMFDataProvider(result: result, options: []) + try await provider.writeOutput(to: tempURL, context: context) + + // Import and verify measurements match + let importedMeasurements = try await Import(model: tempURL).measurements + + #expect(importedMeasurements.volume ≈ originalMeasurements.volume) + #expect(importedMeasurements.surfaceArea ≈ originalMeasurements.surfaceArea) + } + + @Test func `STL export and import preserves geometry`() async throws { + let geometry: any Geometry3D = Box(x: 10, y: 20, z: 30) + .subtracting { + Cylinder(diameter: 5, height: 100) + } + + let originalMeasurements = try await geometry.measurements + + // Export to STL + let tempURL = FileManager.default.temporaryDirectory + .appendingPathComponent("cadova-test-\(UUID().uuidString).stl") + defer { try? FileManager.default.removeItem(at: tempURL) } + + let context = EvaluationContext() + let result = try await context.buildResult(for: geometry.withDefaultSegmentation(), in: .defaultEnvironment) + let provider = BinarySTLDataProvider(result: result, options: []) + try await provider.writeOutput(to: tempURL, context: context) + + // Import and verify measurements match + let importedMeasurements = try await Import(model: tempURL).measurements + + #expect(importedMeasurements.volume ≈ originalMeasurements.volume) + #expect(importedMeasurements.surfaceArea ≈ originalMeasurements.surfaceArea) + } + + @Test func `STL import with parts throws appropriate error`() async throws { + let tempURL = FileManager.default.temporaryDirectory + .appendingPathComponent("cadova-test-\(UUID().uuidString)") + .appendingPathExtension("stl") + defer { try? FileManager.default.removeItem(at: tempURL) } + + // Create a simple STL file + let context = EvaluationContext() + let result = try await context.buildResult(for: Box(10).withDefaultSegmentation(), in: .defaultEnvironment) + let provider = BinarySTLDataProvider(result: result, options: []) + try await provider.writeOutput(to: tempURL, context: context) + + // Attempting to import with parts should fail with partsNotSupported error + do { + _ = try await Import(model: tempURL, parts: [.name("test")]).measurements + Issue.record("Expected Import.Error.partsNotSupported to be thrown") + } catch let error as Import.Error { + switch error { + case .partsNotSupported: + break // Expected + default: + Issue.record("Expected partsNotSupported but got: \(error)") + } + } catch { + Issue.record("Unexpected error type: \(type(of: error)) - \(error)") + } + } } diff --git a/Tests/Tests/Line.swift b/Tests/Tests/Line.swift index d660d9f6..42a018c1 100644 --- a/Tests/Tests/Line.swift +++ b/Tests/Tests/Line.swift @@ -3,7 +3,7 @@ import Testing struct LineTests { @Test - func testContainsPointOnLine() { + func `line correctly identifies points on itself`() { let line = Line(point: [0, 0], direction: .positiveX) #expect(line.contains([5, 0])) #expect(line.contains([0, 0])) @@ -11,7 +11,7 @@ struct LineTests { } @Test - func testTranslatedLine() { + func `line can be translated while preserving direction`() { let original = Line(point: [1, 2, 3], direction: .positiveZ) let translated = original.translated(x: 3, y: -2, z: 1) #expect(translated.point ≈ [4, 0, 4]) @@ -19,7 +19,7 @@ struct LineTests { } @Test - func testRotatedLine() { + func `line can be rotated`() { let line = Line(point: [1, 0, 0], direction: .positiveY) let rotated = line.rotated(x: 0°, y: 0°, z: 90°) #expect(rotated.direction ≈ .negativeX) @@ -29,21 +29,21 @@ struct LineTests { } @Test - func testClosestPoint() { + func `line finds closest point to external point`() { let line = Line(point: [0, 0], direction: .positiveX) #expect(line.closestPoint(to: [2, 5]) ≈ [2, 0]) #expect(line.closestPoint(to: [-3, -4]) ≈ [-3, 0]) } @Test - func testDistanceToPoint() { + func `line calculates distance to external point`() { let line = Line(point: [0, 0], direction: .positiveX) #expect(line.distance(to: [0, 3]) ≈ 3) #expect(line.distance(to: [5, -5]) ≈ 5) } @Test - func testIntersection() { + func `lines can find intersection point`() { let a = Line(point: [0, 0], direction: .positiveX) let b = Line(point: [0, 1], direction: .positiveY) #expect(a.intersection(with: b) ≈ [0, 0]) @@ -53,7 +53,7 @@ struct LineTests { } @Test - func test3DLineOperations() { + func `3D line operations work correctly`() { let line = Line(point: [0, 0, 0], direction: .positiveZ) #expect(line.contains([0, 0, 10])) #expect(!line.contains([1, 0, 10])) diff --git a/Tests/Tests/Loft.swift b/Tests/Tests/Loft.swift index 873dc10b..41f4b946 100644 --- a/Tests/Tests/Loft.swift +++ b/Tests/Tests/Loft.swift @@ -3,7 +3,7 @@ import Testing @testable import Cadova struct LoftTests { - @Test func threeLayers() async throws { + @Test func `loft with three layers and holes produces correct geometry`() async throws { let loft = Loft { layer(z: 0) { Circle(diameter: 20) @@ -35,7 +35,7 @@ struct LoftTests { #expect(m.boundingBox ≈ .init(minimum: [-12.5, -12.5, 0], maximum: [12.5, 12.5, 35])) } - @Test func layerSpecificShaping() async throws { + @Test func `layer can specify custom shaping function`() async throws { let loft = Loft { layer(z: 0) { Circle(diameter: 5) @@ -59,7 +59,7 @@ struct LoftTests { #expect(m.boundingBox?.equals(.init(minimum: [-10, -10, 0], maximum: [10, 10, 20]), within: 1e-2) == true) } - @Test func layerSpecificShapingWithDefault() async throws { + @Test func `layer shaping overrides loft default shaping`() async throws { let loft = Loft(interpolation: .smoothstep) { layer(z: 0) { Circle(diameter: 5) @@ -82,5 +82,78 @@ struct LoftTests { #expect(m.surfaceArea ≈ 1118.009) #expect(m.boundingBox?.equals(.init(minimum: [-10, -10, 0], maximum: [10, 10, 20]), within: 1e-2) == true) } + + @Test func `convex hull transition creates hull between layers`() async throws { + let loft = Loft { + layer(z: 0) { + Circle(diameter: 20) + } + layer(z: 10, interpolation: .convexHull) { + Rectangle([10, 10]) + .aligned(at: .center) + } + } + + try await loft.writeVerificationModel(name: "loftConvexHull") + let m = try await loft.measurements + + // The convex hull of a circle at z=0 and a square at z=10 + // should produce a solid that's larger than a simple loft + #expect(m.volume > 0) + #expect(m.surfaceArea > 0) + #expect(m.boundingBox ≈ .init(minimum: [-10, -10, 0], maximum: [10, 10, 10])) + } + + @Test func `mixed interpolation and convex hull transitions work together`() async throws { + let loft = Loft { + layer(z: 0) { + Circle(diameter: 10) + } + layer(z: 10) { + Circle(diameter: 20) + } + layer(z: 20, interpolation: .convexHull) { + Rectangle([8, 8]) + .aligned(at: .center) + } + layer(z: 30) { + Rectangle([15, 15]) + .aligned(at: .center) + } + } + + try await loft.writeVerificationModel(name: "loftMixedTransitions") + let m = try await loft.measurements + + #expect(m.volume > 0) + #expect(m.surfaceArea > 0) + // Bounding box should span from the circle at bottom to rectangle at top + #expect(m.boundingBox ≈ .init(minimum: [-10, -10, 0], maximum: [10, 10, 30])) + } + + @Test func `visualized loft shows layers at correct positions`() async throws { + let loft = Loft { + layer(z: 0) { + Circle(diameter: 20) + } + layer(z: 10) { + Rectangle([15, 15]) + .aligned(at: .center) + } + layer(z: 25) { + Circle(diameter: 10) + } + } + + let visualization = loft.visualized() + try await visualization.writeVerificationModel(name: "loftVisualized") + let m = try await visualization.measurements(for: .allParts) + + // The visualization should span approximately from z=0 to z=25 + #expect(m.boundingBox!.minimum.z ≈ 0) + #expect(m.boundingBox!.maximum.z ≈ 25) + // Should have some volume (the extruded layer slabs) + #expect(m.volume > 0) + } } diff --git a/Tests/Tests/Matrix.swift b/Tests/Tests/Matrix.swift index 052aae28..cfded73d 100644 --- a/Tests/Tests/Matrix.swift +++ b/Tests/Tests/Matrix.swift @@ -5,7 +5,7 @@ import Testing import simd struct MatrixTests { - @Test func equalImplementations3x3() { + @Test func `3x3 basic matrix matches simd implementation`() { let simdMatrix1 = simd_double3x3(rows: [ .init(45.3, 4565, -94.245), .init(12.4, 0, -15), @@ -43,7 +43,7 @@ struct MatrixTests { #expect(basicMatrix1.values ≈ basicInverse.inverse.values) } - @Test func equalImplementations4x4() { + @Test func `4x4 basic matrix matches simd implementation`() { let simdMatrix1 = simd_double4x4(rows: [ .init(45.3, 67.2, 4565, -94.245), .init(12.4, 0, 45.1, -15), diff --git a/Tests/Tests/NaturalUpDirection.swift b/Tests/Tests/NaturalUpDirection.swift index 3b052dff..7bc29b34 100644 --- a/Tests/Tests/NaturalUpDirection.swift +++ b/Tests/Tests/NaturalUpDirection.swift @@ -2,7 +2,7 @@ import Testing @testable import Cadova struct NaturalUpDirectionTests { - @Test func basics() async throws { + @Test func `natural up direction propagates through geometry tree`() async throws { try await Stack(.z, alignment: .center) { Cylinder(diameter: 1, height: 5) Cylinder(bottomDiameter: 2, topDiameter: 0, height: 2) @@ -16,7 +16,7 @@ struct NaturalUpDirectionTests { .expectEquals(goldenFile: "naturalUpDirection") } - @Test func testDefault() async throws { + @Test func `natural up direction defaults to positive Z`() async throws { try await Box(1) .readingEnvironment(\.naturalUpDirection) { body, direction in #expect(direction ≈ .up) @@ -24,7 +24,7 @@ struct NaturalUpDirectionTests { .triggerEvaluation() } - @Test func perpendicularDirection() async throws { + @Test func `perpendicular direction returns nil XY angle`() async throws { try await Box(1) .readingEnvironment(\.naturalUpDirectionXYAngle) { body, angle in #expect(angle == nil) diff --git a/Tests/Tests/Operation.swift b/Tests/Tests/Operation.swift index 8f3a0808..56849209 100644 --- a/Tests/Tests/Operation.swift +++ b/Tests/Tests/Operation.swift @@ -2,7 +2,7 @@ import Testing @testable import Cadova struct OperationTests { - @Test func operations() async throws { + @Test func `operation context tracks addition and subtraction correctly`() async throws { try await Box(1) .readingOperation { #expect($0 == .addition) } .subtracting { diff --git a/Tests/Tests/Parts.swift b/Tests/Tests/Parts.swift index 95718972..2aeec788 100644 --- a/Tests/Tests/Parts.swift +++ b/Tests/Tests/Parts.swift @@ -2,7 +2,7 @@ import Testing @testable import Cadova struct PartTests { - @Test func separatePart() async throws { + @Test func `parts are exported separately from main geometry`() async throws { try await Box(10) .adding { Sphere(diameter: 5) @@ -12,7 +12,7 @@ struct PartTests { .expectEquals(goldenFile: "separatePart") } - @Test func nestedPartsSurvive() async throws { + @Test func `nested parts survive evaluation`() async throws { let g = Box(10) .adding { Sphere(diameter: 10) @@ -28,7 +28,7 @@ struct PartTests { #expect(Set(partNames) == ["inner", "outer"]) } - @Test func partsWithEqualNamesAreMerged() async throws { + @Test func `parts with same name are merged`() async throws { let g = Box(10) .adding { Sphere(diameter: 5) @@ -44,7 +44,7 @@ struct PartTests { #expect(BoundingBox3D(concrete.bounds) ≈ BoundingBox3D(minimum: [-2.5, -2.5, -2.5], maximum: [20, 4, 4])) } - @Test func rootOperationShouldBePositive() async throws { + @Test func `part root operation is always addition`() async throws { try await Box(10) .subtracting { Sphere(diameter: 10) @@ -56,7 +56,7 @@ struct PartTests { .triggerEvaluation() } - @Test func detachment() async throws { + @Test func `parts can be detached and reattached`() async throws { let measurements = try await Box(10) .readingPartNames { #expect($0.isEmpty) } .adding { @@ -83,7 +83,7 @@ struct PartTests { #expect(measurements.surfaceArea ≈ 882.572) } - @Test func partMeasurements() async throws { + @Test func `measurement scopes correctly filter parts`() async throws { try await Sphere(diameter: 10) .adding { Box(10) @@ -106,7 +106,7 @@ struct PartTests { .triggerEvaluation() } - @Test func stackWithParts() async throws { + @Test func `stack correctly handles children with parts`() async throws { let stack = Stack(.x) { Sphere(diameter: 10) .inPart(named: "sphere") @@ -122,7 +122,7 @@ struct PartTests { #expect(solidMeasurements.boundingBox ≈ .init(minimum: [0, -5, -5], maximum: [35, 20, 30])) } - @Test func transformedPartMeasurement() async throws { + @Test func `transformed geometry measures parts correctly`() async throws { let geometry = Box(1) .adding { Box(2) @@ -141,8 +141,7 @@ struct PartTests { #expect(measurementsMain.boundingBox ≈ .init(minimum: [-1, 3, 0], maximum: [0, 4, 1])) } - @Test - func subtractingParts() async throws { + @Test func `parts can be subtracted from main geometry`() async throws { let geometry = Box(10) .adding { Box(4) @@ -156,8 +155,7 @@ struct PartTests { #expect(try await geometry.mainModelMeasurements.volume ≈ (1000.0 - 64.0)) } - @Test - func detachingParts() async throws { + @Test func `detaching parts removes them from parts list`() async throws { let geometry = Box(10) .adding { Box(4) @@ -175,8 +173,7 @@ struct PartTests { #expect(try await geometry.measurements.volume ≈ 1064) } - @Test - func modifyingParts() async throws { + @Test func `modifyingParts transforms all parts`() async throws { let geometry = Stack(.x) { Box(10) Box(4) @@ -192,8 +189,7 @@ struct PartTests { #expect(try await geometry.measurements.volume ≈ 1009) } - @Test - func modifyingSinglePart() async throws { + @Test func `modifyingPart transforms single named part`() async throws { let geometry = Stack(.x) { Box(10) Box(4) @@ -209,8 +205,7 @@ struct PartTests { #expect(try await geometry.measurements.volume ≈ 1016) } - @Test - func removingParts() async throws { + @Test func `removingParts removes all parts`() async throws { let geometry = Stack(.x) { Box(10) Box(4) @@ -224,8 +219,7 @@ struct PartTests { #expect(try await geometry.measurements.volume ≈ 1000) } - @Test - func removingSinglePart() async throws { + @Test func `removingPart removes single named part`() async throws { let geometry = Stack(.x) { Box(10) Box(4) diff --git a/Tests/Tests/Result.swift b/Tests/Tests/Result.swift index 2f2dc433..be9fb64b 100644 --- a/Tests/Tests/Result.swift +++ b/Tests/Tests/Result.swift @@ -2,7 +2,7 @@ import Testing @testable import Cadova struct ResultTests { - @Test func resultElementCombination() async throws { + @Test func `result elements are combined through boolean operations`() async throws { try await Box(1) .withTestValue(1) .adding { @@ -18,7 +18,7 @@ struct ResultTests { .triggerEvaluation() } - @Test func resultElementReplacement() async throws { + @Test func `result elements can be replaced`() async throws { try await Box(1) .withTestValue(1) .adding { Box(2) } diff --git a/Tests/Tests/Split.swift b/Tests/Tests/Split.swift index f055dcb4..1248f99c 100644 --- a/Tests/Tests/Split.swift +++ b/Tests/Tests/Split.swift @@ -2,7 +2,7 @@ import Testing @testable import Cadova struct SplitTests { - @Test func splitAlongPlane() async throws { + @Test func `geometry can be split along angled plane`() async throws { let split = Box(10) .aligned(at: .center) .split(along: .z(0).rotated(x: 20°)) { @@ -13,7 +13,7 @@ struct SplitTests { try await split.expectEquals(goldenFile: "splitAlongPlane") } - @Test func splitMeasurements() async throws { + @Test func `split parts have correct measurements`() async throws { let topMeasurements = try await Box(10) .split(along: .z(2)) { a, _ in a } .measurements @@ -29,7 +29,7 @@ struct SplitTests { #expect(bottomMeasurements.boundingBox?.minimum.z ≈ 0) } - @Test func separated() async throws { + @Test func `separated correctly identifies disjoint components`() async throws { try await Box(1).adding { Box(1).translated(x: 0.5) } .separated { #expect($0.count == 1) } .triggerEvaluation() @@ -39,7 +39,7 @@ struct SplitTests { .triggerEvaluation() } - @Test func separatedExample() async throws { + @Test func `separated components can be stacked`() async throws { let model = Sphere(diameter: 10) .subtracting { Box([12, 12, 1]) diff --git a/Tests/Tests/Stack.swift b/Tests/Tests/Stack.swift index f3007303..7bf2f63b 100644 --- a/Tests/Tests/Stack.swift +++ b/Tests/Tests/Stack.swift @@ -2,7 +2,7 @@ import Testing @testable import Cadova struct StackTests { - @Test func ignoreAlignmentOfStackAxis() async throws { + @Test func `stack ignores alignment on its own axis`() async throws { // The Z part of the alignment should be ignored try await Stack(.z, alignment: .center) { Cylinder(diameter: 1, height: 1) diff --git a/Tests/Tests/StadiumTests.swift b/Tests/Tests/StadiumTests.swift index e2307689..e706dbeb 100644 --- a/Tests/Tests/StadiumTests.swift +++ b/Tests/Tests/StadiumTests.swift @@ -3,7 +3,7 @@ import Testing @testable import Cadova struct StadiumTests { - @Test func horizontal() async throws { + @Test func `horizontal stadium has correct measurements`() async throws { let s = Stadium([40, 12]) let m = try await s.measurements @@ -12,7 +12,7 @@ struct StadiumTests { #expect(m.boundingBox ≈ .init(minimum: [-20, -6], maximum: [20, 6])) } - @Test func vertical() async throws { + @Test func `vertical stadium has correct measurements`() async throws { let s = Stadium([12, 40]) let m = try await s.measurements @@ -21,7 +21,7 @@ struct StadiumTests { #expect(m.boundingBox ≈ .init(minimum: [-6, -20], maximum: [6, 20])) } - @Test func circular() async throws { + @Test func `square stadium becomes circle`() async throws { let s = Stadium([12, 12]) let m = try await s.measurements @@ -30,7 +30,7 @@ struct StadiumTests { #expect(m.boundingBox ≈ .init(minimum: [-6, -6], maximum: [6, 6])) } - @Test func nonIntegerSize() async throws { + @Test func `stadium works with non-integer sizes`() async throws { let s = Stadium([7.3, 2.1]) let m = try await s.measurements diff --git a/Tests/Tests/Sweep.swift b/Tests/Tests/Sweep.swift index d4366b4b..d1d6efed 100644 --- a/Tests/Tests/Sweep.swift +++ b/Tests/Tests/Sweep.swift @@ -3,7 +3,7 @@ import Testing @testable import Cadova struct SweepTests { - @Test func twist() async throws { + @Test func `shape can be swept along 3D bezier path`() async throws { let shape = Rectangle(x: 10, y: 6) .aligned(at: .center) .adding { @@ -33,8 +33,7 @@ struct SweepTests { #expect(m.boundingBox ≈ .init(minimum: [0, -5.595, -3], maximum: [105.831, 100, 155.5])) } - // Star from example4 - @Test func exampleStar() async throws { + @Test func `star shape can be swept along 2D path`() async throws { let path = BezierPath2D { curve([10, 65], [50, -20], [60, 50]) } diff --git a/Tests/Tests/Tags.swift b/Tests/Tests/Tags.swift index d1889535..f84c9933 100644 --- a/Tests/Tests/Tags.swift +++ b/Tests/Tests/Tags.swift @@ -2,7 +2,7 @@ import Testing @testable import Cadova struct TagTests { - @Test func tags() async throws { + @Test func `tagged geometry can be referenced elsewhere in tree`() async throws { let blueBoxInside = Tag("blue box inside") let geometry = Stack(.z, spacing: 3, alignment: .center) { diff --git a/Tests/Tests/Text.swift b/Tests/Tests/Text.swift index 453f2adf..d5536020 100644 --- a/Tests/Tests/Text.swift +++ b/Tests/Tests/Text.swift @@ -3,7 +3,7 @@ import Testing @testable import Cadova struct TextTests { - @Test func testBasics() async throws { + @Test func `text produces geometry with reasonable dimensions`() async throws { let text = Text("Hello tests!") let m = try await text.measurements diff --git a/Tests/Tests/Transform.swift b/Tests/Tests/Transform.swift index 41ba5e20..5d7f361b 100644 --- a/Tests/Tests/Transform.swift +++ b/Tests/Tests/Transform.swift @@ -19,7 +19,7 @@ struct TransformTests { } */ - @Test func transform2DTo3D() { + @Test func `2D transforms convert correctly to 3D`() { let transforms2D: [Transform2D] = [ .translation(x: 10, y: 3), .scaling(x: 3, y: 9), diff --git a/Tests/Tests/Wrap.swift b/Tests/Tests/Wrap.swift index 63c6d4f8..445f367d 100644 --- a/Tests/Tests/Wrap.swift +++ b/Tests/Tests/Wrap.swift @@ -2,7 +2,7 @@ import Testing @testable import Cadova struct WrapTests { - @Test func wrapAroundSphere() async throws { + @Test func `geometry can be wrapped around sphere`() async throws { try await Box([40, 30, 3]) .aligned(at: .centerXY) .adding { diff --git a/Tests/Tests/golden/2d/circular.json b/Tests/Tests/golden/2d/circular.json index b9ab9c04..d15ca330 100644 --- a/Tests/Tests/golden/2d/circular.json +++ b/Tests/Tests/golden/2d/circular.json @@ -14,35 +14,6 @@ }, { "children" : [ - { - "body" : { - "kind" : "shape2D", - "primitive" : { - "circle" : { - "radius" : 4, - "segmentCount" : 167 - } - } - }, - "kind" : "transform", - "transform" : [ - [ - 2, - 0, - 0 - ], - [ - 0, - 1, - 0 - ], - [ - 0, - 0, - 1 - ] - ] - }, { "body" : { "cacheKey" : { @@ -356,8 +327,8 @@ "kind" : "shape2D", "primitive" : { "circle" : { - "radius" : 2.5, - "segmentCount" : 104 + "radius" : 2, + "segmentCount" : 83 } } }, @@ -366,7 +337,7 @@ [ 1, 0, - 22 + 27 ], [ 0, @@ -385,8 +356,8 @@ "kind" : "shape2D", "primitive" : { "circle" : { - "radius" : 2, - "segmentCount" : 83 + "radius" : 2.5, + "segmentCount" : 104 } } }, @@ -395,7 +366,7 @@ [ 1, 0, - 27 + 22 ], [ 0, @@ -441,14 +412,14 @@ "kind" : "transform", "transform" : [ [ - 1, - 0, - 3 + -0.49999999999999994, + 0.8660254037844387, + -5.830127018922194 ], [ - 0, - 1, - -5 + -0.8660254037844387, + -0.49999999999999994, + -0.09807621135331646 ], [ 0, @@ -503,14 +474,14 @@ "kind" : "transform", "transform" : [ [ - -0.49999999999999994, - 0.8660254037844387, - -5.830127018922194 + 1, + 0, + 3 ], [ - -0.8660254037844387, - -0.49999999999999994, - -0.09807621135331646 + 0, + 1, + -5 ], [ 0, @@ -575,14 +546,14 @@ "kind" : "transform", "transform" : [ [ - 1, - 0, - 3 + -0.49999999999999994, + 0.8660254037844387, + -5.830127018922194 ], [ - 0, - 1, - -5 + -0.8660254037844387, + -0.49999999999999994, + -0.09807621135331646 ], [ 0, @@ -637,14 +608,14 @@ "kind" : "transform", "transform" : [ [ - -0.49999999999999994, - 0.8660254037844387, - -5.830127018922194 + 1, + 0, + 3 ], [ - -0.8660254037844387, - -0.49999999999999994, - -0.09807621135331646 + 0, + 1, + -5 ], [ 0, @@ -664,14 +635,14 @@ "kind" : "transform", "transform" : [ [ - 0.40673664307580015, - -0.9135454576426009, - 6.101049646137002 + -0.9271838545667874, + -0.374606593415912, + -13.90775781850181 ], [ - 0.9135454576426009, - 0.40673664307580015, - 13.703181864639014 + 0.374606593415912, + -0.9271838545667874, + 5.61909890123868 ], [ 0, @@ -709,14 +680,14 @@ "kind" : "transform", "transform" : [ [ - 1, - 0, - 3 + -0.49999999999999994, + 0.8660254037844387, + -5.830127018922194 ], [ - 0, - 1, - -5 + -0.8660254037844387, + -0.49999999999999994, + -0.09807621135331646 ], [ 0, @@ -771,14 +742,14 @@ "kind" : "transform", "transform" : [ [ - -0.49999999999999994, - 0.8660254037844387, - -5.830127018922194 + 1, + 0, + 3 ], [ - -0.8660254037844387, - -0.49999999999999994, - -0.09807621135331646 + 0, + 1, + -5 ], [ 0, @@ -843,14 +814,14 @@ "kind" : "transform", "transform" : [ [ - 1, - 0, - 3 + -0.49999999999999994, + 0.8660254037844387, + -5.830127018922194 ], [ - 0, - 1, - -5 + -0.8660254037844387, + -0.49999999999999994, + -0.09807621135331646 ], [ 0, @@ -905,14 +876,14 @@ "kind" : "transform", "transform" : [ [ - -0.49999999999999994, - 0.8660254037844387, - -5.830127018922194 + 1, + 0, + 3 ], [ - -0.8660254037844387, - -0.49999999999999994, - -0.09807621135331646 + 0, + 1, + -5 ], [ 0, @@ -932,14 +903,14 @@ "kind" : "transform", "transform" : [ [ - -0.9271838545667874, - -0.374606593415912, - -13.90775781850181 + -0.9135454576426009, + 0.40673664307580015, + -13.703181864639014 ], [ - 0.374606593415912, - -0.9271838545667874, - 5.61909890123868 + -0.40673664307580015, + -0.9135454576426009, + -6.101049646137002 ], [ 0, @@ -977,14 +948,14 @@ "kind" : "transform", "transform" : [ [ - 1, - 0, - 3 + -0.49999999999999994, + 0.8660254037844387, + -5.830127018922194 ], [ - 0, - 1, - -5 + -0.8660254037844387, + -0.49999999999999994, + -0.09807621135331646 ], [ 0, @@ -1039,14 +1010,14 @@ "kind" : "transform", "transform" : [ [ - -0.49999999999999994, - 0.8660254037844387, - -5.830127018922194 + 1, + 0, + 3 ], [ - -0.8660254037844387, - -0.49999999999999994, - -0.09807621135331646 + 0, + 1, + -5 ], [ 0, @@ -1066,14 +1037,14 @@ "kind" : "transform", "transform" : [ [ - -0.9135454576426009, 0.40673664307580015, - -13.703181864639014 + -0.9135454576426009, + 6.101049646137002 ], [ - -0.40673664307580015, - -0.9135454576426009, - -6.101049646137002 + 0.9135454576426009, + 0.40673664307580015, + 13.703181864639014 ], [ 0, @@ -1104,6 +1075,35 @@ 1 ] ] + }, + { + "body" : { + "kind" : "shape2D", + "primitive" : { + "circle" : { + "radius" : 4, + "segmentCount" : 167 + } + } + }, + "kind" : "transform", + "transform" : [ + [ + 2, + 0, + 0 + ], + [ + 0, + 1, + 0 + ], + [ + 0, + 0, + 1 + ] + ] } ], "kind" : "boolean",