Skip to content

Commit ebf2b6b

Browse files
committed
Add generic resizing method for scaling geometry ranges
Add resizing(_:in:to:alignment:) that scales a range of geometry along an axis to a new length. Unlike extending (which extrudes at a point), this method scales an existing region and can both stretch and compress. - Add generic resizing method to Geometry protocol (works for 2D and 3D) - Add box(size:at:) factory to Dimensionality protocol for generic masks - Add generic mask property to BoundingBox using the new factory - Add within(_:along:) convenience methods for single-axis clipping - Add tests for bounds and volume preservation
1 parent ddb5297 commit ebf2b6b

File tree

5 files changed

+341
-9
lines changed

5 files changed

+341
-9
lines changed

Sources/Cadova/Abstract Layer/Operations/Bounds/Extend.swift

Lines changed: 75 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,80 @@
11
import Foundation
22

3+
public extension Geometry {
4+
/// Resizes a region of the geometry along an axis by scaling it to a new length.
5+
///
6+
/// This method scales the geometry within a specified range to a target length,
7+
/// while keeping geometry outside the range intact (translated to accommodate the change).
8+
///
9+
/// - Parameters:
10+
/// - axis: The axis along which to resize.
11+
/// - range: The range of the geometry to scale.
12+
/// - newLength: The target length for the range. Must be non-negative.
13+
/// - alignment: Which side stays fixed: `.min` (default), `.max`, or `.mid`.
14+
///
15+
/// ## Examples
16+
/// ```swift
17+
/// // Compress the middle section of a rectangle
18+
/// Rectangle(x: 30, y: 10)
19+
/// .resizing(.x, in: 10...20, to: 5)
20+
///
21+
/// // Compress the middle section of a cylinder
22+
/// Cylinder(diameter: 20, height: 30)
23+
/// .resizing(.z, in: 10...20, to: 5)
24+
///
25+
/// // Stretch a section while keeping the top fixed
26+
/// box.resizing(.z, in: 5...15, to: 20, alignment: .max)
27+
/// ```
28+
///
29+
@GeometryBuilder<D>
30+
func resizing(
31+
_ axis: D.Axis,
32+
in range: ClosedRange<Double>,
33+
to newLength: Double,
34+
alignment: AxisAlignment = .min
35+
) -> D.Geometry {
36+
let originalLength = range.upperBound - range.lowerBound
37+
precondition(newLength >= 0, "New length must be non-negative")
38+
precondition(originalLength > 0, "Range must have positive length")
39+
40+
let delta = newLength - originalLength
41+
let scale = newLength / originalLength
42+
43+
// The point that remains fixed based on alignment
44+
let fixedPoint = range.lowerBound + originalLength * alignment.fraction
45+
46+
// Translation amounts for sections outside the range
47+
let lowerTranslation = -delta * alignment.fraction
48+
let upperTranslation = delta * (1 - alignment.fraction)
49+
50+
let axisVector = D.Direction(axis, .positive).unitVector
51+
52+
measuringBounds { body, bounds in
53+
// Geometry below the range
54+
body.intersecting {
55+
bounds.partialBox(from: nil, to: range.lowerBound, in: axis, margin: 1).mask
56+
}
57+
.translated(axisVector * lowerTranslation)
58+
59+
// Geometry within the range - scale around the fixed point
60+
if scale > 0 {
61+
body.intersecting {
62+
bounds.partialBox(from: range.lowerBound, to: range.upperBound, in: axis, margin: 1).mask
63+
}
64+
.translated(axisVector * -fixedPoint)
65+
.scaled(D.Vector(1).with(axis, as: scale))
66+
.translated(axisVector * fixedPoint)
67+
}
68+
69+
// Geometry above the range
70+
body.intersecting {
71+
bounds.partialBox(from: range.upperBound, to: nil, in: axis, margin: 1).mask
72+
}
73+
.translated(axisVector * upperTranslation)
74+
}
75+
}
76+
}
77+
378
public extension Geometry3D {
479
/// Lengthens the geometry by a given amount at the specified plane.
580
///

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

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,30 @@ import Foundation
33
public typealias WithinRange = RangeExpression<Double> & Sendable
44

55
public extension Geometry2D {
6+
/// Returns a geometry clipped to the specified range along an axis.
7+
///
8+
/// This is a convenience method equivalent to calling `within(x:y:)` with
9+
/// only one axis specified.
10+
///
11+
/// - Parameters:
12+
/// - range: The range to clip to.
13+
/// - axis: The axis along which to clip.
14+
/// - Returns: A geometry clipped to the specified range.
15+
///
16+
/// ## Example
17+
/// ```swift
18+
/// // Keep only the right half of a circle
19+
/// Circle(diameter: 10)
20+
/// .within(0..., along: .x)
21+
/// ```
22+
///
23+
func within(_ range: some WithinRange, along axis: Axis2D) -> any Geometry2D {
24+
switch axis {
25+
case .x: within(x: range)
26+
case .y: within(y: range)
27+
}
28+
}
29+
630
/// Returns a geometry that is clipped within the specified ranges along the x and y axes.
731
///
832
/// This method is useful for trimming a geometry to a specific region of space. Axes that are `nil` are left unbounded. You can use
@@ -31,6 +55,31 @@ public extension Geometry2D {
3155
}
3256

3357
public extension Geometry3D {
58+
/// Returns a geometry clipped to the specified range along an axis.
59+
///
60+
/// This is a convenience method equivalent to calling `within(x:y:z:)` with
61+
/// only one axis specified.
62+
///
63+
/// - Parameters:
64+
/// - range: The range to clip to.
65+
/// - axis: The axis along which to clip.
66+
/// - Returns: A geometry clipped to the specified range.
67+
///
68+
/// ## Example
69+
/// ```swift
70+
/// // Keep only the top half of a sphere
71+
/// Sphere(diameter: 10)
72+
/// .within(0..., along: .z)
73+
/// ```
74+
///
75+
func within(_ range: some WithinRange, along axis: Axis3D) -> any Geometry3D {
76+
switch axis {
77+
case .x: within(x: range)
78+
case .y: within(y: range)
79+
case .z: within(z: range)
80+
}
81+
}
82+
3483
/// Returns a geometry that is clipped within the specified ranges along the x, y, and z axes.
3584
///
3685
/// This method is useful for trimming a 3D geometry to a specific region of space. Axes that are `nil` are left unbounded. You can use

Sources/Cadova/Dimensionality.swift

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,8 @@ public protocol Dimensionality: SendableMetatype {
2424
typealias BuildResult = Cadova.BuildResult<Self>
2525
typealias Measurements = Cadova.Measurements<Self>
2626
typealias BoundingBox = Cadova.BoundingBox<Self>
27+
28+
static func box(size: Vector, at origin: Vector) -> Geometry
2729
}
2830

2931
internal extension Dimensionality {
@@ -44,6 +46,10 @@ public struct D2: Dimensionality {
4446
public typealias Transform = Transform2D
4547
public typealias Axis = Axis2D
4648

49+
public static func box(size: Vector2D, at origin: Vector2D) -> any Geometry2D {
50+
Rectangle(size).translated(origin)
51+
}
52+
4753
private init() {}
4854
}
4955

@@ -60,5 +66,9 @@ public struct D3: Dimensionality {
6066
public typealias Transform = Transform3D
6167
public typealias Axis = Axis3D
6268

69+
public static func box(size: Vector3D, at origin: Vector3D) -> any Geometry3D {
70+
Box(size).translated(origin)
71+
}
72+
6373
private init() {}
6474
}

Sources/Cadova/Values/BoundingBox.swift

Lines changed: 6 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -175,13 +175,17 @@ extension BoundingBox3D {
175175
}
176176
}
177177

178-
fileprivate extension BoundingBox {
178+
internal extension BoundingBox {
179179
func partialBox(from: Double?, to: Double?, in axis: D.Axis, margin: Double) -> BoundingBox {
180180
BoundingBox(
181181
minimum: minimum.with(axis, as: from ?? minimum[axis] - margin),
182182
maximum: maximum.with(axis, as: to ?? maximum[axis] + margin)
183183
)
184184
}
185+
186+
var mask: D.Geometry {
187+
D.box(size: size, at: minimum)
188+
}
185189
}
186190

187191
internal extension BoundingBox2D {
@@ -190,10 +194,6 @@ internal extension BoundingBox2D {
190194
.partialBox(from: x?.min, to: x?.max, in: .x, margin: margin)
191195
.partialBox(from: y?.min, to: y?.max, in: .y, margin: margin)
192196
}
193-
194-
var mask: any Geometry2D {
195-
Rectangle(size).translated(minimum)
196-
}
197197
}
198198

199199
internal extension BoundingBox3D {
@@ -203,8 +203,5 @@ internal extension BoundingBox3D {
203203
.partialBox(from: y?.min, to: y?.max, in: .y, margin: margin)
204204
.partialBox(from: z?.min, to: z?.max, in: .z, margin: margin)
205205
}
206-
207-
var mask: any Geometry3D {
208-
Box(size).translated(minimum)
209-
}
210206
}
207+

0 commit comments

Comments
 (0)