1use axiolid_contracts::{GeomError, GeomResult, Operation};
21use axiolid_core::{Frame2, Interval, Point2, Scalar, Vec2};
22use axiolid_curve::{Circle2, Curve2, Line2};
23use axiolid_profile::{Contour, ContourProfile, ProfileSegment, SectionProfile};
24
25use crate::BACKEND_ID;
26
27fn unsupported(input: &'static str) -> GeomError {
28 GeomError::UnsupportedInput {
29 backend: BACKEND_ID,
30 operation: Operation::Sweep,
31 input,
32 }
33}
34
35#[derive(Debug, Clone, Copy)]
37struct Corner {
38 point: Point2,
39 radius: Scalar,
41}
42
43fn sharp(x: Scalar, y: Scalar) -> Corner {
44 Corner {
45 point: Point2::new(x, y),
46 radius: 0.0,
47 }
48}
49
50fn rounded(x: Scalar, y: Scalar, radius: Option<Scalar>) -> Corner {
51 Corner {
52 point: Point2::new(x, y),
53 radius: radius.unwrap_or(0.0).max(0.0),
56 }
57}
58
59fn route(corners: &[Corner]) -> GeomResult<Contour> {
66 let count = corners.len();
67 if count < 3 {
68 return Err(GeomError::InvalidInput(format!(
69 "a section outline needs at least three corners, got {count}"
70 )));
71 }
72
73 let mut cut = vec![0.0; count];
75 let mut arcs: Vec<Option<(Point2, Point2, Point2, Scalar)>> = vec![None; count];
76
77 for index in 0..count {
78 let here = corners[index];
79 if here.radius <= 0.0 {
80 continue;
81 }
82 let previous = corners[(index + count - 1) % count].point;
83 let next = corners[(index + 1) % count].point;
84 let incoming = (here.point - previous).normalize_or_zero();
85 let outgoing = (next - here.point).normalize_or_zero();
86 if incoming == Vec2::ZERO || outgoing == Vec2::ZERO {
87 return Err(GeomError::Degenerate(
88 "section outline has a zero-length edge".to_owned(),
89 ));
90 }
91 let cross = incoming.perp_dot(outgoing);
92 if cross == 0.0 {
93 continue;
95 }
96 let turn = incoming.dot(outgoing).clamp(-1.0, 1.0).acos();
97 let setback = here.radius * (turn / 2.0).tan();
98 let bisector = (outgoing - incoming).normalize_or_zero();
108 if bisector == Vec2::ZERO {
109 return Err(GeomError::Degenerate(
110 "section outline reverses on itself".to_owned(),
111 ));
112 }
113 let centre = here.point + bisector * (here.radius / (turn / 2.0).cos());
114 let start = here.point - incoming * setback;
115 let end = here.point + outgoing * setback;
116 cut[index] = setback;
117 arcs[index] = Some((centre, start, end, cross.signum() * turn));
118 }
119
120 for start in 0..count {
122 let end = (start + 1) % count;
123 let length = (corners[end].point - corners[start].point).length();
124 if cut[start] + cut[end] > length + 1e-12 {
125 return Err(unsupported(
126 "section radii too large for the edge between two corners",
127 ));
128 }
129 }
130
131 Ok(Contour::new(emit(corners, &arcs)))
132}
133fn emit(
136 corners: &[Corner],
137 arcs: &[Option<(Point2, Point2, Point2, Scalar)>],
138) -> Vec<ProfileSegment> {
139 let count = corners.len();
140 let mut segments = Vec::with_capacity(count * 2);
141 for index in 0..count {
142 let leave = match arcs[index] {
144 Some((centre, start, end, sweep)) => {
145 segments.push(arc_segment(centre, start, sweep));
146 let _ = end;
147 end
148 }
149 None => corners[index].point,
150 };
151 let next = (index + 1) % count;
152 let arrive = match arcs[next] {
153 Some((_, start, _, _)) => start,
154 None => corners[next].point,
155 };
156 if (arrive - leave).length() > 1e-15 {
159 segments.push(ProfileSegment {
160 curve: Curve2::Line(Line2 {
161 origin: leave,
162 direction: arrive - leave,
163 }),
164 domain: Interval::UNIT,
165 same_sense: true,
166 });
167 }
168 }
169 segments
170}
171
172fn arc_segment(centre: Point2, start: Point2, sweep: Scalar) -> ProfileSegment {
180 let x = (start - centre).normalize_or_zero();
181 let perpendicular = Vec2::new(-x.y, x.x);
182 let y = if sweep >= 0.0 {
183 perpendicular
184 } else {
185 -perpendicular
186 };
187 ProfileSegment {
188 curve: Curve2::Circle(Circle2 {
189 frame: Frame2 {
190 origin: centre,
191 x,
192 y,
193 },
194 radius: (start - centre).length(),
195 }),
196 domain: Interval::new(0.0, sweep.abs()),
197 same_sense: true,
198 }
199}
200
201fn checked_slope(slope: Option<Scalar>, what: &'static str) -> GeomResult<Scalar> {
215 let value = slope.unwrap_or(0.0);
216 if !value.is_finite() {
217 return Err(GeomError::InvalidInput(format!(
218 "{what} slope must be finite, got {value}"
219 )));
220 }
221 let limit = core::f64::consts::FRAC_PI_2 * 0.9;
224 if value.abs() >= limit {
225 return Err(GeomError::Degenerate(format!(
226 "{what} slope {value} rad is too steep to leave a flange"
227 )));
228 }
229 Ok(value)
230}
231
232fn positive(value: Scalar, what: &str) -> GeomResult<()> {
233 if !value.is_finite() || value <= 0.0 {
234 return Err(GeomError::InvalidInput(format!(
235 "section {what} must be positive and finite, got {value}"
236 )));
237 }
238 Ok(())
239}
240pub fn section_contour(section: &SectionProfile) -> GeomResult<ContourProfile> {
245 let corners = match section {
246 SectionProfile::I {
247 depth,
248 width,
249 web_thickness,
250 flange_thickness,
251 fillet_radius,
252 flange_edge_radius,
253 flange_slope,
254 } => {
255 let slope = checked_slope(*flange_slope, "I section")?;
256 i_corners(
257 *depth,
258 *width,
259 *width,
260 *web_thickness,
261 *flange_thickness,
262 *flange_thickness,
263 *fillet_radius,
264 *fillet_radius,
265 *flange_edge_radius,
266 *flange_edge_radius,
267 slope,
268 slope,
269 )?
270 }
271 SectionProfile::AsymmetricI {
272 depth,
273 web_thickness,
274 bottom_flange_width,
275 bottom_flange_thickness,
276 bottom_fillet_radius,
277 bottom_flange_edge_radius,
278 bottom_flange_slope,
279 top_flange_width,
280 top_flange_thickness,
281 top_fillet_radius,
282 top_flange_edge_radius,
283 top_flange_slope,
284 } => {
285 let bottom_slope = checked_slope(*bottom_flange_slope, "I section")?;
286 let top_slope = checked_slope(*top_flange_slope, "I section")?;
287 i_corners(
288 *depth,
289 *bottom_flange_width,
290 *top_flange_width,
291 *web_thickness,
292 *bottom_flange_thickness,
293 top_flange_thickness.unwrap_or(*bottom_flange_thickness),
296 *bottom_fillet_radius,
297 *top_fillet_radius,
298 *bottom_flange_edge_radius,
299 *top_flange_edge_radius,
300 bottom_slope,
301 top_slope,
302 )?
303 }
304 SectionProfile::T {
305 depth,
306 flange_width,
307 web_thickness,
308 flange_thickness,
309 fillet_radius,
310 flange_edge_radius,
311 web_edge_radius,
312 web_slope,
313 flange_slope,
314 } => {
315 let web = checked_slope(*web_slope, "T section web")?;
317 let flange = checked_slope(*flange_slope, "T section flange")?;
318 t_corners(
319 *depth,
320 *flange_width,
321 *web_thickness,
322 *flange_thickness,
323 &RadiiT {
324 fillet: *fillet_radius,
325 flange_edge: *flange_edge_radius,
326 web_edge: *web_edge_radius,
327 },
328 &TaperT { web, flange },
329 )?
330 }
331 SectionProfile::U {
332 depth,
333 flange_width,
334 web_thickness,
335 flange_thickness,
336 fillet_radius,
337 edge_radius,
338 flange_slope,
339 } => {
340 let slope = checked_slope(*flange_slope, "U section")?;
341 u_corners(
342 *depth,
343 *flange_width,
344 *web_thickness,
345 *flange_thickness,
346 *fillet_radius,
347 *edge_radius,
348 slope,
349 )?
350 }
351 SectionProfile::L {
352 depth,
353 width,
354 thickness,
355 fillet_radius,
356 edge_radius,
357 leg_slope,
358 } => {
359 let slope = checked_slope(*leg_slope, "L section")?;
360 l_corners(
361 *depth,
362 width.unwrap_or(*depth),
364 *thickness,
365 *fillet_radius,
366 *edge_radius,
367 slope,
368 )?
369 }
370 SectionProfile::Z {
371 depth,
372 flange_width,
373 web_thickness,
374 flange_thickness,
375 fillet_radius,
376 edge_radius,
377 } => z_corners(
378 *depth,
379 *flange_width,
380 *web_thickness,
381 *flange_thickness,
382 *fillet_radius,
383 *edge_radius,
384 )?,
385 SectionProfile::C {
386 depth,
387 width,
388 wall_thickness,
389 girth,
390 internal_fillet_radius,
391 } => c_corners(
392 *depth,
393 *width,
394 *wall_thickness,
395 *girth,
396 *internal_fillet_radius,
397 )?,
398 SectionProfile::Trapezium {
399 bottom_x,
400 top_x,
401 y,
402 top_offset,
403 } => trapezium_corners(*bottom_x, *top_x, *y, *top_offset)?,
404 _ => return Err(unsupported("section profile of an unsupported kind")),
405 };
406
407 Ok(ContourProfile {
408 outer: route(&corners)?,
409 holes: Vec::new(),
410 })
411}
412#[allow(clippy::too_many_arguments)]
418fn i_corners(
419 depth: Scalar,
420 bottom_width: Scalar,
421 top_width: Scalar,
422 web_thickness: Scalar,
423 bottom_flange: Scalar,
424 top_flange: Scalar,
425 bottom_fillet: Option<Scalar>,
426 top_fillet: Option<Scalar>,
427 bottom_edge: Option<Scalar>,
428 top_edge: Option<Scalar>,
429 bottom_slope: Scalar,
430 top_slope: Scalar,
431) -> GeomResult<Vec<Corner>> {
432 positive(depth, "depth")?;
433 positive(bottom_width, "flange width")?;
434 positive(top_width, "flange width")?;
435 positive(web_thickness, "web thickness")?;
436 positive(bottom_flange, "flange thickness")?;
437 positive(top_flange, "flange thickness")?;
438 if bottom_flange + top_flange >= depth {
439 return Err(GeomError::Degenerate(format!(
440 "flanges {bottom_flange} + {top_flange} leave no web in depth {depth}"
441 )));
442 }
443 if web_thickness >= bottom_width.min(top_width) {
444 return Err(GeomError::Degenerate(format!(
445 "web thickness {web_thickness} is not narrower than the flange"
446 )));
447 }
448
449 let (hd, hw) = (depth / 2.0, web_thickness / 2.0);
450 let (hb, ht) = (bottom_width / 2.0, top_width / 2.0);
451 let bottom_top = -hd + bottom_flange;
452 let top_bottom = hd - top_flange;
453
454 let bottom_rise = |x: Scalar| (x - (hw + hb) / 2.0) * bottom_slope.tan();
460 let top_rise = |x: Scalar| (x - (hw + ht) / 2.0) * top_slope.tan();
461
462 Ok(vec![
463 sharp(hb, -hd),
464 rounded(hb, bottom_top - bottom_rise(hb), bottom_edge),
465 rounded(hw, bottom_top - bottom_rise(hw), bottom_fillet),
466 rounded(hw, top_bottom + top_rise(hw), top_fillet),
467 rounded(ht, top_bottom + top_rise(ht), top_edge),
468 sharp(ht, hd),
469 sharp(-ht, hd),
470 rounded(-ht, top_bottom + top_rise(ht), top_edge),
471 rounded(-hw, top_bottom + top_rise(hw), top_fillet),
472 rounded(-hw, bottom_top - bottom_rise(hw), bottom_fillet),
473 rounded(-hb, bottom_top - bottom_rise(hb), bottom_edge),
474 sharp(-hb, -hd),
475 ])
476}
477
478struct RadiiT {
486 fillet: Option<Scalar>,
487 flange_edge: Option<Scalar>,
488 web_edge: Option<Scalar>,
489}
490
491struct TaperT {
492 web: Scalar,
493 flange: Scalar,
494}
495
496fn t_corners(
497 depth: Scalar,
498 flange_width: Scalar,
499 web_thickness: Scalar,
500 flange_thickness: Scalar,
501 radii: &RadiiT,
502 taper: &TaperT,
503) -> GeomResult<Vec<Corner>> {
504 let (web_slope, flange_slope) = (taper.web, taper.flange);
505 let (fillet, flange_edge, web_edge) = (radii.fillet, radii.flange_edge, radii.web_edge);
506 positive(depth, "depth")?;
507 positive(flange_width, "flange width")?;
508 positive(web_thickness, "web thickness")?;
509 positive(flange_thickness, "flange thickness")?;
510 if flange_thickness >= depth {
511 return Err(GeomError::Degenerate(format!(
512 "flange {flange_thickness} leaves no web in depth {depth}"
513 )));
514 }
515 if web_thickness >= flange_width {
516 return Err(GeomError::Degenerate(format!(
517 "web thickness {web_thickness} is not narrower than the flange"
518 )));
519 }
520
521 let (hd, hw, hf) = (depth / 2.0, web_thickness / 2.0, flange_width / 2.0);
522 let flange_bottom = hd - flange_thickness;
523
524 let flange_rise = |x: Scalar| (x - (hw + hf) / 2.0) * flange_slope.tan();
528 let web_mid = (-hd + flange_bottom) / 2.0;
529 let web_out = |y: Scalar| (y - web_mid) * web_slope.tan();
530
531 Ok(vec![
532 rounded(hw + web_out(-hd), -hd, web_edge),
533 rounded(hw + web_out(flange_bottom), flange_bottom, fillet),
534 rounded(hf, flange_bottom - flange_rise(hf), flange_edge),
535 sharp(hf, hd),
536 sharp(-hf, hd),
537 rounded(-hf, flange_bottom - flange_rise(hf), flange_edge),
538 rounded(-hw - web_out(flange_bottom), flange_bottom, fillet),
539 rounded(-hw - web_out(-hd), -hd, web_edge),
540 ])
541}
542
543fn u_corners(
545 depth: Scalar,
546 flange_width: Scalar,
547 web_thickness: Scalar,
548 flange_thickness: Scalar,
549 fillet: Option<Scalar>,
550 edge: Option<Scalar>,
551 flange_slope: Scalar,
552) -> GeomResult<Vec<Corner>> {
553 positive(depth, "depth")?;
554 positive(flange_width, "flange width")?;
555 positive(web_thickness, "web thickness")?;
556 positive(flange_thickness, "flange thickness")?;
557 if 2.0 * flange_thickness >= depth {
558 return Err(GeomError::Degenerate(format!(
559 "flanges {flange_thickness} leave no web in depth {depth}"
560 )));
561 }
562 if web_thickness >= flange_width {
563 return Err(GeomError::Degenerate(format!(
564 "web thickness {web_thickness} is not narrower than the flange"
565 )));
566 }
567
568 let hd = depth / 2.0;
569 let inner = web_thickness;
570 let rise = |x: Scalar| (x - (inner + flange_width) / 2.0) * flange_slope.tan();
573
574 Ok(vec![
575 sharp(flange_width, -hd),
576 rounded(
577 flange_width,
578 -hd + flange_thickness - rise(flange_width),
579 edge,
580 ),
581 rounded(inner, -hd + flange_thickness - rise(inner), fillet),
582 rounded(inner, hd - flange_thickness + rise(inner), fillet),
583 rounded(
584 flange_width,
585 hd - flange_thickness + rise(flange_width),
586 edge,
587 ),
588 sharp(flange_width, hd),
589 sharp(0.0, hd),
590 sharp(0.0, -hd),
591 ])
592}
593
594fn l_corners(
596 depth: Scalar,
597 width: Scalar,
598 thickness: Scalar,
599 fillet: Option<Scalar>,
600 edge: Option<Scalar>,
601 leg_slope: Scalar,
602) -> GeomResult<Vec<Corner>> {
603 positive(depth, "depth")?;
604 positive(width, "width")?;
605 positive(thickness, "thickness")?;
606 if thickness >= depth.min(width) {
607 return Err(GeomError::Degenerate(format!(
608 "thickness {thickness} is not thinner than the legs"
609 )));
610 }
611
612 let tan = leg_slope.tan();
615 let horizontal = |x: Scalar| (x - (thickness + width) / 2.0) * tan;
616 let vertical = |y: Scalar| (y - (thickness + depth) / 2.0) * tan;
617
618 Ok(vec![
619 sharp(0.0, 0.0),
620 sharp(width, 0.0),
621 rounded(width, thickness - horizontal(width), edge),
622 rounded(thickness, thickness, fillet),
623 rounded(thickness - vertical(depth), depth, edge),
624 sharp(0.0, depth),
625 ])
626}
627fn z_corners(
629 depth: Scalar,
630 flange_width: Scalar,
631 web_thickness: Scalar,
632 flange_thickness: Scalar,
633 fillet: Option<Scalar>,
634 edge: Option<Scalar>,
635) -> GeomResult<Vec<Corner>> {
636 positive(depth, "depth")?;
637 positive(flange_width, "flange width")?;
638 positive(web_thickness, "web thickness")?;
639 positive(flange_thickness, "flange thickness")?;
640 if 2.0 * flange_thickness >= depth {
641 return Err(GeomError::Degenerate(format!(
642 "flanges {flange_thickness} leave no web in depth {depth}"
643 )));
644 }
645
646 let (hd, hw) = (depth / 2.0, web_thickness / 2.0);
647 let bottom_top = -hd + flange_thickness;
648 let top_bottom = hd - flange_thickness;
649
650 Ok(vec![
652 sharp(hw + flange_width, -hd),
653 rounded(hw + flange_width, bottom_top, edge),
654 rounded(hw, bottom_top, fillet),
655 sharp(hw, hd),
656 sharp(-hw - flange_width, hd),
657 rounded(-hw - flange_width, top_bottom, edge),
658 rounded(-hw, top_bottom, fillet),
659 sharp(-hw, -hd),
660 ])
661}
662
663fn c_corners(
669 depth: Scalar,
670 width: Scalar,
671 wall_thickness: Scalar,
672 girth: Scalar,
673 fillet: Option<Scalar>,
674) -> GeomResult<Vec<Corner>> {
675 positive(depth, "depth")?;
676 positive(width, "width")?;
677 positive(wall_thickness, "wall thickness")?;
678 positive(girth, "girth")?;
679 if 2.0 * wall_thickness >= depth || 2.0 * wall_thickness >= width {
680 return Err(GeomError::Degenerate(format!(
681 "wall thickness {wall_thickness} leaves no opening"
682 )));
683 }
684 if girth <= wall_thickness {
685 return Err(GeomError::Degenerate(format!(
686 "lip girth {girth} is not longer than the wall thickness"
687 )));
688 }
689
690 let hd = depth / 2.0;
691 let t = wall_thickness;
692
693 Ok(vec![
694 sharp(width, -hd),
696 sharp(width, -hd + girth),
697 sharp(width - t, -hd + girth),
698 rounded(width - t, -hd + t, fillet),
699 rounded(t, -hd + t, fillet),
700 rounded(t, hd - t, fillet),
701 rounded(width - t, hd - t, fillet),
702 sharp(width - t, hd - girth),
703 sharp(width, hd - girth),
704 sharp(width, hd),
705 sharp(0.0, hd),
706 sharp(0.0, -hd),
707 ])
708}
709
710fn trapezium_corners(
712 bottom_x: Scalar,
713 top_x: Scalar,
714 y: Scalar,
715 top_offset: Scalar,
716) -> GeomResult<Vec<Corner>> {
717 positive(bottom_x, "bottom width")?;
718 positive(top_x, "top width")?;
719 positive(y, "height")?;
720 if !top_offset.is_finite() {
721 return Err(GeomError::InvalidInput(format!(
722 "trapezium top offset must be finite, got {top_offset}"
723 )));
724 }
725
726 Ok(vec![
727 sharp(0.0, 0.0),
728 sharp(bottom_x, 0.0),
729 sharp(top_offset + top_x, y),
730 sharp(top_offset, y),
731 ])
732}
733
734#[cfg(test)]
735mod tests {
736 use super::*;
737
738 #[test]
750 fn a_non_right_corner_is_rounded_tangentially() {
751 let radius = 0.02;
753 let corners = vec![
754 sharp(0.0, 0.0),
755 Corner {
756 point: Point2::new(0.4, 0.0),
757 radius,
758 },
759 sharp(0.3, 0.2),
760 sharp(0.05, 0.2),
761 ];
762 let contour = route(&corners).expect("a trapezoidal ring routes");
763
764 let arc = contour
765 .segments
766 .iter()
767 .find_map(|segment| match &segment.curve {
768 Curve2::Circle(circle) => Some(*circle),
769 _ => None,
770 })
771 .expect("the rounded corner produced an arc");
772 assert!(
773 (arc.radius - radius).abs() < 1e-12,
774 "arc must carry the stated radius, got {}",
775 arc.radius
776 );
777
778 let distance_to_line = |a: Point2, b: Point2| {
782 let along = (b - a).normalize();
783 let normal = Vec2::new(-along.y, along.x);
784 (arc.frame.origin - a).dot(normal).abs()
785 };
786 let incoming = distance_to_line(Point2::new(0.0, 0.0), Point2::new(0.4, 0.0));
787 let outgoing = distance_to_line(Point2::new(0.4, 0.0), Point2::new(0.3, 0.2));
788 assert!(
789 (incoming - radius).abs() < 1e-12,
790 "arc must be tangent to the incoming edge, distance {incoming}"
791 );
792 assert!(
793 (outgoing - radius).abs() < 1e-12,
794 "arc must be tangent to the outgoing edge, distance {outgoing}"
795 );
796 }
797}