1use 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
15pub 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
65pub 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#[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
184fn 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 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 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 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 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 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}