File tree Expand file tree Collapse file tree
Sources/Cadova/Abstract Layer/Geometry/Parts Expand file tree Collapse file tree Original file line number Diff line number Diff line change @@ -72,6 +72,28 @@ public extension Geometry {
7272 PartModifier ( body: self , predicate: { $0. semantic == type } , modifier: reader)
7373 }
7474
75+ /// Applies a transformation to the main geometry and to each part of the specified semantic.
76+ ///
77+ /// This method runs the `reader` closure on the receiver's body geometry and on each part in the
78+ /// catalog whose semantic matches `type`. Use it to apply the same modification - such as a
79+ /// boolean operation, recoloring, or wrapping - to both the body and matching parts in a single
80+ /// pass, without writing the closure twice.
81+ ///
82+ /// - Parameters:
83+ /// - type: The semantic of parts to modify. Defaults to `.solid`.
84+ /// - reader: A closure that receives 3D geometry (either the body or a part's combined geometry)
85+ /// and returns new geometry to replace it.
86+ /// - Returns: A geometry with the closure applied to the body and to each matching part.
87+ ///
88+ func modifyingBodyAndParts(
89+ ofType type: PartSemantic = . solid,
90+ @GeometryBuilder < D3 > reader: @Sendable @escaping ( _ geometry: any Geometry3D ) -> any Geometry3D
91+ ) -> D3 . Geometry where D == D3 {
92+ reader ( self ) . modifyingParts ( ofType: type) { partGeometry, _ in
93+ reader ( partGeometry)
94+ }
95+ }
96+
7597 /// Removes the specified part from the part catalog.
7698 ///
7799 /// This does not alter the base geometry of the receiver; it only removes the matching entry
Original file line number Diff line number Diff line change @@ -247,6 +247,36 @@ struct PartTests {
247247 #expect( try await geometry. measurements. volume ≈ 1016 )
248248 }
249249
250+ @Test func `modifyingBodyAndParts applies operation to body and matching parts`() async throws {
251+ let boxPart = Part ( " box " )
252+ let visualPart = Part ( " visual " , semantic: . visual)
253+
254+ let geometry = Box ( 10 )
255+ . adding {
256+ Box ( 4 )
257+ . translated ( x: 12 )
258+ . inPart ( boxPart)
259+ Box ( 4 )
260+ . translated ( x: 20 )
261+ . inPart ( visualPart)
262+ }
263+ . modifyingBodyAndParts {
264+ $0. subtracting {
265+ Box ( x: 100 , y: 1 , z: 1 )
266+ . translated ( x: - 10 , y: 1 , z: 1 )
267+ }
268+ }
269+
270+ // Slab carves 10 from Box(10) and 4 from the solid Box(4) at x=12. The .visual part is
271+ // skipped because the default semantic filter is .solid, leaving it at its full volume of 64.
272+ #expect( try await geometry. partNames. sorted ( ) == [ " box " , " visual " ] )
273+ #expect( try await geometry. mainModelMeasurements. volume ≈ 990 ) // 1000 - 10
274+ // body (990) + solid part (64 - 4 = 60), all disjoint; visual excluded by .solidParts scope
275+ #expect( try await geometry. measurements. volume ≈ 1050 )
276+ // body (990) + solid part (60) + untouched visual part (64) = 1114
277+ #expect( try await geometry. measurements ( for: . allParts) . volume ≈ 1114 )
278+ }
279+
250280 @Test func `removingParts removes all parts of semantic`() async throws {
251281 let box1Part = Part ( " box1 " )
252282 let box2Part = Part ( " box2 " )
You can’t perform that action at this time.
0 commit comments