Skip to content

Commit cfa4070

Browse files
committed
Document and test side-based footprint shape correction
Renames the per-side 2D mirror helper used by `cuttingEdgeProfile(using:)` and `formingEdgeProfile(using:)` from `transformToPlaneLocal` to `footprintShape`, with a doc comment that explains why three of the six sides need the correction (minimum-angle plane rotation flips chirality). Adds a regression test that cuts an asymmetric profile on all six sides.
1 parent 4da8aaf commit cfa4070

5 files changed

Lines changed: 7293 additions & 2 deletions

File tree

Sources/Cadova/Values/Corners, Edges and Sides/DirectionalAxis.swift

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -92,6 +92,33 @@ public extension DirectionalAxis where D == D3 {
9292
static let top = maxZ
9393
}
9494

95+
internal extension DirectionalAxis where D == D3 {
96+
/// Returns a user-drawn 2D shape mirrored as needed so that it aligns with the
97+
/// plane-local frame produced by `Plane(side: self, on:).transform` for this side.
98+
///
99+
/// `Plane.transform` is built from `rotation(from: +Z, to: normal)` — the minimum-angle
100+
/// rotation. For three of the six sides the resulting plane-local basis has its X or Y
101+
/// axis flipped relative to the "looking at the face from outside the body" view that
102+
/// users naturally draw in. This helper compensates with a 2D mirror so that the same
103+
/// user-drawn shape lands in the same world-space position and orientation regardless
104+
/// of which side it's applied to.
105+
///
106+
/// Only relevant to side-based overloads that accept a user-provided 2D shape
107+
/// (e.g. `cuttingEdgeProfile(_:on:offset:using:)`). Auto-sliced paths go through
108+
/// `sliced(along:)`, which already runs through `plane.transform.inverse`, so no
109+
/// correction is needed there.
110+
func footprintShape(_ shape: any Geometry2D) -> any Geometry2D {
111+
switch (axis, axisDirection) {
112+
case (.z, .negative), (.x, .positive):
113+
shape.scaled(x: -1)
114+
case (.y, .positive):
115+
shape.scaled(y: -1)
116+
default:
117+
shape
118+
}
119+
}
120+
}
121+
95122
/// Convenience alias for referring to the sides of a 2D rectangle using directional axes.
96123
public extension Rectangle {
97124
/// A type representing one of the four orthogonal sides of a 2D rectangle.

Sources/Cadova/Values/Edge Profiles/EdgeProfile+Cutting.swift

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -60,7 +60,7 @@ public extension Geometry3D {
6060
) -> any Geometry3D {
6161
measuringBounds { _, selfBounds in
6262
let plane = Plane(side: side, on: selfBounds, offset: offset * side.axisDirection.factor)
63-
cuttingEdgeProfile(edgeProfile, with: shape(), at: plane)
63+
cuttingEdgeProfile(edgeProfile, with: side.footprintShape(shape()), at: plane)
6464
}
6565
}
6666
}

Sources/Cadova/Values/Edge Profiles/EdgeProfile+Forming.swift

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -60,7 +60,7 @@ public extension Geometry3D {
6060
) -> any Geometry3D {
6161
measuringBounds { _, selfBounds in
6262
let plane = Plane(side: side, on: selfBounds, offset: offset * side.axisDirection.factor)
63-
formingEdgeProfile(edgeProfile, with: shape(), at: plane)
63+
formingEdgeProfile(edgeProfile, with: side.footprintShape(shape()), at: plane)
6464
}
6565
}
6666
}

Tests/Tests/3D.swift

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,32 @@ struct Geometry3DTests {
3939
.expectEquals(goldenFile: "3d/rounded-box")
4040
}
4141

42+
@Test func `cuttingEdgeProfile(using:) places asymmetric shape consistently across all six sides`() async throws {
43+
// An asymmetric profile (not symmetric in either axis). If the per-side
44+
// chirality correction is wrong on any side, the cut on that side would
45+
// be mirrored relative to its neighbours, producing a visibly different
46+
// combined result.
47+
let profile: @Sendable () -> any Geometry2D = {
48+
Rectangle([6, 3])
49+
.aligned(at: .center)
50+
.subtracting {
51+
Rectangle([2, 1.5])
52+
.translated(x: 1, y: 0)
53+
}
54+
}
55+
let edge = EdgeProfile.chamfer(depth: 0.5)
56+
let sides: [DirectionalAxis<D3>] = [.top, .bottom, .left, .right, .front, .back]
57+
58+
let geometry = sides.enumerated().mapUnion { index, side in
59+
Box(10)
60+
.aligned(at: .center)
61+
.cuttingEdgeProfile(edge, on: side, using: profile)
62+
.translated(x: Double(index) * 12)
63+
}
64+
65+
try await geometry.expectEquals(goldenFile: "3d/edge-profile-side-orientation")
66+
}
67+
4268
@Test func `cylinders support various dimension specifications`() async throws {
4369
try await Stack(.y, spacing: 1) {
4470
Cylinder(bottomRadius: 3, topRadius: 6, height: 10)

0 commit comments

Comments
 (0)