1use axiolid_brep::{EdgeName, ExactBRep, FaceName, SweptFace};
30use axiolid_contracts::{GeomError, GeomResult, Operation};
31use axiolid_core::{Point2, Scalar, Tolerance, Vec2, Vec3};
32use axiolid_profile::{Profile, RectangleProfile};
33
34use crate::extrude_exact::{
35 extrude_polygon_rings, extrude_with_cylindrical_blend, extrude_with_cylindrical_blends,
36};
37use crate::BACKEND_ID;
38
39fn unsupported(input: &'static str) -> GeomError {
40 GeomError::UnsupportedInput {
41 backend: BACKEND_ID,
42 operation: Operation::Sweep,
43 input,
44 }
45}
46
47#[derive(Debug, Clone, PartialEq)]
52pub enum EdgeSelector {
53 NearestCorner(Point2),
55 Named(EdgeName),
62}
63
64impl EdgeSelector {
65 fn corner_index(&self, corners: &[Point2]) -> GeomResult<usize> {
70 match self {
71 Self::NearestCorner(target) => corners
72 .iter()
73 .enumerate()
74 .min_by(|(_, a), (_, b)| {
75 (**a - *target)
76 .length_squared()
77 .total_cmp(&(**b - *target).length_squared())
78 })
79 .map(|(index, _)| index)
80 .ok_or_else(|| GeomError::Degenerate("profile has no corners".to_owned())),
81 Self::Named(name) => {
86 let count = corners.len();
87 (0..count)
88 .find(|index| {
89 let previous = (index + count - 1) % count;
90 let (Ok(ordinal), Ok(previous_ordinal)) =
91 (u32::try_from(*index), u32::try_from(previous))
92 else {
93 return false;
94 };
95 let candidate = EdgeName::between(
96 FaceName::swept(SweptFace::Side(previous_ordinal)),
97 FaceName::swept(SweptFace::Side(ordinal)),
98 );
99 candidate == *name
100 })
101 .ok_or_else(|| {
102 unsupported("edge name does not resolve to a corner of this profile")
103 })
104 }
105 }
106 }
107}
108
109#[derive(Debug, Clone, Copy, PartialEq)]
111pub enum FeatureSize {
112 ConstantDistance(Scalar),
114 ConstantRadius(Scalar),
116}
117
118pub fn chamfer_extruded_profile(
123 profile: &Profile,
124 direction: Vec3,
125 depth: Scalar,
126 edge: EdgeSelector,
127 size: FeatureSize,
128 tolerance: Tolerance,
129) -> GeomResult<ExactBRep> {
130 let distance = match size {
131 FeatureSize::ConstantDistance(distance) => distance,
132 FeatureSize::ConstantRadius(_) => {
136 return Err(unsupported("constant-radius fillet on an extruded solid"))
137 }
138 };
139 if !distance.is_finite() || distance <= 0.0 {
140 return Err(GeomError::InvalidInput(format!(
141 "chamfer distance must be positive and finite, got {distance}"
142 )));
143 }
144
145 let Profile::Rectangle(rectangle) = profile else {
146 return Err(unsupported("chamfer on a non-rectangle profile"));
147 };
148 let RectangleProfile {
149 x,
150 y,
151 thickness,
152 outer_radius,
153 inner_radius,
154 } = *rectangle;
155 if thickness.is_some() {
156 return Err(unsupported("chamfer on a hollow profile"));
157 }
158 if outer_radius.is_some() || inner_radius.is_some() {
159 return Err(unsupported("chamfer on an already-rounded profile"));
160 }
161 if !x.is_finite() || !y.is_finite() || x <= 0.0 || y <= 0.0 {
162 return Err(GeomError::InvalidInput(format!(
163 "chamfer profile must have positive finite extents, got {x} x {y}"
164 )));
165 }
166 if !depth.is_finite() || depth <= 0.0 {
167 return Err(GeomError::InvalidInput(format!(
168 "chamfer extrusion depth must be positive and finite, got {depth}"
169 )));
170 }
171 if direction.normalize_or_zero().dot(Vec3::Z) < 1.0 - tolerance.linear() {
174 return Err(unsupported("chamfer on an oblique extrusion"));
175 }
176
177 let (half_x, half_y) = (x / 2.0, y / 2.0);
178 let corners = [
180 Point2::new(-half_x, -half_y),
181 Point2::new(half_x, -half_y),
182 Point2::new(half_x, half_y),
183 Point2::new(-half_x, half_y),
184 ];
185
186 if distance * 2.0 >= x || distance * 2.0 >= y {
190 return Err(GeomError::Degenerate(format!(
191 "chamfer distance {distance} consumes an entire edge of the {x} x {y} profile"
192 )));
193 }
194
195 let index = edge.corner_index(&corners)?;
196
197 let previous = corners[(index + corners.len() - 1) % corners.len()];
200 let corner = corners[index];
201 let next = corners[(index + 1) % corners.len()];
202 let into_previous = (previous - corner).normalize();
203 let into_next = (next - corner).normalize();
204
205 let mut ring = Vec::with_capacity(corners.len() + 1);
206 for (position, point) in corners.iter().enumerate() {
207 if position == index {
208 ring.push(corner + into_previous * distance);
211 ring.push(corner + into_next * distance);
212 } else {
213 ring.push(*point);
214 }
215 }
216
217 extrude_polygon_rings(&[ring], Vec3::Z * depth)
218}
219
220pub fn fillet_extruded_profile(
241 profile: &Profile,
242 direction: Vec3,
243 depth: Scalar,
244 edge: EdgeSelector,
245 size: FeatureSize,
246 tolerance: Tolerance,
247) -> GeomResult<ExactBRep> {
248 let radius = match size {
249 FeatureSize::ConstantRadius(radius) => radius,
250 FeatureSize::ConstantDistance(_) => {
251 return Err(unsupported(
252 "chamfer requested through the fillet entry point",
253 ))
254 }
255 };
256 if !radius.is_finite() || radius <= 0.0 {
257 return Err(GeomError::InvalidInput(format!(
258 "fillet radius must be positive and finite, got {radius}"
259 )));
260 }
261
262 let geometry = rectangle_prism(profile, direction, depth, tolerance)?;
263 let (x, y) = geometry;
264
265 if radius * 2.0 >= x || radius * 2.0 >= y {
269 return Err(GeomError::Degenerate(format!(
270 "fillet radius {radius} reaches past a neighbouring corner of the {x} x {y} profile"
271 )));
272 }
273
274 build_filleted_prism(x, y, depth, radius, edge)
275}
276
277fn rectangle_prism(
284 profile: &Profile,
285 direction: Vec3,
286 depth: Scalar,
287 tolerance: Tolerance,
288) -> GeomResult<(Scalar, Scalar)> {
289 let Profile::Rectangle(rectangle) = profile else {
290 return Err(unsupported("fillet on a non-rectangle profile"));
291 };
292 let RectangleProfile {
293 x,
294 y,
295 thickness,
296 outer_radius,
297 inner_radius,
298 } = *rectangle;
299 if thickness.is_some() {
300 return Err(unsupported("fillet on a hollow profile"));
301 }
302 if outer_radius.is_some() || inner_radius.is_some() {
303 return Err(unsupported("fillet on an already-rounded profile"));
304 }
305 if !x.is_finite() || !y.is_finite() || x <= 0.0 || y <= 0.0 {
306 return Err(GeomError::InvalidInput(format!(
307 "fillet profile must have positive finite extents, got {x} x {y}"
308 )));
309 }
310 if !depth.is_finite() || depth <= 0.0 {
311 return Err(GeomError::InvalidInput(format!(
312 "fillet extrusion depth must be positive and finite, got {depth}"
313 )));
314 }
315 if direction.normalize_or_zero().dot(Vec3::Z) < 1.0 - tolerance.linear() {
316 return Err(unsupported("fillet on an oblique extrusion"));
317 }
318 Ok((x, y))
319}
320
321pub struct BlendCorner {
328 pub centre: Point2,
329 pub start: Point2,
330 pub end: Point2,
331 pub sweep: Scalar,
332}
333
334fn blend_corner(corners: &[Point2], index: usize, radius: Scalar) -> Option<BlendCorner> {
348 let count = corners.len();
349 let previous = corners[(index + count - 1) % count];
350 let corner = corners[index];
351 let next = corners[(index + 1) % count];
352
353 let into_previous = (previous - corner).normalize_or_zero();
354 let into_next = (next - corner).normalize_or_zero();
355 if into_previous == Vec2::ZERO || into_next == Vec2::ZERO {
356 return None;
357 }
358
359 let cosine = into_previous.dot(into_next).clamp(-1.0, 1.0);
361 let theta = cosine.acos();
362 let half = theta / 2.0;
363 let (sin_half, tan_half) = (half.sin(), half.tan());
364 if sin_half.abs() <= f64::EPSILON || tan_half.abs() <= f64::EPSILON {
366 return None;
367 }
368
369 let setback = radius / tan_half;
372 let start = corner + into_previous * setback;
373 let end = corner + into_next * setback;
374
375 let bisector = (into_previous + into_next).normalize_or_zero();
377 if bisector == Vec2::ZERO {
378 return None;
379 }
380 let centre = corner + bisector * (radius / sin_half);
381
382 let start_angle = (start.y - centre.y).atan2(start.x - centre.x);
386 let end_angle = (end.y - centre.y).atan2(end.x - centre.x);
387 let mut sweep = end_angle - start_angle;
389 while sweep > core::f64::consts::PI {
390 sweep -= core::f64::consts::TAU;
391 }
392 while sweep < -core::f64::consts::PI {
393 sweep += core::f64::consts::TAU;
394 }
395
396 Some(BlendCorner {
397 centre,
398 start,
399 end,
400 sweep,
401 })
402}
403
404fn build_filleted_prism(
412 x: Scalar,
413 y: Scalar,
414 depth: Scalar,
415 radius: Scalar,
416 edge: EdgeSelector,
417) -> GeomResult<ExactBRep> {
418 let (half_x, half_y) = (x / 2.0, y / 2.0);
419 let corners = [
420 Point2::new(-half_x, -half_y),
421 Point2::new(half_x, -half_y),
422 Point2::new(half_x, half_y),
423 Point2::new(-half_x, half_y),
424 ];
425
426 let index = edge.corner_index(&corners)?;
427
428 let blend = blend_corner(&corners, index, radius)
429 .ok_or_else(|| unsupported("fillet at a degenerate corner"))?;
430
431 let mut ring = Vec::with_capacity(corners.len() + 1);
434 for (position, point) in corners.iter().enumerate() {
435 if position == index {
436 ring.push(blend.start);
437 ring.push(blend.end);
438 } else {
439 ring.push(*point);
440 }
441 }
442
443 extrude_with_cylindrical_blend(&ring, Vec3::Z * depth, index, &blend, radius)
444}
445
446pub fn fillet_polygon_corner(
464 ring: &[Point2],
465 corner: usize,
466 radius: Scalar,
467 depth: Scalar,
468) -> GeomResult<ExactBRep> {
469 if ring.len() < 3 {
470 return Err(GeomError::InvalidInput(
471 "fillet needs a ring of at least three corners".to_owned(),
472 ));
473 }
474 if corner >= ring.len() {
475 return Err(GeomError::InvalidInput(format!(
476 "fillet corner {corner} is outside a ring of {} corners",
477 ring.len()
478 )));
479 }
480 if !radius.is_finite() || radius <= 0.0 {
481 return Err(GeomError::InvalidInput(format!(
482 "fillet radius must be positive and finite, got {radius}"
483 )));
484 }
485 if !depth.is_finite() || depth <= 0.0 {
486 return Err(GeomError::InvalidInput(format!(
487 "fillet extrusion depth must be positive and finite, got {depth}"
488 )));
489 }
490 if !ring.iter().all(|p| p.x.is_finite() && p.y.is_finite()) {
491 return Err(GeomError::InvalidInput(
492 "fillet ring has a non-finite corner".to_owned(),
493 ));
494 }
495
496 let count = ring.len();
497 let previous = ring[(corner + count - 1) % count];
498 let here = ring[corner];
499 let next = ring[(corner + 1) % count];
500
501 let ring_is_ccw = signed_area(ring) > 0.0;
505 let turn = (here - previous).perp_dot(next - here);
506 let convex = if ring_is_ccw { turn > 0.0 } else { turn < 0.0 };
507 if !convex {
508 return Err(unsupported("fillet on a reflex corner"));
509 }
510
511 let blend = blend_corner(ring, corner, radius)
512 .ok_or_else(|| unsupported("fillet at a degenerate corner"))?;
513
514 let setback = (blend.start - here).length();
518 let to_previous = (previous - here).length();
519 let to_next = (next - here).length();
520 if setback >= to_previous || setback >= to_next {
521 return Err(unsupported("fillet radius larger than an adjacent edge"));
522 }
523
524 let mut blended = Vec::with_capacity(count + 1);
527 for (index, point) in ring.iter().enumerate() {
528 if index == corner {
529 blended.push(blend.start);
530 blended.push(blend.end);
531 } else {
532 blended.push(*point);
533 }
534 }
535 let blend_index = corner;
536 extrude_with_cylindrical_blend(&blended, Vec3::Z * depth, blend_index, &blend, radius)
537}
538
539fn signed_area(ring: &[Point2]) -> Scalar {
541 let count = ring.len();
542 (0..count)
543 .map(|index| {
544 let a = ring[index];
545 let b = ring[(index + 1) % count];
546 a.x * b.y - b.x * a.y
547 })
548 .sum()
549}
550
551pub fn fillet_polygon_corners(
565 ring: &[Point2],
566 fillets: &[(usize, Scalar)],
567 depth: Scalar,
568) -> GeomResult<ExactBRep> {
569 if fillets.is_empty() {
570 return Err(GeomError::InvalidInput(
571 "multi-corner fillet needs at least one corner".to_owned(),
572 ));
573 }
574 if ring.len() < 3 {
575 return Err(GeomError::InvalidInput(
576 "fillet profile ring needs at least three points".to_owned(),
577 ));
578 }
579
580 let count = ring.len();
581 let mut seen = vec![false; count];
582 let mut setbacks = vec![0.0; count];
583 let mut blends: Vec<Option<BlendCorner>> = (0..count).map(|_| None).collect();
584
585 for (corner, radius) in fillets.iter().copied() {
586 if corner >= count {
587 return Err(GeomError::InvalidInput(format!(
588 "fillet corner {corner} is outside a ring of {count} points"
589 )));
590 }
591 if seen[corner] {
592 return Err(GeomError::InvalidInput(format!(
593 "fillet corner {corner} given more than once"
594 )));
595 }
596 seen[corner] = true;
597 if !radius.is_finite() || radius <= 0.0 {
598 return Err(GeomError::InvalidInput(format!(
599 "fillet radius must be positive and finite, got {radius}"
600 )));
601 }
602 let blend = blend_corner(ring, corner, radius)
603 .ok_or_else(|| unsupported("fillet at a degenerate corner"))?;
604 setbacks[corner] = (blend.start - ring[corner]).length();
605 blends[corner] = Some(blend);
606 }
607
608 for corner in 0..count {
611 if !seen[corner] {
612 continue;
613 }
614 let previous = ring[(corner + count - 1) % count];
615 let here = ring[corner];
616 let next = ring[(corner + 1) % count];
617 let cross = (here - previous).perp_dot(next - here);
618 if cross <= 0.0 {
619 return Err(unsupported("fillet on a reflex corner"));
620 }
621 }
622
623 for start in 0..count {
627 let end = (start + 1) % count;
628 let length = (ring[end] - ring[start]).length();
629 if setbacks[start] + setbacks[end] >= length {
630 return Err(unsupported(
631 "fillet radii too large for the edge between two corners",
632 ));
633 }
634 }
635
636 let mut blended = Vec::with_capacity(count + fillets.len());
640 let mut table: Vec<Option<(BlendCorner, Scalar)>> = Vec::with_capacity(count + fillets.len());
641 let radius_of: Vec<Scalar> = {
642 let mut r = vec![0.0; count];
643 for (corner, radius) in fillets.iter().copied() {
644 r[corner] = radius;
645 }
646 r
647 };
648 for corner in 0..count {
649 match blends[corner].take() {
650 Some(blend) => {
651 let end = blend.end;
653 blended.push(blend.start);
654 table.push(Some((blend, radius_of[corner])));
655 blended.push(end);
658 table.push(None);
659 }
660 None => {
661 blended.push(ring[corner]);
662 table.push(None);
663 }
664 }
665 }
666
667 extrude_with_cylindrical_blends(&blended, Vec3::Z * depth, &table)
668}
669
670pub fn chamfer_polygon_corners(
685 ring: &[Point2],
686 chamfers: &[(usize, Scalar)],
687 depth: Scalar,
688) -> GeomResult<ExactBRep> {
689 if chamfers.is_empty() {
690 return Err(GeomError::InvalidInput(
691 "multi-corner chamfer needs at least one corner".to_owned(),
692 ));
693 }
694 if ring.len() < 3 {
695 return Err(GeomError::InvalidInput(
696 "chamfer profile ring needs at least three points".to_owned(),
697 ));
698 }
699 if !depth.is_finite() || depth <= 0.0 {
700 return Err(GeomError::InvalidInput(format!(
701 "chamfer extrusion depth must be positive and finite, got {depth}"
702 )));
703 }
704
705 let count = ring.len();
706 let mut seen = vec![false; count];
707 let mut setbacks = vec![0.0; count];
708
709 for (corner, distance) in chamfers.iter().copied() {
710 if corner >= count {
711 return Err(GeomError::InvalidInput(format!(
712 "chamfer corner {corner} is outside a ring of {count} points"
713 )));
714 }
715 if seen[corner] {
716 return Err(GeomError::InvalidInput(format!(
717 "chamfer corner {corner} given more than once"
718 )));
719 }
720 seen[corner] = true;
721 if !distance.is_finite() || distance <= 0.0 {
722 return Err(GeomError::InvalidInput(format!(
723 "chamfer distance must be positive and finite, got {distance}"
724 )));
725 }
726 setbacks[corner] = distance;
727 }
728
729 for corner in 0..count {
732 if !seen[corner] {
733 continue;
734 }
735 let previous = ring[(corner + count - 1) % count];
736 let here = ring[corner];
737 let next = ring[(corner + 1) % count];
738 if (here - previous).perp_dot(next - here) <= 0.0 {
739 return Err(unsupported("chamfer on a reflex corner"));
740 }
741 }
742
743 for start in 0..count {
745 let end = (start + 1) % count;
746 let length = (ring[end] - ring[start]).length();
747 if setbacks[start] + setbacks[end] >= length {
748 return Err(unsupported(
749 "chamfer distances too large for the edge between two corners",
750 ));
751 }
752 }
753
754 let mut chamfered = Vec::with_capacity(count + chamfers.len());
755 for corner in 0..count {
756 let here = ring[corner];
757 if !seen[corner] {
758 chamfered.push(here);
759 continue;
760 }
761 let previous = ring[(corner + count - 1) % count];
762 let next = ring[(corner + 1) % count];
763 let into_previous = (previous - here).normalize_or_zero();
764 let into_next = (next - here).normalize_or_zero();
765 if into_previous == Vec2::ZERO || into_next == Vec2::ZERO {
766 return Err(unsupported("chamfer at a degenerate corner"));
767 }
768 chamfered.push(here + into_previous * setbacks[corner]);
770 chamfered.push(here + into_next * setbacks[corner]);
771 }
772
773 extrude_polygon_rings(&[chamfered], Vec3::Z * depth)
774}