|
| 1 | +import Foundation |
| 2 | + |
| 3 | +/// A container for organizing models into logical groups with optional subdirectories. |
| 4 | +/// |
| 5 | +/// Use `Group` to organize related models within a ``Project``. Groups can specify shared |
| 6 | +/// `ModelOptions` and `EnvironmentValues` that apply to all contained models, and can |
| 7 | +/// optionally define a subdirectory name for file organization. |
| 8 | +/// |
| 9 | +/// Groups can be nested, allowing for hierarchical organization of models. |
| 10 | +/// |
| 11 | +/// > Note: Unlike ``Model``, which can be used standalone, `Group` must be used within |
| 12 | +/// > a ``Project`` or another `Group`. |
| 13 | +/// |
| 14 | +/// In addition to `Model` and nested `Group` entries, the group's result builder also accepts: |
| 15 | +/// - `Metadata(...)`: Attaches metadata that is combined into shared `ModelOptions` |
| 16 | +/// - `Environment { … }` or `Environment(\.keyPath, value)`: Applies environment customizations |
| 17 | +/// at the group scope |
| 18 | +/// |
| 19 | +/// Precedence and merging rules: |
| 20 | +/// - Environment and options are inherited from the parent (Project or outer Group) |
| 21 | +/// - `Environment` directives inside the group's builder are applied on top of inherited values |
| 22 | +/// - `Metadata` specified in the group builder is merged into shared options |
| 23 | +/// - Nested groups and models can further override these settings |
| 24 | +/// |
| 25 | +/// - Parameters: |
| 26 | +/// - name: An optional subdirectory name. If provided, all models in this group will be |
| 27 | +/// saved under this subdirectory. If `nil`, models inherit the parent's directory. |
| 28 | +/// - options: Shared `ModelOptions` applied to all models in the group unless overridden. |
| 29 | +/// - content: A result builder that returns an array of directives including `Model`, |
| 30 | +/// `Group`, `Environment`, and `Metadata` entries. |
| 31 | +/// |
| 32 | +/// ### Examples |
| 33 | +/// ```swift |
| 34 | +/// await Project(root: "output") { |
| 35 | +/// await Group("parts") { |
| 36 | +/// Environment(\.segmentation, .defaults) |
| 37 | +/// |
| 38 | +/// await Model("bracket") { Box(10) } |
| 39 | +/// await Model("spacer") { Cylinder(diameter: 5, height: 2) } |
| 40 | +/// } |
| 41 | +/// |
| 42 | +/// await Group("tools") { |
| 43 | +/// await Model("wrench") { ... } |
| 44 | +/// } |
| 45 | +/// } |
| 46 | +/// ``` |
| 47 | +/// |
| 48 | +/// Groups without names are useful for applying shared options without creating subdirectories: |
| 49 | +/// ```swift |
| 50 | +/// await Project(root: "output") { |
| 51 | +/// await Group { |
| 52 | +/// Environment(\.segmentation, .adaptive(minAngle: 5°, minSize: 0.5)) |
| 53 | +/// Metadata(author: "Acme Corp") |
| 54 | +/// |
| 55 | +/// await Model("part1") { ... } |
| 56 | +/// await Model("part2") { ... } |
| 57 | +/// } |
| 58 | +/// } |
| 59 | +/// ``` |
| 60 | +/// |
| 61 | +public struct Group: Sendable { |
| 62 | + let name: String? |
| 63 | + private let directives: @Sendable () async -> [BuildDirective] |
| 64 | + private let options: ModelOptions |
| 65 | + |
| 66 | + /// Creates a group with an optional subdirectory name. |
| 67 | + /// |
| 68 | + /// - Parameters: |
| 69 | + /// - name: An optional subdirectory name for organizing output files. |
| 70 | + /// - options: Shared `ModelOptions` applied to all models in the group. |
| 71 | + /// - content: A result builder that builds the group's contents. |
| 72 | + public init( |
| 73 | + _ name: String? = nil, |
| 74 | + options: ModelOptions..., |
| 75 | + @GroupContentBuilder content: @Sendable @escaping () async -> [BuildDirective] |
| 76 | + ) async { |
| 77 | + self.name = name |
| 78 | + self.directives = content |
| 79 | + self.options = .init(options) |
| 80 | + } |
| 81 | + |
| 82 | + internal func build( |
| 83 | + environment inheritedEnvironment: EnvironmentValues, |
| 84 | + context: EvaluationContext, |
| 85 | + options inheritedOptions: ModelOptions, |
| 86 | + URL directory: URL? |
| 87 | + ) async -> [URL] { |
| 88 | + let directives = await inheritedEnvironment.whileCurrent { |
| 89 | + await self.directives() |
| 90 | + } |
| 91 | + let combinedOptions = ModelOptions([ |
| 92 | + inheritedOptions, |
| 93 | + options, |
| 94 | + .init(directives.compactMap(\.options)) |
| 95 | + ]) |
| 96 | + |
| 97 | + var environment = inheritedEnvironment |
| 98 | + for builder in directives.compactMap(\.environment) { |
| 99 | + builder(&environment) |
| 100 | + } |
| 101 | + |
| 102 | + // Determine output directory |
| 103 | + let outputDirectory: URL? |
| 104 | + if let name { |
| 105 | + if let parent = directory { |
| 106 | + outputDirectory = parent.appendingPathComponent(name, isDirectory: true) |
| 107 | + } else { |
| 108 | + outputDirectory = URL(expandingFilePath: name) |
| 109 | + } |
| 110 | + try? FileManager().createDirectory(at: outputDirectory!, withIntermediateDirectories: true) |
| 111 | + } else { |
| 112 | + outputDirectory = directory |
| 113 | + } |
| 114 | + |
| 115 | + // Build nested content |
| 116 | + let models = directives.compactMap(\.model) |
| 117 | + let groups = directives.compactMap(\.group) |
| 118 | + |
| 119 | + var urls: [URL] = [] |
| 120 | + |
| 121 | + for model in models { |
| 122 | + if let url = await model.build( |
| 123 | + environment: environment, |
| 124 | + context: context, |
| 125 | + options: combinedOptions, |
| 126 | + URL: outputDirectory |
| 127 | + ) { |
| 128 | + urls.append(url) |
| 129 | + } |
| 130 | + } |
| 131 | + |
| 132 | + for group in groups { |
| 133 | + let groupUrls = await group.build( |
| 134 | + environment: environment, |
| 135 | + context: context, |
| 136 | + options: combinedOptions, |
| 137 | + URL: outputDirectory |
| 138 | + ) |
| 139 | + urls.append(contentsOf: groupUrls) |
| 140 | + } |
| 141 | + |
| 142 | + return urls |
| 143 | + } |
| 144 | +} |
0 commit comments