From 53e35934156e60ab4e4b3bc579142650f6563b32 Mon Sep 17 00:00:00 2001 From: Daniel Kennett Date: Sun, 1 Feb 2026 14:54:20 +0100 Subject: [PATCH 1/3] Add ModelFileGenerator and ModelFile for model data creation --- .../Concrete Layer/ModelGenerator.swift | 69 +++++++++++++++++++ Tests/Tests/ModelFileGenerator.swift | 45 ++++++++++++ 2 files changed, 114 insertions(+) create mode 100644 Sources/Cadova/Concrete Layer/ModelGenerator.swift create mode 100644 Tests/Tests/ModelFileGenerator.swift diff --git a/Sources/Cadova/Concrete Layer/ModelGenerator.swift b/Sources/Cadova/Concrete Layer/ModelGenerator.swift new file mode 100644 index 00000000..af10a10d --- /dev/null +++ b/Sources/Cadova/Concrete Layer/ModelGenerator.swift @@ -0,0 +1,69 @@ +import Foundation + +public struct ModelFileGenerator { + private let evaluationContext = EvaluationContext() + + public init() {} + + public func build( + named name: String? = nil, + options: ModelOptions..., + @ModelContentBuilder content: @Sendable @escaping () -> [BuildDirective] + ) async throws -> ModelFile { + let directives = content() + let options = ModelOptions(options).adding(modelName: name, directives: directives) + let environment = EnvironmentValues.defaultEnvironment.adding(directives: directives, modelOptions: options) + + let (dataProvider, warnings) = try await directives.build(with: options, in: environment, context: evaluationContext) + return ModelFile(dataProvider: dataProvider, evaluationContext: evaluationContext, modelName: name, + buildWarnings: warnings) + } +} + +public struct ModelFile { + private let dataProvider: OutputDataProvider + private let evaluationContext: EvaluationContext + private let modelName: String? + + internal init(dataProvider: OutputDataProvider, evaluationContext: EvaluationContext, modelName: String?, + buildWarnings: [BuildWarning]) { + self.dataProvider = dataProvider + self.evaluationContext = evaluationContext + self.modelName = modelName + self.buildWarnings = buildWarnings + } + + public let buildWarnings: [BuildWarning] + + public var fileExtension: String { dataProvider.fileExtension } + + public var suggestedFileName: String { + let invalidCharacters: String + #if os(Windows) + // https://learn.microsoft.com/en-us/windows/win32/fileio/naming-a-file + invalidCharacters = "<>:\"/\\|?*" + #elseif os(Linux) + invalidCharacters = "/" + #else + // Assume an Apple platform. + // ':' is technically allowed, but for legacy reasons is displayed as '/' in the Finder. + invalidCharacters = ":/" + #endif + + var disallowedCharacterSet: CharacterSet = CharacterSet(charactersIn: Unicode.Scalar(0).. Data { + try await dataProvider.generateOutput(context: evaluationContext) + } + + public func write(to fileURL: URL) async throws { + try await dataProvider.writeOutput(to: fileURL, context: evaluationContext) + } +} diff --git a/Tests/Tests/ModelFileGenerator.swift b/Tests/Tests/ModelFileGenerator.swift new file mode 100644 index 00000000..e7e5a624 --- /dev/null +++ b/Tests/Tests/ModelFileGenerator.swift @@ -0,0 +1,45 @@ +import Foundation +import Testing +@testable import Cadova + +struct ModelFileGeneratorTests { + + @Test func `model generator creates valid files`() async throws { + + let generator = ModelFileGenerator() + let defaultNameModelFile = try await generator.build(options: .format3D(.threeMF)) { + Box(x: 10, y: 10, z: 5) + } + + #expect(defaultNameModelFile.fileExtension == "3mf") + #expect(defaultNameModelFile.suggestedFileName == "Model.3mf") + + let namedModelFile = try await generator.build(named: "My Cool Model", options: .format3D(.threeMF)) { + Box(x: 10, y: 10, z: 5) + } + + #expect(namedModelFile.fileExtension == "3mf") + #expect(namedModelFile.suggestedFileName == "My Cool Model.3mf") + + let illegallyNamedModelFile = try await generator.build(named: "/////", options: .format3D(.threeMF)) { + Box(x: 10, y: 10, z: 5) + } + + #expect(illegallyNamedModelFile.fileExtension == "3mf") + #expect(illegallyNamedModelFile.suggestedFileName == "Model.3mf") + + let partialIllegallyNamedModelFile = try await generator.build(named: "//My Cool Model//", options: .format3D(.threeMF)) { + Box(x: 10, y: 10, z: 5) + } + + #expect(partialIllegallyNamedModelFile.fileExtension == "3mf") + #expect(partialIllegallyNamedModelFile.suggestedFileName == "My Cool Model.3mf") + + let results = [defaultNameModelFile, namedModelFile, illegallyNamedModelFile, partialIllegallyNamedModelFile] + + for result in results { + let data = try await result.data() + #expect(!data.isEmpty) + } + } +} From 1877ae442eb5ef2371e295b3a391a576007cc06c Mon Sep 17 00:00:00 2001 From: Daniel Kennett Date: Mon, 2 Feb 2026 09:33:30 +0100 Subject: [PATCH 2/3] Add static build method, documentation to ModelFileGenerator --- .../Concrete Layer/Build/Model/Model.swift | 6 +- .../Concrete Layer/ModelGenerator.swift | 116 +++++++++++++++++- 2 files changed, 114 insertions(+), 8 deletions(-) diff --git a/Sources/Cadova/Concrete Layer/Build/Model/Model.swift b/Sources/Cadova/Concrete Layer/Build/Model/Model.swift index 43291869..15267be9 100644 --- a/Sources/Cadova/Concrete Layer/Build/Model/Model.swift +++ b/Sources/Cadova/Concrete Layer/Build/Model/Model.swift @@ -1,6 +1,6 @@ import Foundation -/// A model that can be exported to a file. +/// A model that will be exported to a file in the current working directory. /// /// 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. @@ -14,13 +14,15 @@ import Foundation /// Models can also be grouped within a ``Project`` to share environment settings and metadata /// across multiple output files. /// +/// For fine-grained control of file output, see ``ModelFileGenerator``. +/// public struct Model: Sendable, ModelBuildable { let name: String private let directives: @Sendable () -> [BuildDirective] private let options: ModelOptions - /// Creates and exports a model based on the provided geometry. + /// Creates and exports a model to the current working directory based on the provided geometry. /// /// Use this initializer to construct and write a 3D or 2D model to disk. The model is /// generated from a geometry tree you define using the result builder. Supported output diff --git a/Sources/Cadova/Concrete Layer/ModelGenerator.swift b/Sources/Cadova/Concrete Layer/ModelGenerator.swift index af10a10d..33d0860d 100644 --- a/Sources/Cadova/Concrete Layer/ModelGenerator.swift +++ b/Sources/Cadova/Concrete Layer/ModelGenerator.swift @@ -1,15 +1,112 @@ import Foundation +/// A model that will be rendered into a standard file format. +/// +/// Use `ModelFileGenerator` to build geometry into standard file formats like 3MF, STL, or SVG. +/// The model is rendered into a `ModelFile`, from which data can be accessed in memory or written +/// to disk. +/// +/// ```swift +/// let modelFile = try await ModelFileGenerator.build(named: "my-part") { +/// Box(x: 10, y: 10, z: 5) +/// } +/// +/// let fileName = modelFile.suggestedFileName +/// let fileData = try await modelFile.data() +/// ``` +/// +/// For command-line apps, see ``Model`` for a pre-built convenient workflow for outputting to +/// the current working directory. public struct ModelFileGenerator { - private let evaluationContext = EvaluationContext() + + /// Render a one-shot model to a model file. + /// + /// For more details, see the documentation for the ``ModelFileGenerator.build()`` + /// instance method. + /// + /// - Note: If you intend to render geometries more than once, create an instance of + /// ``ModelFileGenerator`` and re-use ``build()`` on that instance instead. + /// Instances maintain a cache for improved performance over multiple builds. + public static func build( + named name: String? = nil, + options: ModelOptions..., + @ModelContentBuilder content: @Sendable @escaping () -> [BuildDirective] + ) async throws -> ModelFile { + return try await ModelFileGenerator().build(named: name, options: options, content: content) + } + private let evaluationContext = EvaluationContext() + + /// Creates a ``ModelFileGenerator`` instance. Instances maintain a cache, allowing improved + /// performance when performing multiple, subsequent builds. public init() {} + /// Renders a model to a standard file format based on the provided geometry. + /// + /// Use this function to construct a 3D or 2D model to a file which can then be used in memory + /// or written to disk. The model is generated from a geometry tree you define using the result + /// builder. Supported output formats include 3MF, STL, and SVG, and can be customized via `ModelOptions`. + /// + /// The model will be rendered into a ``ModelFile`` object which can be used to access the file's + /// contents, get a suggested file name, and write the file to disk. + /// + /// In addition to geometry, the model’s result builder also accepts: + /// - `Metadata(...)`: Attaches metadata (e.g. title, author, license) that is merged into the model’s options. + /// - `Environment { … }` or `Environment(\.keyPath, value)`: Applies environment customizations for this model. + /// + /// Precedence and merging rules: + /// - `Environment` directives inside the model’s builder form the base. + /// - `Metadata` inside the model’s builder is merged into the model’s options. + /// + /// - Parameters: + /// - name: The base name of the model, which will be used to generate a suggested file name. + /// - options: One or more `ModelOptions` used to customize output format, compression, metadata, etc. + /// - content: A result builder that builds the model geometry, and may also include `Environment` and `Metadata`. + /// + /// - Returns: Returns the constructed file in the form of a ``ModelFile`` object. + /// + /// ### Examples + /// + /// ```swift + /// let fileData: Data = try await ModelFileGenerator.build(named: "simple") { + /// Box(x: 10, y: 10, z: 5) + /// }.data() + /// ``` + /// + /// ```swift + /// let modelGenerator = ModelFileGenerator() + /// let file: ModelFile = try await modelGenerator.build(named: "complex", options: .format3D(.threeMF)) { + /// // Model-local metadata and environment + /// Metadata(title: "Complex", description: "A more complex example of using ModelFileGenerator") + /// + /// Environment { + /// $0.segmentation = .adaptive(minAngle: 10°, minSize: 0.5) + /// } + /// + /// Box(x: 100, y: 3, z: 20) + /// .deformed(by: BezierPath2D { + /// curve(controlX: 50, controlY: 50, endX: 100, endY: 0) + /// }) + /// } + /// + /// let url = try await presentSaveDialog(defaultName: file.suggestedFileName) + /// try await file.write(to: url) + /// ``` public func build( named name: String? = nil, options: ModelOptions..., @ModelContentBuilder content: @Sendable @escaping () -> [BuildDirective] ) async throws -> ModelFile { + return try await build(named: name, options: options, content: content) + } + + internal func build( + named name: String? = nil, + options: [ModelOptions], + @ModelContentBuilder content: @Sendable @escaping () -> [BuildDirective] + ) async throws -> ModelFile { + // This is here because in Swift, a varadic parameter can't be passed along as a varadic + // parameter (i.e., the static method can't call the instance method without this). let directives = content() let options = ModelOptions(options).adding(modelName: name, directives: directives) let environment = EnvironmentValues.defaultEnvironment.adding(directives: directives, modelOptions: options) @@ -20,6 +117,7 @@ public struct ModelFileGenerator { } } +/// A representation of a model in the form of a standard file format (3MF, STL, SVG, etc). public struct ModelFile { private let dataProvider: OutputDataProvider private let evaluationContext: EvaluationContext @@ -32,11 +130,15 @@ public struct ModelFile { self.modelName = modelName self.buildWarnings = buildWarnings } - + + /// Any warnings generated during the build process. public let buildWarnings: [BuildWarning] - + + /// The file's file extension, such as `3mf`, `stl`, etc. public var fileExtension: String { dataProvider.fileExtension } - + + /// The file's suggested name, including extension, based on the model name given when built. + /// Illegal file name characters will be removed based on the current platform. public var suggestedFileName: String { let invalidCharacters: String #if os(Windows) @@ -58,11 +160,13 @@ public struct ModelFile { if sanitizedFileName.isEmpty { sanitizedFileName = "Model" } return "\(sanitizedFileName).\(fileExtension)" } - + + /// Generates the file's contents as in-memory data. public func data() async throws -> Data { try await dataProvider.generateOutput(context: evaluationContext) } - + + /// Writes the file's contents to the given location on disk. public func write(to fileURL: URL) async throws { try await dataProvider.writeOutput(to: fileURL, context: evaluationContext) } From f7c77e7068e128483727dd074caea0e69fd6ea46 Mon Sep 17 00:00:00 2001 From: Daniel Kennett Date: Mon, 2 Feb 2026 09:33:49 +0100 Subject: [PATCH 3/3] =?UTF-8?q?Rename=20ModelGenerator.swift=20=E2=86=92?= =?UTF-8?q?=20ModelFileGenerator.swift?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../{ModelGenerator.swift => ModelFileGenerator.swift} | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename Sources/Cadova/Concrete Layer/{ModelGenerator.swift => ModelFileGenerator.swift} (100%) diff --git a/Sources/Cadova/Concrete Layer/ModelGenerator.swift b/Sources/Cadova/Concrete Layer/ModelFileGenerator.swift similarity index 100% rename from Sources/Cadova/Concrete Layer/ModelGenerator.swift rename to Sources/Cadova/Concrete Layer/ModelFileGenerator.swift