Skip to content

Commit ddb5297

Browse files
committed
Add Group type for organizing models within a Project
Group allows hierarchical organization of models with optional subdirectory names and shared options/environment. Groups can nest within other groups and support the same Environment and Metadata directives as Project and Model.
1 parent 4bd4ab7 commit ddb5297

File tree

5 files changed

+210
-4
lines changed

5 files changed

+210
-4
lines changed

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

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ public struct BuildDirective: Sendable {
77
case geometry2D (any Geometry2D)
88
case geometry3D (any Geometry3D)
99
case model (Model)
10+
case group (Group)
1011
case options (ModelOptions)
1112
case environment (@Sendable (inout EnvironmentValues) -> ())
1213
}
@@ -23,6 +24,10 @@ public struct BuildDirective: Sendable {
2324
if case .model(let model) = payload { model } else { nil }
2425
}
2526

27+
var group: Group? {
28+
if case .group(let group) = payload { group } else { nil }
29+
}
30+
2631
var options: ModelOptions? {
2732
if case .options(let options) = payload { options } else { nil }
2833
}
Lines changed: 144 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,144 @@
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+
}
Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
1+
import Foundation
2+
3+
@resultBuilder public struct GroupContentBuilder {
4+
public static func buildExpression(_ model: Model) -> [BuildDirective] {
5+
[BuildDirective(payload: .model(model))]
6+
}
7+
8+
public static func buildExpression(_ group: Group) -> [BuildDirective] {
9+
[BuildDirective(payload: .group(group))]
10+
}
11+
12+
public static func buildExpression(_ metadata: Metadata) -> [BuildDirective] {
13+
[BuildDirective(payload: .options(ModelOptions(metadata)))]
14+
}
15+
16+
public static func buildExpression(_ environment: Environment<@Sendable (inout EnvironmentValues) -> ()>) -> [BuildDirective] {
17+
[BuildDirective(payload: .environment(environment.getter(.init())))]
18+
}
19+
20+
public static func buildExpression(_ void: Void) -> [BuildDirective] { [] }
21+
public static func buildExpression(_ never: Never) -> [BuildDirective] {}
22+
23+
public static func buildBlock(_ children: [BuildDirective]...) -> [BuildDirective] {
24+
children.flatMap { $0 }
25+
}
26+
27+
public static func buildOptional(_ children: [BuildDirective]?) -> [BuildDirective] {
28+
children ?? []
29+
}
30+
31+
public static func buildEither(first child: [BuildDirective]) -> [BuildDirective] {
32+
child
33+
}
34+
35+
public static func buildEither(second child: [BuildDirective]) -> [BuildDirective] {
36+
child
37+
}
38+
39+
public static func buildArray(_ children: [[BuildDirective]]) -> [BuildDirective] {
40+
children.flatMap { $0 }
41+
}
42+
}

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

Lines changed: 15 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -83,14 +83,25 @@ public func Project(
8383
builder(&environment)
8484
}
8585

86-
// Build models
87-
guard models.isEmpty == false else { return }
86+
// Build models and groups
87+
let groups = directives.compactMap(\.group)
88+
guard models.isEmpty == false || groups.isEmpty == false else { return }
8889
let context = EvaluationContext()
8990

9091
let constantEnvironment = environment
91-
let urls = await models.asyncCompactMap { model in
92-
await model.build(environment: constantEnvironment, context: context, options: combinedOptions, URL: url)
92+
var urls: [URL] = []
93+
94+
for model in models {
95+
if let modelUrl = await model.build(environment: constantEnvironment, context: context, options: combinedOptions, URL: url) {
96+
urls.append(modelUrl)
97+
}
9398
}
99+
100+
for group in groups {
101+
let groupUrls = await group.build(environment: constantEnvironment, context: context, options: combinedOptions, URL: url)
102+
urls.append(contentsOf: groupUrls)
103+
}
104+
94105
try? Platform.revealFiles(urls)
95106
}
96107

Sources/Cadova/Concrete Layer/Build/Project/ProjectContentBuilder.swift

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,10 @@ import Foundation
55
[BuildDirective(payload: .model(model))]
66
}
77

8+
public static func buildExpression(_ group: Group) -> [BuildDirective] {
9+
[BuildDirective(payload: .group(group))]
10+
}
11+
812
public static func buildExpression(_ metadata: Metadata) -> [BuildDirective] {
913
[BuildDirective(payload: .options(ModelOptions(metadata)))]
1014
}

0 commit comments

Comments
 (0)