Skip to main content

axiolid_construct/
section_lower.rs

1//! Lower parameterised structural sections into exact contours (ADR 0057).
2//!
3//! # Why the fillets are not optional
4//!
5//! For a rolled steel section the web-to-flange root fillet is real material.
6//! Measured on a 0.4 x 0.3 I-section with an 0.021 root radius, the four
7//! fillets carry **2.40%** of the cross-sectional area. Dropping them yields a
8//! section that looks correct and whose area, second moment and mass are all
9//! wrong, so they are built as exact arcs rather than ignored or approximated.
10//!
11//! # Structure
12//!
13//! Every variant reduces to a closed counter-clockwise ring of corners, each
14//! carrying an optional radius. One shared router turns that into a contour,
15//! inserting a tangent arc at each rounded corner. Concave root fillets and
16//! convex toe radii are the SAME operation -- the sign of the turn decides
17//! which way the arc bends -- so neither gets a special case that could drift
18//! from the other.
19
20use 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/// One boundary corner: a position and the radius rounding it.
38#[derive(Debug, Clone, Copy)]
39struct Corner {
40    point: Point2,
41    /// Zero means a sharp corner.
42    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        // `None` means the source did not state a radius, which is a sharp
56        // corner here; `Some(0.0)` states one explicitly and agrees.
57        radius: radius.unwrap_or(0.0).max(0.0),
58    }
59}
60
61/// Turn a closed counter-clockwise corner ring into an exact contour.
62///
63/// At each corner with a positive radius the boundary is cut back along both
64/// adjacent edges by `r * tan(alpha / 2)`, where `alpha` is the turn angle,
65/// and joined by an arc tangent to both. That setback is what makes the arc
66/// tangent rather than merely near the corner.
67fn 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    // Setback and arc geometry per corner, or `None` when sharp.
76    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            // Collinear: there is no corner to round.
96            continue;
97        }
98        let turn = incoming.dot(outgoing).clamp(-1.0, 1.0).acos();
99        let setback = here.radius * (turn / 2.0).tan();
100        // Distance from the corner to the arc centre along the interior
101        // bisector.
102        //
103        // `turn` is the EXTERIOR deflection, so the interior angle is
104        // `pi - turn` and the centre sits `r / sin(interior / 2)` away, which
105        // is `r / cos(turn / 2)`. Using `sin(turn / 2)` here is wrong
106        // everywhere EXCEPT at a right angle, where the two coincide -- and
107        // every rounded corner in every section variant is a right angle, so
108        // the error is invisible to them.
109        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    // Both ends of an edge draw from the same edge length.
123    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}
135/// Emit segments: a straight run between consecutive corners, plus an arc at
136/// each rounded corner.
137fn 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        // Where this corner hands over to the straight run that follows.
145        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        // A fully consumed edge leaves the two arcs touching; emitting a
159        // zero-length line there would be a degenerate segment.
160        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
174/// A tangent arc from `start`, about `centre`, turning by `sweep`.
175///
176/// The frame's x-axis points at the arc start, so the segment domain begins
177/// at zero. A negative sweep is carried by a LEFT-handed frame rather than a
178/// negative domain, matching the convention the contour lowering already
179/// uses: the parameter always increases, and handedness says which way the
180/// world turn goes.
181fn 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
203/// Reject a stated taper.
204///
205/// Validate a declared taper and return it as an angle.
206///
207/// A slope is an angle from the horizontal, so it must stay well inside a
208/// quarter turn: at a right angle the inner face would be parallel to the web
209/// and the section would have no flange at all. The bound is deliberately
210/// generous -- rolled sections taper by 5 to 14 degrees -- because the job
211/// here is to exclude nonsense, not to second-guess a source that states an
212/// unusual but buildable value.
213///
214/// `None` means the source did not state a taper, which is a parallel flange.
215/// `Some(0.0)` states one explicitly and gives the same geometry.
216fn 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    // A quarter turn is the hard limit; stop short of it so the tangent stays
224    // usable rather than exploding.
225    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
234/// A circle profile, and its bore when it has a wall thickness, as exact
235/// contours of four quarter arcs each (#111).
236///
237/// Four quarters, not one full turn: contour lowering refuses a segment of
238/// half a turn or more (ADR 0053), and a quarter keeps every arc's endpoints
239/// on the axes, where the seams of revolved and extruded walls sit.
240pub 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
281/// Lower a rectangle -- rounded, hollow, or both -- into an exact contour.
282///
283/// Corner radii become exact quarter arcs through the same router the
284/// structural sections use, so a rounded corner extrudes to a cylinder wall
285/// rather than a fan of chords. A hollow rectangle's inner boundary comes
286/// back as a hole, built counter-clockwise like the outer ring; the extruder
287/// re-orients holes itself.
288///
289/// Refuses, rather than clamps, a radius that is negative, non-finite, or
290/// wider than the half-extent it rounds, and an inner radius on a filled
291/// rectangle.
292///
293/// Also refuses a hollow section whose corners leave no wall. Two rounded
294/// rectangles nest exactly when their support functions do; along the corner
295/// diagonal that requires `outer - inner < (2 + sqrt 2) * thickness`. Past
296/// that the inner corner reaches the outer arc and the profile crosses itself.
297pub 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
337/// Counter-clockwise corners of an origin-centred box, all rounded alike.
338fn 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
348/// Validate a stated corner radius; `None` is a sharp corner.
349fn 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}
367/// Lower a parameterised section into an exact contour.
368///
369/// The outline is built counter-clockwise about the section centroid-ish
370/// origin each source entity declares, so the result needs no re-orientation.
371pub 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                // The source may omit the top thickness, meaning "same as
421                // the bottom" rather than "zero".
422                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            // A T's web taper and flange taper are independent faces.
443            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                // An absent width means an EQUAL angle, not a zero one.
490                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/// Twelve-corner I outline, walked counter-clockwise from the bottom-right.
540///
541/// Handles the asymmetric case directly; the symmetric variant passes equal
542/// top and bottom dimensions rather than going through a separate routine
543/// that could drift from this one.
544#[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    // A tapered flange's inner face is inclined, so its height depends on x.
582    // The face pivots about the MID-POINT between the web face and the flange
583    // tip, which keeps `flange_thickness` the MEAN thickness -- the value
584    // section tables state. Pivoting about the tip or the web instead would
585    // silently change the declared thickness and with it the area.
586    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
605/// Eight-corner T outline: flange on top, web hanging below.
606/// The two independent tapers a T section can declare.
607///
608/// Grouped rather than passed loose so the web angle cannot be handed to the
609/// flange by accident: the two are the same type and adjacent in the argument
610/// list, which is exactly the shape of a silent swap.
611/// The three optional radii a T section can declare.
612struct 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    // Flange underside inclines about the mid-point between web face and tip,
652    // keeping `flange_thickness` the mean. The web's side faces incline about
653    // the mid-height of the exposed web run for the same reason.
654    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
670/// Eight-corner U (channel) outline: web on the left, flanges to the right.
671fn 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    // Same convention as the I: pivot about the mid-point of the inner face so
698    // the stated flange thickness stays the mean.
699    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
721/// Six-corner L (angle) outline with the heel at the origin.
722fn 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    // Each leg's inner face inclines about the mid-point of its run, so the
740    // stated thickness remains the mean thickness of the leg.
741    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}
754/// Eight-corner Z outline: flanges point in opposite directions.
755fn 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    // Bottom flange runs right, top flange runs left.
778    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
790/// Twelve-corner C (lipped channel) outline.
791///
792/// Unlike `U`, this is a THIN-WALLED section: the boundary follows the wall
793/// all the way round, including the returned lips, so the enclosed area is
794/// the wall material rather than the full channel envelope.
795fn 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        // Outer boundary: up the web, out along each flange, down each lip.
822        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
837/// Four-corner trapezium.
838fn 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    /// The corner router must round a NON-right corner correctly.
866    ///
867    /// Every rounded corner reachable through `SectionProfile` today is a
868    /// right angle, and at 90 degrees `r * tan(45) == r`, so a setback that
869    /// ignored the turn angle would be indistinguishable there. The router is
870    /// general, so its generality is tested directly rather than left to a
871    /// variant that happens not to exercise it.
872    ///
873    /// The invariant checked is TANGENCY: the arc must meet both adjacent
874    /// edges at a point whose distance to the arc centre equals the radius,
875    /// and the centre must sit exactly `radius` from each edge line.
876    #[test]
877    fn a_non_right_corner_is_rounded_tangentially() {
878        // A 116.565-degree turn: setback is r*tan(58.28) = 1.618 r, not r.
879        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        // Distance from the arc centre to each adjacent edge LINE must equal
906        // the radius. That is tangency, and it holds only for the correct
907        // setback.
908        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}