Skip to content

Commit 5a29663

Browse files
committed
Refactor loft layer Z resolution and add offset/range layer support
Move Z resolution logic from Loft.init into Layer.resolved(lastZ:), and extract Layer and layer() functions into Loft.Layer.swift. Adds layer(zOffset:) and layer(z/zOffset: Range) overloads so layers can be placed relative to the previous layer or spanning a Z range. Adds tests covering all resolution variants.
1 parent 83a629d commit 5a29663

4 files changed

Lines changed: 297 additions & 105 deletions

File tree

Sources/Cadova/Abstract Layer/Operations/Loft/Loft+Resampling.swift

Lines changed: 1 addition & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -57,12 +57,7 @@ internal extension Loft {
5757
geometries.append(geometry)
5858
}
5959

60-
// Combine all segments
61-
if geometries.count == 1 {
62-
return geometries[0]
63-
} else {
64-
return Union<D3>(geometries)
65-
}
60+
return Union(geometries)
6661
}
6762

6863
private static func convexHullSegment(lower: ResamplingLayer, upper: ResamplingLayer) -> any Geometry3D {
Lines changed: 213 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,213 @@
1+
public extension Loft {
2+
/// A result builder for composing loft layers.
3+
typealias LayerBuilder = ArrayBuilder<Layer>
4+
5+
/// A single cross-section in a lofted shape.
6+
///
7+
/// Each layer defines a 2D shape at a specific Z height. Layers are created using the
8+
/// `layer(z:interpolation:shape:)` or `layer(zOffset:interpolation:shape:)` functions
9+
/// within a ``Loft`` builder.
10+
///
11+
struct Layer: Sendable {
12+
internal enum ZSpecification: Sendable {
13+
case absolute(Double, upperBound: Double? = nil)
14+
case offset(Double, upperBound: Double? = nil)
15+
}
16+
17+
internal let zSpec: ZSpecification
18+
internal let transition: LayerTransition?
19+
internal let geometry: @Sendable () -> any Geometry2D
20+
21+
internal init(zSpec: ZSpecification, transition: LayerTransition?, geometry: @Sendable @escaping () -> any Geometry2D) {
22+
self.zSpec = zSpec
23+
self.transition = transition
24+
self.geometry = geometry
25+
}
26+
27+
internal init(z: Double, transition: LayerTransition?, geometry: @Sendable @escaping () -> any Geometry2D) {
28+
self.init(zSpec: .absolute(z, upperBound: nil), transition: transition, geometry: geometry)
29+
}
30+
31+
internal var z: Double {
32+
guard case .absolute(let z, _) = zSpec else {
33+
preconditionFailure("Layer Z has not been resolved — use layer(z:) or layer(zOffset:) inside a Loft builder")
34+
}
35+
return z
36+
}
37+
38+
internal func resolved(lastZ: inout Double) -> [Layer] {
39+
switch zSpec {
40+
case .absolute(let lower, let upper):
41+
var result = [Layer(z: lower, transition: transition, geometry: geometry)]
42+
lastZ = lower
43+
if let upper {
44+
result.append(Layer(z: upper, transition: .interpolated(.linear), geometry: geometry))
45+
lastZ = upper
46+
}
47+
return result
48+
case .offset(let lower, let upper):
49+
let baseZ = lastZ
50+
let lowerZ = baseZ + lower
51+
var result = [Layer(z: lowerZ, transition: transition, geometry: geometry)]
52+
lastZ = lowerZ
53+
if let upper {
54+
let upperZ = baseZ + upper
55+
result.append(Layer(z: upperZ, transition: .interpolated(.linear), geometry: geometry))
56+
lastZ = upperZ
57+
}
58+
return result
59+
}
60+
}
61+
}
62+
}
63+
64+
/// Creates a single layer in a lofted shape at the specified Z height.
65+
/// This function is intended to be used inside a `Loft` builder to define each horizontal cross-section.
66+
///
67+
/// - Parameters:
68+
/// - z: The Z height at which to place the 2D shape.
69+
/// - shapingFunction: An optional shaping function that controls how the transition progresses between
70+
/// the previous layer and this one. If `nil`, the `Loft`'s own shaping function is used.
71+
/// - shape: A builder that returns the 2D geometry to use for this layer.
72+
///
73+
public func layer(
74+
z: Double,
75+
interpolation shapingFunction: ShapingFunction? = nil,
76+
@GeometryBuilder2D shape: @Sendable @escaping () -> any Geometry2D
77+
) -> Loft.Layer {
78+
Loft.Layer(z: z, transition: shapingFunction.map { .interpolated($0) }, geometry: shape)
79+
}
80+
81+
/// Creates a single layer in a lofted shape at the specified Z height with a specified transition type.
82+
/// This function is intended to be used inside a `Loft` builder to define each horizontal cross-section.
83+
///
84+
/// - Parameters:
85+
/// - z: The Z height at which to place the 2D shape.
86+
/// - transition: The transition type that controls how this layer connects to the previous one.
87+
/// Use `.interpolated(_:)` for shape interpolation or `.convexHull` for a convex hull connection.
88+
/// - shape: A builder that returns the 2D geometry to use for this layer.
89+
///
90+
public func layer(
91+
z: Double,
92+
interpolation transition: LayerTransition,
93+
@GeometryBuilder2D shape: @Sendable @escaping () -> any Geometry2D
94+
) -> Loft.Layer {
95+
Loft.Layer(z: z, transition: transition, geometry: shape)
96+
}
97+
98+
/// Creates a single layer in a lofted shape at a Z height relative to the previous layer.
99+
///
100+
/// The layer is placed at the Z position of the preceding layer plus the given offset.
101+
/// This is useful when building up a loft incrementally, where each layer's height
102+
/// is defined relative to the one before it rather than as an absolute position.
103+
///
104+
/// - Parameters:
105+
/// - zOffset: The Z distance from the previous layer. Must be positive.
106+
/// - shapingFunction: An optional shaping function for the transition from the previous layer.
107+
/// If `nil`, the `Loft`'s own shaping function is used.
108+
/// - shape: A builder that returns the 2D geometry to use for this layer.
109+
///
110+
public func layer(
111+
zOffset: Double,
112+
interpolation shapingFunction: ShapingFunction? = nil,
113+
@GeometryBuilder2D shape: @Sendable @escaping () -> any Geometry2D
114+
) -> Loft.Layer {
115+
Loft.Layer(zSpec: .offset(zOffset), transition: shapingFunction.map { .interpolated($0) }, geometry: shape)
116+
}
117+
118+
/// Creates a single layer in a lofted shape at a Z height relative to the previous layer,
119+
/// with a specified transition type.
120+
///
121+
/// - Parameters:
122+
/// - zOffset: The Z distance from the previous layer. Must be positive.
123+
/// - transition: The transition type that controls how this layer connects to the previous one.
124+
/// - shape: A builder that returns the 2D geometry to use for this layer.
125+
///
126+
public func layer(
127+
zOffset: Double,
128+
interpolation transition: LayerTransition,
129+
@GeometryBuilder2D shape: @Sendable @escaping () -> any Geometry2D
130+
) -> Loft.Layer {
131+
Loft.Layer(zSpec: .offset(zOffset), transition: transition, geometry: shape)
132+
}
133+
134+
/// Creates two layers spanning an offset range using the same 2D shape.
135+
///
136+
/// This convenience overload generates a pair of `Loft.Layer` entries from a single shape:
137+
/// one at `previous + range.lowerBound` and one at `previous + range.upperBound`, both using
138+
/// the same shape. This is useful when you want a straight shape across the specified interval,
139+
/// defined relative to the previous layer rather than at an absolute Z position.
140+
///
141+
/// - Parameters:
142+
/// - range: The Z offset range relative to the previous layer.
143+
/// - shapingFunction: An optional shaping function that controls how the transition progresses between
144+
/// the previous layer and the lower bound of this range. If `nil`, the `Loft`'s shaping
145+
/// function is used for the first layer.
146+
/// - shape: A builder that returns the 2D geometry to use for both layers.
147+
/// - Returns: Two `Loft.Layer` values, one at the lower bound offset and one at the upper bound offset.
148+
///
149+
public func layer(
150+
zOffset range: Range<Double>,
151+
interpolation shapingFunction: ShapingFunction? = nil,
152+
@GeometryBuilder2D shape: @Sendable @escaping () -> any Geometry2D
153+
) -> Loft.Layer {
154+
Loft.Layer(zSpec: .offset(range.lowerBound, upperBound: range.upperBound), transition: shapingFunction.map { .interpolated($0) }, geometry: shape)
155+
}
156+
157+
/// Creates two layers spanning an offset range using the same 2D shape with a specified transition type.
158+
///
159+
/// - Parameters:
160+
/// - range: The Z offset range relative to the previous layer.
161+
/// - transition: The transition type that controls how this layer connects to the previous one.
162+
/// - shape: A builder that returns the 2D geometry to use for both layers.
163+
///
164+
public func layer(
165+
zOffset range: Range<Double>,
166+
interpolation transition: LayerTransition,
167+
@GeometryBuilder2D shape: @Sendable @escaping () -> any Geometry2D
168+
) -> Loft.Layer {
169+
Loft.Layer(zSpec: .offset(range.lowerBound, upperBound: range.upperBound), transition: transition, geometry: shape)
170+
}
171+
172+
/// Creates two layers spanning a Z range using the same 2D shape.
173+
///
174+
/// This convenience overload generates a pair of `Loft.Layer` entries from a single shape:
175+
/// one at `range.lowerBound` using the provided `shapingFunction` (or the `Loft` default if `nil`),
176+
/// and one at `range.upperBound` using a linear shaping function. This is useful when you want a
177+
/// straight shape across the specified interval.
178+
///
179+
/// - Parameters:
180+
/// - range: The Z range defining the lower and upper bounds where the shape will be placed.
181+
/// - shapingFunction: An optional shaping function that controls how the transition progresses between
182+
/// the previous layer and the lower bound of this range. If `nil`, the `Loft`'s shaping
183+
/// function is used for the first layer.
184+
/// - shape: A builder that returns the 2D geometry to use for both layers.
185+
/// - Returns: Two `Loft.Layer` values, one at the lower bound and one at the upper bound.
186+
///
187+
public func layer(
188+
z range: Range<Double>,
189+
interpolation shapingFunction: ShapingFunction? = nil,
190+
@GeometryBuilder2D shape: @Sendable @escaping () -> any Geometry2D
191+
) -> Loft.Layer {
192+
Loft.Layer(zSpec: .absolute(range.lowerBound, upperBound: range.upperBound), transition: shapingFunction.map { .interpolated($0) }, geometry: shape)
193+
}
194+
195+
/// Creates two layers spanning a Z range using the same 2D shape with a specified transition type.
196+
///
197+
/// This convenience overload generates a pair of `Loft.Layer` entries from a single shape:
198+
/// one at `range.lowerBound` using the provided transition, and one at `range.upperBound` using
199+
/// a linear interpolation. This is useful when you want a straight shape across the specified interval.
200+
///
201+
/// - Parameters:
202+
/// - range: The Z range defining the lower and upper bounds where the shape will be placed.
203+
/// - transition: The transition type that controls how this layer connects to the previous one.
204+
/// Use `.interpolated(_:)` for shape interpolation or `.convexHull` for a convex hull connection.
205+
/// - shape: A builder that returns the 2D geometry to use for this layer.
206+
///
207+
public func layer(
208+
z range: Range<Double>,
209+
interpolation transition: LayerTransition,
210+
@GeometryBuilder2D shape: @Sendable @escaping () -> any Geometry2D
211+
) -> Loft.Layer {
212+
Loft.Layer(zSpec: .absolute(range.lowerBound, upperBound: range.upperBound), transition: transition, geometry: shape)
213+
}

Sources/Cadova/Abstract Layer/Operations/Loft/Loft.swift

Lines changed: 6 additions & 99 deletions
Original file line numberDiff line numberDiff line change
@@ -108,107 +108,14 @@ public struct Loft: Geometry {
108108
///
109109
public init(interpolation: ShapingFunction = .linear, @LayerBuilder layers: () -> [Layer]) {
110110
self.shapingFunction = interpolation
111-
self.layers = layers().sorted(by: { $0.z < $1.z })
111+
var lastZ = 0.0
112+
var resolved: [Layer] = []
113+
for layer in layers() {
114+
resolved.append(contentsOf: layer.resolved(lastZ: &lastZ))
115+
}
116+
self.layers = resolved.sorted(by: { $0.z < $1.z })
112117
precondition(self.layers.count >= 2, "Loft requires at least two layers")
113118
}
114-
115-
/// A result builder for composing loft layers.
116-
public typealias LayerBuilder = ArrayBuilder<Layer>
117-
118-
/// A single cross-section in a lofted shape.
119-
///
120-
/// Each layer defines a 2D shape at a specific Z height. Layers are created using the
121-
/// `layer(z:interpolation:shape:)` function within a ``Loft`` builder.
122-
///
123-
public struct Layer: Sendable {
124-
internal let z: Double
125-
internal let transition: LayerTransition?
126-
internal let geometry: @Sendable () -> any Geometry2D
127-
}
128-
}
129-
130-
/// Creates a single layer in a lofted shape at the specified Z height.
131-
/// This function is intended to be used inside a `Loft` builder to define each horizontal cross-section.
132-
///
133-
/// - Parameters:
134-
/// - z: The Z height at which to place the 2D shape.
135-
/// - shapingFunction: An optional shaping function that controls how the transition progresses between
136-
/// the previous layer and this one. If `nil`, the `Loft`'s own shaping function is used.
137-
/// - shape: A builder that returns the 2D geometry to use for this layer.
138-
///
139-
public func layer(
140-
z: Double,
141-
interpolation shapingFunction: ShapingFunction? = nil,
142-
@GeometryBuilder2D shape: @Sendable @escaping () -> any Geometry2D
143-
) -> Loft.Layer {
144-
Loft.Layer(z: z, transition: shapingFunction.map { .interpolated($0) }, geometry: shape)
145-
}
146-
147-
/// Creates a single layer in a lofted shape at the specified Z height with a specified transition type.
148-
/// This function is intended to be used inside a `Loft` builder to define each horizontal cross-section.
149-
///
150-
/// - Parameters:
151-
/// - z: The Z height at which to place the 2D shape.
152-
/// - transition: The transition type that controls how this layer connects to the previous one.
153-
/// Use `.interpolated(_:)` for shape interpolation or `.convexHull` for a convex hull connection.
154-
/// - shape: A builder that returns the 2D geometry to use for this layer.
155-
///
156-
public func layer(
157-
z: Double,
158-
interpolation transition: LayerTransition,
159-
@GeometryBuilder2D shape: @Sendable @escaping () -> any Geometry2D
160-
) -> Loft.Layer {
161-
Loft.Layer(z: z, transition: transition, geometry: shape)
162-
}
163-
164-
/// Creates two layers spanning a Z range using the same 2D shape.
165-
///
166-
/// This convenience overload generates a pair of `Loft.Layer` entries from a single shape:
167-
/// one at `range.lowerBound` using the provided `shapingFunction` (or the `Loft` default if `nil`),
168-
/// and one at `range.upperBound` using a linear shaping function. This is useful when you want a
169-
/// straight shape across the specified interval.
170-
///
171-
/// - Parameters:
172-
/// - range: The Z range defining the lower and upper bounds where the shape will be placed.
173-
/// - shapingFunction: An optional shaping function that controls how the transition progresses between
174-
/// the previous layer and the lower bound of this range. If `nil`, the `Loft`'s shaping
175-
/// function is used for the first layer.
176-
/// - shape: A builder that returns the 2D geometry to use for both layers.
177-
/// - Returns: Two `Loft.Layer` values, one at the lower bound and one at the upper bound.
178-
///
179-
public func layer(
180-
z range: Range<Double>,
181-
interpolation shapingFunction: ShapingFunction? = nil,
182-
@GeometryBuilder2D shape: @Sendable @escaping () -> any Geometry2D
183-
) -> [Loft.Layer] {
184-
[
185-
Loft.Layer(z: range.lowerBound, transition: shapingFunction.map { .interpolated($0) }, geometry: shape),
186-
Loft.Layer(z: range.upperBound, transition: .interpolated(.linear), geometry: shape)
187-
]
188-
}
189-
190-
/// Creates two layers spanning a Z range using the same 2D shape with a specified transition type.
191-
///
192-
/// This convenience overload generates a pair of `Loft.Layer` entries from a single shape:
193-
/// one at `range.lowerBound` using the provided transition, and one at `range.upperBound` using
194-
/// a linear interpolation. This is useful when you want a straight shape across the specified interval.
195-
///
196-
/// - Parameters:
197-
/// - range: The Z range defining the lower and upper bounds where the shape will be placed.
198-
/// - transition: The transition type that controls how this layer connects to the previous one.
199-
/// Use `.interpolated(_:)` for shape interpolation or `.convexHull` for a convex hull connection.
200-
/// - shape: A builder that returns the 2D geometry to use for both layers.
201-
/// - Returns: Two `Loft.Layer` values, one at the lower bound and one at the upper bound.
202-
///
203-
public func layer(
204-
z range: Range<Double>,
205-
interpolation transition: LayerTransition,
206-
@GeometryBuilder2D shape: @Sendable @escaping () -> any Geometry2D
207-
) -> [Loft.Layer] {
208-
[
209-
Loft.Layer(z: range.lowerBound, transition: transition, geometry: shape),
210-
Loft.Layer(z: range.upperBound, transition: .interpolated(.linear), geometry: shape)
211-
]
212119
}
213120

214121
public extension Geometry2D {

0 commit comments

Comments
 (0)