Skip to main content

brepkit_operations/
pattern.rs

1//! Pattern operations: linear and circular arrays of solids.
2//!
3//! Creates multiple copies of a solid arranged in a pattern.
4
5use brepkit_math::mat::Mat4;
6use brepkit_math::tolerance::Tolerance;
7use brepkit_math::vec::Vec3;
8use brepkit_topology::Topology;
9use brepkit_topology::compound::{Compound, CompoundId};
10use brepkit_topology::solid::SolidId;
11
12use crate::copy::copy_solid;
13use crate::transform::transform_solid;
14
15/// Create a linear pattern of a solid.
16///
17/// Produces `count` copies of the solid, each offset from the previous
18/// by `spacing` along `direction`. The original solid is included as
19/// the first element.
20///
21/// Returns a compound containing all copies.
22///
23/// # Errors
24///
25/// Returns an error if `count < 1`, `spacing` is non-positive,
26/// the direction is zero-length, or copy/transform fails.
27pub fn linear_pattern(
28    topo: &mut Topology,
29    solid: SolidId,
30    direction: Vec3,
31    spacing: f64,
32    count: usize,
33) -> Result<CompoundId, crate::OperationsError> {
34    let tol = Tolerance::new();
35
36    if count < 1 {
37        return Err(crate::OperationsError::InvalidInput {
38            reason: "pattern count must be at least 1".into(),
39        });
40    }
41    if spacing <= tol.linear {
42        return Err(crate::OperationsError::InvalidInput {
43            reason: format!("pattern spacing must be positive, got {spacing}"),
44        });
45    }
46
47    let dir = direction.normalize()?;
48
49    let mut solids = Vec::with_capacity(count);
50    solids.push(solid);
51
52    for i in 1..count {
53        let copy = copy_solid(topo, solid)?;
54        #[allow(clippy::cast_precision_loss)]
55        let offset = dir * (spacing * i as f64);
56        let matrix = Mat4::translation(offset.x(), offset.y(), offset.z());
57        transform_solid(topo, copy, &matrix)?;
58        solids.push(copy);
59    }
60
61    let compound = Compound::new(solids);
62    Ok(topo.add_compound(compound))
63}
64
65/// Create a circular pattern of a solid.
66///
67/// Produces `count` copies arrayed around an axis, evenly spaced over
68/// a full 360 degrees. The original solid is included as the first element.
69///
70/// Returns a compound containing all copies.
71///
72/// # Errors
73///
74/// Returns an error if `count < 2`, the axis is zero-length, or
75/// copy/transform fails.
76pub fn circular_pattern(
77    topo: &mut Topology,
78    solid: SolidId,
79    axis_direction: Vec3,
80    count: usize,
81) -> Result<CompoundId, crate::OperationsError> {
82    if count < 2 {
83        return Err(crate::OperationsError::InvalidInput {
84            reason: "circular pattern needs at least 2 copies".into(),
85        });
86    }
87
88    let axis = axis_direction.normalize()?;
89
90    let mut solids = Vec::with_capacity(count);
91    solids.push(solid);
92
93    #[allow(clippy::cast_precision_loss)]
94    let angle_step = 2.0 * std::f64::consts::PI / (count as f64);
95
96    for i in 1..count {
97        let copy = copy_solid(topo, solid)?;
98        #[allow(clippy::cast_precision_loss)]
99        let angle = angle_step * (i as f64);
100
101        let matrix = rotation_matrix(axis, angle);
102        transform_solid(topo, copy, &matrix)?;
103        solids.push(copy);
104    }
105
106    let compound = Compound::new(solids);
107    Ok(topo.add_compound(compound))
108}
109
110/// Create a 2D grid pattern of a solid.
111///
112/// Produces `count_x × count_y` copies arranged in a rectangular grid.
113/// Each row is offset by `spacing_x` along `dir_x`, each column by
114/// `spacing_y` along `dir_y`. The original solid occupies position (0, 0).
115///
116/// Returns a compound containing all copies.
117///
118/// # Errors
119///
120/// Returns an error if either count is less than 1, either spacing is
121/// non-positive, either direction is zero-length, or copy/transform fails.
122#[allow(clippy::too_many_arguments)]
123pub fn grid_pattern(
124    topo: &mut Topology,
125    solid: SolidId,
126    dir_x: Vec3,
127    dir_y: Vec3,
128    spacing_x: f64,
129    spacing_y: f64,
130    count_x: usize,
131    count_y: usize,
132) -> Result<CompoundId, crate::OperationsError> {
133    let tol = Tolerance::new();
134
135    if count_x < 1 || count_y < 1 {
136        return Err(crate::OperationsError::InvalidInput {
137            reason: "grid pattern counts must be at least 1".into(),
138        });
139    }
140    if spacing_x <= tol.linear {
141        return Err(crate::OperationsError::InvalidInput {
142            reason: format!("grid spacing_x must be positive, got {spacing_x}"),
143        });
144    }
145    if spacing_y <= tol.linear {
146        return Err(crate::OperationsError::InvalidInput {
147            reason: format!("grid spacing_y must be positive, got {spacing_y}"),
148        });
149    }
150
151    let dx = dir_x.normalize()?;
152    let dy = dir_y.normalize()?;
153
154    if dx.cross(dy).length() < tol.linear {
155        return Err(crate::OperationsError::InvalidInput {
156            reason: "dir_x and dir_y must not be parallel".into(),
157        });
158    }
159
160    let mut solids = Vec::with_capacity(count_x * count_y);
161
162    for iy in 0..count_y {
163        for ix in 0..count_x {
164            if ix == 0 && iy == 0 {
165                solids.push(solid);
166                continue;
167            }
168
169            let copy = copy_solid(topo, solid)?;
170
171            #[allow(clippy::cast_precision_loss)]
172            let offset = dx * (spacing_x * ix as f64) + dy * (spacing_y * iy as f64);
173
174            let matrix = Mat4::translation(offset.x(), offset.y(), offset.z());
175            transform_solid(topo, copy, &matrix)?;
176            solids.push(copy);
177        }
178    }
179
180    let compound = Compound::new(solids);
181    Ok(topo.add_compound(compound))
182}
183
184/// Build a rotation matrix for a given axis and angle (Rodrigues' formula).
185fn rotation_matrix(axis: Vec3, angle: f64) -> Mat4 {
186    let cos_a = angle.cos();
187    let sin_a = angle.sin();
188    let omc = 1.0 - cos_a;
189    let ax = axis.x();
190    let ay = axis.y();
191    let az = axis.z();
192
193    Mat4([
194        [
195            omc.mul_add(ax * ax, cos_a),
196            ax.mul_add(ay * omc, -(sin_a * az)),
197            ax.mul_add(az * omc, sin_a * ay),
198            0.0,
199        ],
200        [
201            ax.mul_add(ay * omc, sin_a * az),
202            omc.mul_add(ay * ay, cos_a),
203            ay.mul_add(az * omc, -(sin_a * ax)),
204            0.0,
205        ],
206        [
207            ax.mul_add(az * omc, -(sin_a * ay)),
208            ay.mul_add(az * omc, sin_a * ax),
209            omc.mul_add(az * az, cos_a),
210            0.0,
211        ],
212        [0.0, 0.0, 0.0, 1.0],
213    ])
214}
215
216#[cfg(test)]
217mod tests {
218    #![allow(clippy::unwrap_used)]
219
220    use brepkit_math::tolerance::Tolerance;
221    use brepkit_math::vec::Vec3;
222    use brepkit_topology::Topology;
223
224    use super::*;
225
226    #[test]
227    fn linear_pattern_3_boxes() {
228        let mut topo = Topology::new();
229        let solid = crate::primitives::make_box(&mut topo, 1.0, 1.0, 1.0).unwrap();
230
231        let compound = linear_pattern(&mut topo, solid, Vec3::new(1.0, 0.0, 0.0), 2.0, 3).unwrap();
232
233        let comp = topo.compound(compound).unwrap();
234        assert_eq!(comp.solids().len(), 3, "should have 3 copies");
235
236        let tol = Tolerance::loose();
237        for &sid in comp.solids() {
238            let vol = crate::measure::solid_volume(&topo, sid, 0.1).unwrap();
239            assert!(
240                tol.approx_eq(vol, 1.0),
241                "each copy should have volume ~1.0, got {vol}"
242            );
243        }
244    }
245
246    #[test]
247    fn linear_pattern_spacing_shifts_bboxes() {
248        let mut topo = Topology::new();
249        let solid = crate::primitives::make_box(&mut topo, 1.0, 1.0, 1.0).unwrap();
250
251        let compound = linear_pattern(&mut topo, solid, Vec3::new(1.0, 0.0, 0.0), 3.0, 3).unwrap();
252
253        let comp = topo.compound(compound).unwrap();
254        let tol = Tolerance::loose();
255
256        // First copy at x=0, second at x=3, third at x=6.
257        let bbox0 = crate::measure::solid_bounding_box(&topo, comp.solids()[0]).unwrap();
258        let bbox1 = crate::measure::solid_bounding_box(&topo, comp.solids()[1]).unwrap();
259        let bbox2 = crate::measure::solid_bounding_box(&topo, comp.solids()[2]).unwrap();
260
261        // Box goes from [0,1], copies shifted by 3 and 6 along x.
262        assert!(
263            tol.approx_eq(bbox0.min.x(), 0.0),
264            "first copy min_x should be ~0.0, got {}",
265            bbox0.min.x()
266        );
267        assert!(
268            tol.approx_eq(bbox1.min.x(), 3.0),
269            "second copy min_x should be ~3.0, got {}",
270            bbox1.min.x()
271        );
272        assert!(
273            tol.approx_eq(bbox2.min.x(), 6.0),
274            "third copy min_x should be ~6.0, got {}",
275            bbox2.min.x()
276        );
277    }
278
279    #[test]
280    fn linear_pattern_single_returns_original() {
281        let mut topo = Topology::new();
282        let solid = crate::primitives::make_box(&mut topo, 1.0, 1.0, 1.0).unwrap();
283
284        let compound = linear_pattern(&mut topo, solid, Vec3::new(1.0, 0.0, 0.0), 1.0, 1).unwrap();
285
286        let comp = topo.compound(compound).unwrap();
287        assert_eq!(comp.solids().len(), 1);
288        assert_eq!(comp.solids()[0].index(), solid.index());
289    }
290
291    #[test]
292    fn linear_pattern_zero_spacing_error() {
293        let mut topo = Topology::new();
294        let solid = crate::primitives::make_box(&mut topo, 1.0, 1.0, 1.0).unwrap();
295        assert!(linear_pattern(&mut topo, solid, Vec3::new(1.0, 0.0, 0.0), 0.0, 3).is_err());
296    }
297
298    #[test]
299    fn circular_pattern_4_around_z() {
300        let mut topo = Topology::new();
301        let solid = crate::primitives::make_box(&mut topo, 1.0, 1.0, 1.0).unwrap();
302
303        // Move box to x=3 so rotations are visible.
304        let matrix = Mat4::translation(3.0, 0.0, 0.0);
305        transform_solid(&mut topo, solid, &matrix).unwrap();
306
307        let compound = circular_pattern(&mut topo, solid, Vec3::new(0.0, 0.0, 1.0), 4).unwrap();
308
309        let comp = topo.compound(compound).unwrap();
310        assert_eq!(comp.solids().len(), 4, "should have 4 copies");
311
312        let tol = Tolerance::loose();
313        for &sid in comp.solids() {
314            let vol = crate::measure::solid_volume(&topo, sid, 0.1).unwrap();
315            assert!(
316                tol.approx_eq(vol, 1.0),
317                "each copy should have volume ~1.0, got {vol}"
318            );
319        }
320    }
321
322    #[test]
323    fn circular_pattern_single_error() {
324        let mut topo = Topology::new();
325        let solid = crate::primitives::make_box(&mut topo, 1.0, 1.0, 1.0).unwrap();
326        assert!(circular_pattern(&mut topo, solid, Vec3::new(0.0, 0.0, 1.0), 1).is_err());
327    }
328
329    #[test]
330    fn grid_pattern_3x2() {
331        let mut topo = Topology::new();
332        let solid = crate::primitives::make_box(&mut topo, 1.0, 1.0, 1.0).unwrap();
333
334        let compound = grid_pattern(
335            &mut topo,
336            solid,
337            Vec3::new(1.0, 0.0, 0.0),
338            Vec3::new(0.0, 1.0, 0.0),
339            2.0,
340            3.0,
341            3,
342            2,
343        )
344        .unwrap();
345
346        let comp = topo.compound(compound).unwrap();
347        assert_eq!(comp.solids().len(), 6, "3×2 grid should have 6 copies");
348
349        let tol = Tolerance::loose();
350        for &sid in comp.solids() {
351            let vol = crate::measure::solid_volume(&topo, sid, 0.1).unwrap();
352            assert!(
353                tol.approx_eq(vol, 1.0),
354                "each copy should have volume ~1.0, got {vol}"
355            );
356        }
357    }
358
359    #[test]
360    fn grid_pattern_positions() {
361        let mut topo = Topology::new();
362        let solid = crate::primitives::make_box(&mut topo, 1.0, 1.0, 1.0).unwrap();
363
364        let compound = grid_pattern(
365            &mut topo,
366            solid,
367            Vec3::new(1.0, 0.0, 0.0),
368            Vec3::new(0.0, 1.0, 0.0),
369            5.0,
370            5.0,
371            2,
372            2,
373        )
374        .unwrap();
375
376        let comp = topo.compound(compound).unwrap();
377        let tol = Tolerance::loose();
378
379        // (0,0), (5,0), (0,5), (5,5)
380        let bbox00 = crate::measure::solid_bounding_box(&topo, comp.solids()[0]).unwrap();
381        let bbox10 = crate::measure::solid_bounding_box(&topo, comp.solids()[1]).unwrap();
382        let bbox01 = crate::measure::solid_bounding_box(&topo, comp.solids()[2]).unwrap();
383        let bbox11 = crate::measure::solid_bounding_box(&topo, comp.solids()[3]).unwrap();
384
385        assert!(tol.approx_eq(bbox00.min.x(), 0.0));
386        assert!(tol.approx_eq(bbox00.min.y(), 0.0));
387        assert!(tol.approx_eq(bbox10.min.x(), 5.0));
388        assert!(tol.approx_eq(bbox10.min.y(), 0.0));
389        assert!(tol.approx_eq(bbox01.min.x(), 0.0));
390        assert!(tol.approx_eq(bbox01.min.y(), 5.0));
391        assert!(tol.approx_eq(bbox11.min.x(), 5.0));
392        assert!(tol.approx_eq(bbox11.min.y(), 5.0));
393    }
394
395    #[test]
396    fn grid_pattern_1x1_returns_original() {
397        let mut topo = Topology::new();
398        let solid = crate::primitives::make_box(&mut topo, 1.0, 1.0, 1.0).unwrap();
399
400        let compound = grid_pattern(
401            &mut topo,
402            solid,
403            Vec3::new(1.0, 0.0, 0.0),
404            Vec3::new(0.0, 1.0, 0.0),
405            1.0,
406            1.0,
407            1,
408            1,
409        )
410        .unwrap();
411
412        let comp = topo.compound(compound).unwrap();
413        assert_eq!(comp.solids().len(), 1);
414        assert_eq!(comp.solids()[0].index(), solid.index());
415    }
416
417    #[test]
418    fn grid_pattern_zero_count_error() {
419        let mut topo = Topology::new();
420        let solid = crate::primitives::make_box(&mut topo, 1.0, 1.0, 1.0).unwrap();
421        assert!(
422            grid_pattern(
423                &mut topo,
424                solid,
425                Vec3::new(1.0, 0.0, 0.0),
426                Vec3::new(0.0, 1.0, 0.0),
427                1.0,
428                1.0,
429                0,
430                3
431            )
432            .is_err()
433        );
434    }
435
436    #[test]
437    fn grid_pattern_zero_spacing_error() {
438        let mut topo = Topology::new();
439        let solid = crate::primitives::make_box(&mut topo, 1.0, 1.0, 1.0).unwrap();
440        assert!(
441            grid_pattern(
442                &mut topo,
443                solid,
444                Vec3::new(1.0, 0.0, 0.0),
445                Vec3::new(0.0, 1.0, 0.0),
446                0.0,
447                1.0,
448                3,
449                3
450            )
451            .is_err()
452        );
453    }
454
455    #[test]
456    fn grid_pattern_parallel_directions_error() {
457        let mut topo = Topology::new();
458        let solid = crate::primitives::make_box(&mut topo, 1.0, 1.0, 1.0).unwrap();
459        // Both directions along X — should fail.
460        assert!(
461            grid_pattern(
462                &mut topo,
463                solid,
464                Vec3::new(1.0, 0.0, 0.0),
465                Vec3::new(2.0, 0.0, 0.0),
466                1.0,
467                1.0,
468                3,
469                3
470            )
471            .is_err()
472        );
473    }
474}