Skip to content

Commit 0bb77f2

Browse files
authored
Merge pull request #18 from iKenndac-forks/feature/custom-output-options
Add ModelFileGenerator and ModelFile for model data creation
2 parents 5d769bf + f7c77e7 commit 0bb77f2

File tree

3 files changed

+222
-2
lines changed

3 files changed

+222
-2
lines changed

Sources/Cadova/Concrete Layer/Build/Model/Model.swift

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import Foundation
22

3-
/// A model that can be exported to a file.
3+
/// A model that will be exported to a file in the current working directory.
44
///
55
/// Use `Model` to build geometry and write it to disk in formats like 3MF, STL, or SVG.
66
/// The model is created and exported in a single step using an async initializer.
@@ -14,13 +14,15 @@ import Foundation
1414
/// Models can also be grouped within a ``Project`` to share environment settings and metadata
1515
/// across multiple output files.
1616
///
17+
/// For fine-grained control of file output, see ``ModelFileGenerator``.
18+
///
1719
public struct Model: Sendable, ModelBuildable {
1820
let name: String
1921

2022
private let directives: @Sendable () -> [BuildDirective]
2123
private let options: ModelOptions
2224

23-
/// Creates and exports a model based on the provided geometry.
25+
/// Creates and exports a model to the current working directory based on the provided geometry.
2426
///
2527
/// Use this initializer to construct and write a 3D or 2D model to disk. The model is
2628
/// generated from a geometry tree you define using the result builder. Supported output
Lines changed: 173 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,173 @@
1+
import Foundation
2+
3+
/// A model that will be rendered into a standard file format.
4+
///
5+
/// Use `ModelFileGenerator` to build geometry into standard file formats like 3MF, STL, or SVG.
6+
/// The model is rendered into a `ModelFile`, from which data can be accessed in memory or written
7+
/// to disk.
8+
///
9+
/// ```swift
10+
/// let modelFile = try await ModelFileGenerator.build(named: "my-part") {
11+
/// Box(x: 10, y: 10, z: 5)
12+
/// }
13+
///
14+
/// let fileName = modelFile.suggestedFileName
15+
/// let fileData = try await modelFile.data()
16+
/// ```
17+
///
18+
/// For command-line apps, see ``Model`` for a pre-built convenient workflow for outputting to
19+
/// the current working directory.
20+
public struct ModelFileGenerator {
21+
22+
/// Render a one-shot model to a model file.
23+
///
24+
/// For more details, see the documentation for the ``ModelFileGenerator.build()``
25+
/// instance method.
26+
///
27+
/// - Note: If you intend to render geometries more than once, create an instance of
28+
/// ``ModelFileGenerator`` and re-use ``build()`` on that instance instead.
29+
/// Instances maintain a cache for improved performance over multiple builds.
30+
public static func build(
31+
named name: String? = nil,
32+
options: ModelOptions...,
33+
@ModelContentBuilder content: @Sendable @escaping () -> [BuildDirective]
34+
) async throws -> ModelFile {
35+
return try await ModelFileGenerator().build(named: name, options: options, content: content)
36+
}
37+
38+
private let evaluationContext = EvaluationContext()
39+
40+
/// Creates a ``ModelFileGenerator`` instance. Instances maintain a cache, allowing improved
41+
/// performance when performing multiple, subsequent builds.
42+
public init() {}
43+
44+
/// Renders a model to a standard file format based on the provided geometry.
45+
///
46+
/// Use this function to construct a 3D or 2D model to a file which can then be used in memory
47+
/// or written to disk. The model is generated from a geometry tree you define using the result
48+
/// builder. Supported output formats include 3MF, STL, and SVG, and can be customized via `ModelOptions`.
49+
///
50+
/// The model will be rendered into a ``ModelFile`` object which can be used to access the file's
51+
/// contents, get a suggested file name, and write the file to disk.
52+
///
53+
/// In addition to geometry, the model’s result builder also accepts:
54+
/// - `Metadata(...)`: Attaches metadata (e.g. title, author, license) that is merged into the model’s options.
55+
/// - `Environment { … }` or `Environment(\.keyPath, value)`: Applies environment customizations for this model.
56+
///
57+
/// Precedence and merging rules:
58+
/// - `Environment` directives inside the model’s builder form the base.
59+
/// - `Metadata` inside the model’s builder is merged into the model’s options.
60+
///
61+
/// - Parameters:
62+
/// - name: The base name of the model, which will be used to generate a suggested file name.
63+
/// - options: One or more `ModelOptions` used to customize output format, compression, metadata, etc.
64+
/// - content: A result builder that builds the model geometry, and may also include `Environment` and `Metadata`.
65+
///
66+
/// - Returns: Returns the constructed file in the form of a ``ModelFile`` object.
67+
///
68+
/// ### Examples
69+
///
70+
/// ```swift
71+
/// let fileData: Data = try await ModelFileGenerator.build(named: "simple") {
72+
/// Box(x: 10, y: 10, z: 5)
73+
/// }.data()
74+
/// ```
75+
///
76+
/// ```swift
77+
/// let modelGenerator = ModelFileGenerator()
78+
/// let file: ModelFile = try await modelGenerator.build(named: "complex", options: .format3D(.threeMF)) {
79+
/// // Model-local metadata and environment
80+
/// Metadata(title: "Complex", description: "A more complex example of using ModelFileGenerator")
81+
///
82+
/// Environment {
83+
/// $0.segmentation = .adaptive(minAngle: 10°, minSize: 0.5)
84+
/// }
85+
///
86+
/// Box(x: 100, y: 3, z: 20)
87+
/// .deformed(by: BezierPath2D {
88+
/// curve(controlX: 50, controlY: 50, endX: 100, endY: 0)
89+
/// })
90+
/// }
91+
///
92+
/// let url = try await presentSaveDialog(defaultName: file.suggestedFileName)
93+
/// try await file.write(to: url)
94+
/// ```
95+
public func build(
96+
named name: String? = nil,
97+
options: ModelOptions...,
98+
@ModelContentBuilder content: @Sendable @escaping () -> [BuildDirective]
99+
) async throws -> ModelFile {
100+
return try await build(named: name, options: options, content: content)
101+
}
102+
103+
internal func build(
104+
named name: String? = nil,
105+
options: [ModelOptions],
106+
@ModelContentBuilder content: @Sendable @escaping () -> [BuildDirective]
107+
) async throws -> ModelFile {
108+
// This is here because in Swift, a varadic parameter can't be passed along as a varadic
109+
// parameter (i.e., the static method can't call the instance method without this).
110+
let directives = content()
111+
let options = ModelOptions(options).adding(modelName: name, directives: directives)
112+
let environment = EnvironmentValues.defaultEnvironment.adding(directives: directives, modelOptions: options)
113+
114+
let (dataProvider, warnings) = try await directives.build(with: options, in: environment, context: evaluationContext)
115+
return ModelFile(dataProvider: dataProvider, evaluationContext: evaluationContext, modelName: name,
116+
buildWarnings: warnings)
117+
}
118+
}
119+
120+
/// A representation of a model in the form of a standard file format (3MF, STL, SVG, etc).
121+
public struct ModelFile {
122+
private let dataProvider: OutputDataProvider
123+
private let evaluationContext: EvaluationContext
124+
private let modelName: String?
125+
126+
internal init(dataProvider: OutputDataProvider, evaluationContext: EvaluationContext, modelName: String?,
127+
buildWarnings: [BuildWarning]) {
128+
self.dataProvider = dataProvider
129+
self.evaluationContext = evaluationContext
130+
self.modelName = modelName
131+
self.buildWarnings = buildWarnings
132+
}
133+
134+
/// Any warnings generated during the build process.
135+
public let buildWarnings: [BuildWarning]
136+
137+
/// The file's file extension, such as `3mf`, `stl`, etc.
138+
public var fileExtension: String { dataProvider.fileExtension }
139+
140+
/// The file's suggested name, including extension, based on the model name given when built.
141+
/// Illegal file name characters will be removed based on the current platform.
142+
public var suggestedFileName: String {
143+
let invalidCharacters: String
144+
#if os(Windows)
145+
// https://learn.microsoft.com/en-us/windows/win32/fileio/naming-a-file
146+
invalidCharacters = "<>:\"/\\|?*"
147+
#elseif os(Linux)
148+
invalidCharacters = "/"
149+
#else
150+
// Assume an Apple platform.
151+
// ':' is technically allowed, but for legacy reasons is displayed as '/' in the Finder.
152+
invalidCharacters = ":/"
153+
#endif
154+
155+
var disallowedCharacterSet: CharacterSet = CharacterSet(charactersIn: Unicode.Scalar(0)..<Unicode.Scalar(32))
156+
disallowedCharacterSet.insert(charactersIn: invalidCharacters)
157+
158+
var sanitizedFileName: String = (modelName ?? "Model")
159+
sanitizedFileName.unicodeScalars.removeAll(where: { disallowedCharacterSet.contains($0) })
160+
if sanitizedFileName.isEmpty { sanitizedFileName = "Model" }
161+
return "\(sanitizedFileName).\(fileExtension)"
162+
}
163+
164+
/// Generates the file's contents as in-memory data.
165+
public func data() async throws -> Data {
166+
try await dataProvider.generateOutput(context: evaluationContext)
167+
}
168+
169+
/// Writes the file's contents to the given location on disk.
170+
public func write(to fileURL: URL) async throws {
171+
try await dataProvider.writeOutput(to: fileURL, context: evaluationContext)
172+
}
173+
}
Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
import Foundation
2+
import Testing
3+
@testable import Cadova
4+
5+
struct ModelFileGeneratorTests {
6+
7+
@Test func `model generator creates valid files`() async throws {
8+
9+
let generator = ModelFileGenerator()
10+
let defaultNameModelFile = try await generator.build(options: .format3D(.threeMF)) {
11+
Box(x: 10, y: 10, z: 5)
12+
}
13+
14+
#expect(defaultNameModelFile.fileExtension == "3mf")
15+
#expect(defaultNameModelFile.suggestedFileName == "Model.3mf")
16+
17+
let namedModelFile = try await generator.build(named: "My Cool Model", options: .format3D(.threeMF)) {
18+
Box(x: 10, y: 10, z: 5)
19+
}
20+
21+
#expect(namedModelFile.fileExtension == "3mf")
22+
#expect(namedModelFile.suggestedFileName == "My Cool Model.3mf")
23+
24+
let illegallyNamedModelFile = try await generator.build(named: "/////", options: .format3D(.threeMF)) {
25+
Box(x: 10, y: 10, z: 5)
26+
}
27+
28+
#expect(illegallyNamedModelFile.fileExtension == "3mf")
29+
#expect(illegallyNamedModelFile.suggestedFileName == "Model.3mf")
30+
31+
let partialIllegallyNamedModelFile = try await generator.build(named: "//My Cool Model//", options: .format3D(.threeMF)) {
32+
Box(x: 10, y: 10, z: 5)
33+
}
34+
35+
#expect(partialIllegallyNamedModelFile.fileExtension == "3mf")
36+
#expect(partialIllegallyNamedModelFile.suggestedFileName == "My Cool Model.3mf")
37+
38+
let results = [defaultNameModelFile, namedModelFile, illegallyNamedModelFile, partialIllegallyNamedModelFile]
39+
40+
for result in results {
41+
let data = try await result.data()
42+
#expect(!data.isEmpty)
43+
}
44+
}
45+
}

0 commit comments

Comments
 (0)