1use axiolid_contracts::{GeomError, GeomResult, Operation};
21use axiolid_core::{Frame2, Interval, Point2, Scalar, Vec2};
22use axiolid_curve::{Circle2, Curve2, Line2};
23use axiolid_profile::{
24 CircleProfile, Contour, ContourProfile, ProfileSegment, RectangleProfile, SectionProfile,
25};
26
27use crate::BACKEND_ID;
28
29fn unsupported(input: &'static str) -> GeomError {
30 GeomError::UnsupportedInput {
31 backend: BACKEND_ID,
32 operation: Operation::Sweep,
33 input,
34 }
35}
36
37#[derive(Debug, Clone, Copy)]
39struct Corner {
40 point: Point2,
41 radius: Scalar,
43}
44
45fn sharp(x: Scalar, y: Scalar) -> Corner {
46 Corner {
47 point: Point2::new(x, y),
48 radius: 0.0,
49 }
50}
51
52fn rounded(x: Scalar, y: Scalar, radius: Option<Scalar>) -> Corner {
53 Corner {
54 point: Point2::new(x, y),
55 radius: radius.unwrap_or(0.0).max(0.0),
58 }
59}
60
61fn route(corners: &[Corner]) -> GeomResult<Contour> {
68 let count = corners.len();
69 if count < 3 {
70 return Err(GeomError::InvalidInput(format!(
71 "a section outline needs at least three corners, got {count}"
72 )));
73 }
74
75 let mut cut = vec![0.0; count];
77 let mut arcs: Vec<Option<(Point2, Point2, Point2, Scalar)>> = vec![None; count];
78
79 for index in 0..count {
80 let here = corners[index];
81 if here.radius <= 0.0 {
82 continue;
83 }
84 let previous = corners[(index + count - 1) % count].point;
85 let next = corners[(index + 1) % count].point;
86 let incoming = (here.point - previous).normalize_or_zero();
87 let outgoing = (next - here.point).normalize_or_zero();
88 if incoming == Vec2::ZERO || outgoing == Vec2::ZERO {
89 return Err(GeomError::Degenerate(
90 "section outline has a zero-length edge".to_owned(),
91 ));
92 }
93 let cross = incoming.perp_dot(outgoing);
94 if cross == 0.0 {
95 continue;
97 }
98 let turn = incoming.dot(outgoing).clamp(-1.0, 1.0).acos();
99 let setback = here.radius * (turn / 2.0).tan();
100 let bisector = (outgoing - incoming).normalize_or_zero();
110 if bisector == Vec2::ZERO {
111 return Err(GeomError::Degenerate(
112 "section outline reverses on itself".to_owned(),
113 ));
114 }
115 let centre = here.point + bisector * (here.radius / (turn / 2.0).cos());
116 let start = here.point - incoming * setback;
117 let end = here.point + outgoing * setback;
118 cut[index] = setback;
119 arcs[index] = Some((centre, start, end, cross.signum() * turn));
120 }
121
122 for start in 0..count {
124 let end = (start + 1) % count;
125 let length = (corners[end].point - corners[start].point).length();
126 if cut[start] + cut[end] > length + 1e-12 {
127 return Err(unsupported(
128 "section radii too large for the edge between two corners",
129 ));
130 }
131 }
132
133 Ok(Contour::new(emit(corners, &arcs)))
134}
135fn emit(
138 corners: &[Corner],
139 arcs: &[Option<(Point2, Point2, Point2, Scalar)>],
140) -> Vec<ProfileSegment> {
141 let count = corners.len();
142 let mut segments = Vec::with_capacity(count * 2);
143 for index in 0..count {
144 let leave = match arcs[index] {
146 Some((centre, start, end, sweep)) => {
147 segments.push(arc_segment(centre, start, sweep));
148 let _ = end;
149 end
150 }
151 None => corners[index].point,
152 };
153 let next = (index + 1) % count;
154 let arrive = match arcs[next] {
155 Some((_, start, _, _)) => start,
156 None => corners[next].point,
157 };
158 if (arrive - leave).length() > 1e-15 {
161 segments.push(ProfileSegment {
162 curve: Curve2::Line(Line2 {
163 origin: leave,
164 direction: arrive - leave,
165 }),
166 domain: Interval::UNIT,
167 same_sense: true,
168 });
169 }
170 }
171 segments
172}
173
174fn arc_segment(centre: Point2, start: Point2, sweep: Scalar) -> ProfileSegment {
182 let x = (start - centre).normalize_or_zero();
183 let perpendicular = Vec2::new(-x.y, x.x);
184 let y = if sweep >= 0.0 {
185 perpendicular
186 } else {
187 -perpendicular
188 };
189 ProfileSegment {
190 curve: Curve2::Circle(Circle2 {
191 frame: Frame2 {
192 origin: centre,
193 x,
194 y,
195 },
196 radius: (start - centre).length(),
197 }),
198 domain: Interval::new(0.0, sweep.abs()),
199 same_sense: true,
200 }
201}
202
203fn checked_slope(slope: Option<Scalar>, what: &'static str) -> GeomResult<Scalar> {
217 let value = slope.unwrap_or(0.0);
218 if !value.is_finite() {
219 return Err(GeomError::InvalidInput(format!(
220 "{what} slope must be finite, got {value}"
221 )));
222 }
223 let limit = core::f64::consts::FRAC_PI_2 * 0.9;
226 if value.abs() >= limit {
227 return Err(GeomError::Degenerate(format!(
228 "{what} slope {value} rad is too steep to leave a flange"
229 )));
230 }
231 Ok(value)
232}
233
234pub fn circle_contour(circle: &CircleProfile) -> GeomResult<ContourProfile> {
241 positive(circle.radius, "circle radius")?;
242 let ring = |radius: Scalar| {
243 let frame = Frame2 {
244 origin: Point2::ZERO,
245 x: Vec2::X,
246 y: Vec2::Y,
247 };
248 let quarter = core::f64::consts::FRAC_PI_2;
249 Contour::new(
250 (0..4)
251 .map(|index| ProfileSegment {
252 curve: Curve2::Circle(Circle2 { frame, radius }),
253 domain: Interval::new(
254 quarter * index as Scalar,
255 quarter * (index + 1) as Scalar,
256 ),
257 same_sense: true,
258 })
259 .collect(),
260 )
261 };
262 let holes = match circle.thickness {
263 None => Vec::new(),
264 Some(thickness) => {
265 positive(thickness, "circle wall thickness")?;
266 if thickness >= circle.radius {
267 return Err(GeomError::InvalidInput(format!(
268 "circle wall thickness {thickness} leaves no bore in radius {}",
269 circle.radius
270 )));
271 }
272 vec![ring(circle.radius - thickness)]
273 }
274 };
275 Ok(ContourProfile {
276 outer: ring(circle.radius),
277 holes,
278 })
279}
280
281pub fn rectangle_contour(rectangle: &RectangleProfile) -> GeomResult<ContourProfile> {
298 positive(rectangle.x, "rectangle x extent")?;
299 positive(rectangle.y, "rectangle y extent")?;
300 let (hx, hy) = (rectangle.x / 2.0, rectangle.y / 2.0);
301 let outer_radius = corner_radius(rectangle.outer_radius, hx.min(hy), "outer")?;
302 let outer = route(&box_corners(hx, hy, outer_radius))?;
303 let Some(thickness) = rectangle.thickness else {
304 if rectangle.inner_radius.is_some() {
305 return Err(GeomError::InvalidInput(
306 "an inner corner radius needs a hollow rectangle".to_owned(),
307 ));
308 }
309 return Ok(ContourProfile {
310 outer,
311 holes: Vec::new(),
312 });
313 };
314 positive(thickness, "rectangle wall thickness")?;
315 if 2.0 * thickness >= rectangle.x.min(rectangle.y) {
316 return Err(GeomError::Degenerate(format!(
317 "wall thickness {thickness} leaves no opening in {} x {}",
318 rectangle.x, rectangle.y
319 )));
320 }
321 let (ix, iy) = (hx - thickness, hy - thickness);
322 let inner_radius = corner_radius(rectangle.inner_radius, ix.min(iy), "inner")?;
323 let wall_limit = (2.0 + core::f64::consts::SQRT_2) * thickness;
324 if outer_radius - inner_radius >= wall_limit {
325 return Err(GeomError::Degenerate(format!(
326 "outer radius {outer_radius} minus inner radius {inner_radius} must stay \
327 below {wall_limit} or the {thickness}-thick wall vanishes at the corners"
328 )));
329 }
330 let hole = route(&box_corners(ix, iy, inner_radius))?;
331 Ok(ContourProfile {
332 outer,
333 holes: vec![hole],
334 })
335}
336
337fn box_corners(hx: Scalar, hy: Scalar, radius: Scalar) -> [Corner; 4] {
339 let radius = Some(radius);
340 [
341 rounded(-hx, -hy, radius),
342 rounded(hx, -hy, radius),
343 rounded(hx, hy, radius),
344 rounded(-hx, hy, radius),
345 ]
346}
347
348fn corner_radius(radius: Option<Scalar>, limit: Scalar, which: &str) -> GeomResult<Scalar> {
350 let value = radius.unwrap_or(0.0);
351 if !value.is_finite() || value < 0.0 || value > limit {
352 return Err(GeomError::InvalidInput(format!(
353 "{which} corner radius must lie in [0, {limit}], got {value}"
354 )));
355 }
356 Ok(value)
357}
358
359fn positive(value: Scalar, what: &str) -> GeomResult<()> {
360 if !value.is_finite() || value <= 0.0 {
361 return Err(GeomError::InvalidInput(format!(
362 "section {what} must be positive and finite, got {value}"
363 )));
364 }
365 Ok(())
366}
367pub fn section_contour(section: &SectionProfile) -> GeomResult<ContourProfile> {
372 let corners = match section {
373 SectionProfile::I {
374 depth,
375 width,
376 web_thickness,
377 flange_thickness,
378 fillet_radius,
379 flange_edge_radius,
380 flange_slope,
381 } => {
382 let slope = checked_slope(*flange_slope, "I section")?;
383 i_corners(
384 *depth,
385 *width,
386 *width,
387 *web_thickness,
388 *flange_thickness,
389 *flange_thickness,
390 *fillet_radius,
391 *fillet_radius,
392 *flange_edge_radius,
393 *flange_edge_radius,
394 slope,
395 slope,
396 )?
397 }
398 SectionProfile::AsymmetricI {
399 depth,
400 web_thickness,
401 bottom_flange_width,
402 bottom_flange_thickness,
403 bottom_fillet_radius,
404 bottom_flange_edge_radius,
405 bottom_flange_slope,
406 top_flange_width,
407 top_flange_thickness,
408 top_fillet_radius,
409 top_flange_edge_radius,
410 top_flange_slope,
411 } => {
412 let bottom_slope = checked_slope(*bottom_flange_slope, "I section")?;
413 let top_slope = checked_slope(*top_flange_slope, "I section")?;
414 i_corners(
415 *depth,
416 *bottom_flange_width,
417 *top_flange_width,
418 *web_thickness,
419 *bottom_flange_thickness,
420 top_flange_thickness.unwrap_or(*bottom_flange_thickness),
423 *bottom_fillet_radius,
424 *top_fillet_radius,
425 *bottom_flange_edge_radius,
426 *top_flange_edge_radius,
427 bottom_slope,
428 top_slope,
429 )?
430 }
431 SectionProfile::T {
432 depth,
433 flange_width,
434 web_thickness,
435 flange_thickness,
436 fillet_radius,
437 flange_edge_radius,
438 web_edge_radius,
439 web_slope,
440 flange_slope,
441 } => {
442 let web = checked_slope(*web_slope, "T section web")?;
444 let flange = checked_slope(*flange_slope, "T section flange")?;
445 t_corners(
446 *depth,
447 *flange_width,
448 *web_thickness,
449 *flange_thickness,
450 &RadiiT {
451 fillet: *fillet_radius,
452 flange_edge: *flange_edge_radius,
453 web_edge: *web_edge_radius,
454 },
455 &TaperT { web, flange },
456 )?
457 }
458 SectionProfile::U {
459 depth,
460 flange_width,
461 web_thickness,
462 flange_thickness,
463 fillet_radius,
464 edge_radius,
465 flange_slope,
466 } => {
467 let slope = checked_slope(*flange_slope, "U section")?;
468 u_corners(
469 *depth,
470 *flange_width,
471 *web_thickness,
472 *flange_thickness,
473 *fillet_radius,
474 *edge_radius,
475 slope,
476 )?
477 }
478 SectionProfile::L {
479 depth,
480 width,
481 thickness,
482 fillet_radius,
483 edge_radius,
484 leg_slope,
485 } => {
486 let slope = checked_slope(*leg_slope, "L section")?;
487 l_corners(
488 *depth,
489 width.unwrap_or(*depth),
491 *thickness,
492 *fillet_radius,
493 *edge_radius,
494 slope,
495 )?
496 }
497 SectionProfile::Z {
498 depth,
499 flange_width,
500 web_thickness,
501 flange_thickness,
502 fillet_radius,
503 edge_radius,
504 } => z_corners(
505 *depth,
506 *flange_width,
507 *web_thickness,
508 *flange_thickness,
509 *fillet_radius,
510 *edge_radius,
511 )?,
512 SectionProfile::C {
513 depth,
514 width,
515 wall_thickness,
516 girth,
517 internal_fillet_radius,
518 } => c_corners(
519 *depth,
520 *width,
521 *wall_thickness,
522 *girth,
523 *internal_fillet_radius,
524 )?,
525 SectionProfile::Trapezium {
526 bottom_x,
527 top_x,
528 y,
529 top_offset,
530 } => trapezium_corners(*bottom_x, *top_x, *y, *top_offset)?,
531 _ => return Err(unsupported("section profile of an unsupported kind")),
532 };
533
534 Ok(ContourProfile {
535 outer: route(&corners)?,
536 holes: Vec::new(),
537 })
538}
539#[allow(clippy::too_many_arguments)]
545fn i_corners(
546 depth: Scalar,
547 bottom_width: Scalar,
548 top_width: Scalar,
549 web_thickness: Scalar,
550 bottom_flange: Scalar,
551 top_flange: Scalar,
552 bottom_fillet: Option<Scalar>,
553 top_fillet: Option<Scalar>,
554 bottom_edge: Option<Scalar>,
555 top_edge: Option<Scalar>,
556 bottom_slope: Scalar,
557 top_slope: Scalar,
558) -> GeomResult<Vec<Corner>> {
559 positive(depth, "depth")?;
560 positive(bottom_width, "flange width")?;
561 positive(top_width, "flange width")?;
562 positive(web_thickness, "web thickness")?;
563 positive(bottom_flange, "flange thickness")?;
564 positive(top_flange, "flange thickness")?;
565 if bottom_flange + top_flange >= depth {
566 return Err(GeomError::Degenerate(format!(
567 "flanges {bottom_flange} + {top_flange} leave no web in depth {depth}"
568 )));
569 }
570 if web_thickness >= bottom_width.min(top_width) {
571 return Err(GeomError::Degenerate(format!(
572 "web thickness {web_thickness} is not narrower than the flange"
573 )));
574 }
575
576 let (hd, hw) = (depth / 2.0, web_thickness / 2.0);
577 let (hb, ht) = (bottom_width / 2.0, top_width / 2.0);
578 let bottom_top = -hd + bottom_flange;
579 let top_bottom = hd - top_flange;
580
581 let bottom_rise = |x: Scalar| (x - (hw + hb) / 2.0) * bottom_slope.tan();
587 let top_rise = |x: Scalar| (x - (hw + ht) / 2.0) * top_slope.tan();
588
589 Ok(vec![
590 sharp(hb, -hd),
591 rounded(hb, bottom_top - bottom_rise(hb), bottom_edge),
592 rounded(hw, bottom_top - bottom_rise(hw), bottom_fillet),
593 rounded(hw, top_bottom + top_rise(hw), top_fillet),
594 rounded(ht, top_bottom + top_rise(ht), top_edge),
595 sharp(ht, hd),
596 sharp(-ht, hd),
597 rounded(-ht, top_bottom + top_rise(ht), top_edge),
598 rounded(-hw, top_bottom + top_rise(hw), top_fillet),
599 rounded(-hw, bottom_top - bottom_rise(hw), bottom_fillet),
600 rounded(-hb, bottom_top - bottom_rise(hb), bottom_edge),
601 sharp(-hb, -hd),
602 ])
603}
604
605struct RadiiT {
613 fillet: Option<Scalar>,
614 flange_edge: Option<Scalar>,
615 web_edge: Option<Scalar>,
616}
617
618struct TaperT {
619 web: Scalar,
620 flange: Scalar,
621}
622
623fn t_corners(
624 depth: Scalar,
625 flange_width: Scalar,
626 web_thickness: Scalar,
627 flange_thickness: Scalar,
628 radii: &RadiiT,
629 taper: &TaperT,
630) -> GeomResult<Vec<Corner>> {
631 let (web_slope, flange_slope) = (taper.web, taper.flange);
632 let (fillet, flange_edge, web_edge) = (radii.fillet, radii.flange_edge, radii.web_edge);
633 positive(depth, "depth")?;
634 positive(flange_width, "flange width")?;
635 positive(web_thickness, "web thickness")?;
636 positive(flange_thickness, "flange thickness")?;
637 if flange_thickness >= depth {
638 return Err(GeomError::Degenerate(format!(
639 "flange {flange_thickness} leaves no web in depth {depth}"
640 )));
641 }
642 if web_thickness >= flange_width {
643 return Err(GeomError::Degenerate(format!(
644 "web thickness {web_thickness} is not narrower than the flange"
645 )));
646 }
647
648 let (hd, hw, hf) = (depth / 2.0, web_thickness / 2.0, flange_width / 2.0);
649 let flange_bottom = hd - flange_thickness;
650
651 let flange_rise = |x: Scalar| (x - (hw + hf) / 2.0) * flange_slope.tan();
655 let web_mid = (-hd + flange_bottom) / 2.0;
656 let web_out = |y: Scalar| (y - web_mid) * web_slope.tan();
657
658 Ok(vec![
659 rounded(hw + web_out(-hd), -hd, web_edge),
660 rounded(hw + web_out(flange_bottom), flange_bottom, fillet),
661 rounded(hf, flange_bottom - flange_rise(hf), flange_edge),
662 sharp(hf, hd),
663 sharp(-hf, hd),
664 rounded(-hf, flange_bottom - flange_rise(hf), flange_edge),
665 rounded(-hw - web_out(flange_bottom), flange_bottom, fillet),
666 rounded(-hw - web_out(-hd), -hd, web_edge),
667 ])
668}
669
670fn u_corners(
672 depth: Scalar,
673 flange_width: Scalar,
674 web_thickness: Scalar,
675 flange_thickness: Scalar,
676 fillet: Option<Scalar>,
677 edge: Option<Scalar>,
678 flange_slope: Scalar,
679) -> GeomResult<Vec<Corner>> {
680 positive(depth, "depth")?;
681 positive(flange_width, "flange width")?;
682 positive(web_thickness, "web thickness")?;
683 positive(flange_thickness, "flange thickness")?;
684 if 2.0 * flange_thickness >= depth {
685 return Err(GeomError::Degenerate(format!(
686 "flanges {flange_thickness} leave no web in depth {depth}"
687 )));
688 }
689 if web_thickness >= flange_width {
690 return Err(GeomError::Degenerate(format!(
691 "web thickness {web_thickness} is not narrower than the flange"
692 )));
693 }
694
695 let hd = depth / 2.0;
696 let inner = web_thickness;
697 let rise = |x: Scalar| (x - (inner + flange_width) / 2.0) * flange_slope.tan();
700
701 Ok(vec![
702 sharp(flange_width, -hd),
703 rounded(
704 flange_width,
705 -hd + flange_thickness - rise(flange_width),
706 edge,
707 ),
708 rounded(inner, -hd + flange_thickness - rise(inner), fillet),
709 rounded(inner, hd - flange_thickness + rise(inner), fillet),
710 rounded(
711 flange_width,
712 hd - flange_thickness + rise(flange_width),
713 edge,
714 ),
715 sharp(flange_width, hd),
716 sharp(0.0, hd),
717 sharp(0.0, -hd),
718 ])
719}
720
721fn l_corners(
723 depth: Scalar,
724 width: Scalar,
725 thickness: Scalar,
726 fillet: Option<Scalar>,
727 edge: Option<Scalar>,
728 leg_slope: Scalar,
729) -> GeomResult<Vec<Corner>> {
730 positive(depth, "depth")?;
731 positive(width, "width")?;
732 positive(thickness, "thickness")?;
733 if thickness >= depth.min(width) {
734 return Err(GeomError::Degenerate(format!(
735 "thickness {thickness} is not thinner than the legs"
736 )));
737 }
738
739 let tan = leg_slope.tan();
742 let horizontal = |x: Scalar| (x - (thickness + width) / 2.0) * tan;
743 let vertical = |y: Scalar| (y - (thickness + depth) / 2.0) * tan;
744
745 Ok(vec![
746 sharp(0.0, 0.0),
747 sharp(width, 0.0),
748 rounded(width, thickness - horizontal(width), edge),
749 rounded(thickness, thickness, fillet),
750 rounded(thickness - vertical(depth), depth, edge),
751 sharp(0.0, depth),
752 ])
753}
754fn z_corners(
756 depth: Scalar,
757 flange_width: Scalar,
758 web_thickness: Scalar,
759 flange_thickness: Scalar,
760 fillet: Option<Scalar>,
761 edge: Option<Scalar>,
762) -> GeomResult<Vec<Corner>> {
763 positive(depth, "depth")?;
764 positive(flange_width, "flange width")?;
765 positive(web_thickness, "web thickness")?;
766 positive(flange_thickness, "flange thickness")?;
767 if 2.0 * flange_thickness >= depth {
768 return Err(GeomError::Degenerate(format!(
769 "flanges {flange_thickness} leave no web in depth {depth}"
770 )));
771 }
772
773 let (hd, hw) = (depth / 2.0, web_thickness / 2.0);
774 let bottom_top = -hd + flange_thickness;
775 let top_bottom = hd - flange_thickness;
776
777 Ok(vec![
779 sharp(hw + flange_width, -hd),
780 rounded(hw + flange_width, bottom_top, edge),
781 rounded(hw, bottom_top, fillet),
782 sharp(hw, hd),
783 sharp(-hw - flange_width, hd),
784 rounded(-hw - flange_width, top_bottom, edge),
785 rounded(-hw, top_bottom, fillet),
786 sharp(-hw, -hd),
787 ])
788}
789
790fn c_corners(
796 depth: Scalar,
797 width: Scalar,
798 wall_thickness: Scalar,
799 girth: Scalar,
800 fillet: Option<Scalar>,
801) -> GeomResult<Vec<Corner>> {
802 positive(depth, "depth")?;
803 positive(width, "width")?;
804 positive(wall_thickness, "wall thickness")?;
805 positive(girth, "girth")?;
806 if 2.0 * wall_thickness >= depth || 2.0 * wall_thickness >= width {
807 return Err(GeomError::Degenerate(format!(
808 "wall thickness {wall_thickness} leaves no opening"
809 )));
810 }
811 if girth <= wall_thickness {
812 return Err(GeomError::Degenerate(format!(
813 "lip girth {girth} is not longer than the wall thickness"
814 )));
815 }
816
817 let hd = depth / 2.0;
818 let t = wall_thickness;
819
820 Ok(vec![
821 sharp(width, -hd),
823 sharp(width, -hd + girth),
824 sharp(width - t, -hd + girth),
825 rounded(width - t, -hd + t, fillet),
826 rounded(t, -hd + t, fillet),
827 rounded(t, hd - t, fillet),
828 rounded(width - t, hd - t, fillet),
829 sharp(width - t, hd - girth),
830 sharp(width, hd - girth),
831 sharp(width, hd),
832 sharp(0.0, hd),
833 sharp(0.0, -hd),
834 ])
835}
836
837fn trapezium_corners(
839 bottom_x: Scalar,
840 top_x: Scalar,
841 y: Scalar,
842 top_offset: Scalar,
843) -> GeomResult<Vec<Corner>> {
844 positive(bottom_x, "bottom width")?;
845 positive(top_x, "top width")?;
846 positive(y, "height")?;
847 if !top_offset.is_finite() {
848 return Err(GeomError::InvalidInput(format!(
849 "trapezium top offset must be finite, got {top_offset}"
850 )));
851 }
852
853 Ok(vec![
854 sharp(0.0, 0.0),
855 sharp(bottom_x, 0.0),
856 sharp(top_offset + top_x, y),
857 sharp(top_offset, y),
858 ])
859}
860
861#[cfg(test)]
862mod tests {
863 use super::*;
864
865 #[test]
877 fn a_non_right_corner_is_rounded_tangentially() {
878 let radius = 0.02;
880 let corners = vec![
881 sharp(0.0, 0.0),
882 Corner {
883 point: Point2::new(0.4, 0.0),
884 radius,
885 },
886 sharp(0.3, 0.2),
887 sharp(0.05, 0.2),
888 ];
889 let contour = route(&corners).expect("a trapezoidal ring routes");
890
891 let arc = contour
892 .segments
893 .iter()
894 .find_map(|segment| match &segment.curve {
895 Curve2::Circle(circle) => Some(*circle),
896 _ => None,
897 })
898 .expect("the rounded corner produced an arc");
899 assert!(
900 (arc.radius - radius).abs() < 1e-12,
901 "arc must carry the stated radius, got {}",
902 arc.radius
903 );
904
905 let distance_to_line = |a: Point2, b: Point2| {
909 let along = (b - a).normalize();
910 let normal = Vec2::new(-along.y, along.x);
911 (arc.frame.origin - a).dot(normal).abs()
912 };
913 let incoming = distance_to_line(Point2::new(0.0, 0.0), Point2::new(0.4, 0.0));
914 let outgoing = distance_to_line(Point2::new(0.4, 0.0), Point2::new(0.3, 0.2));
915 assert!(
916 (incoming - radius).abs() < 1e-12,
917 "arc must be tangent to the incoming edge, distance {incoming}"
918 );
919 assert!(
920 (outgoing - radius).abs() < 1e-12,
921 "arc must be tangent to the outgoing edge, distance {outgoing}"
922 );
923 }
924}