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: a contour needs at least two segments,
238/// and a quarter keeps every arc's endpoints on the axes, where the seams of
239/// 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. A radius past its half-extent by rounding alone, at most four
292/// relative epsilons of the smaller outer half-extent, states the fully
293/// rounded corner and is snapped onto the limit (#339); one wider than that
294/// is still refused.
295///
296/// Also refuses a hollow section whose corners leave no wall. Two rounded
297/// rectangles nest exactly when their support functions do; along the corner
298/// diagonal that requires `outer - inner < (2 + sqrt 2) * thickness`. Past
299/// that the inner corner reaches the outer arc and the profile crosses itself.
300pub fn rectangle_contour(rectangle: &RectangleProfile) -> GeomResult<ContourProfile> {
301    positive(rectangle.x, "rectangle x extent")?;
302    positive(rectangle.y, "rectangle y extent")?;
303    let (hx, hy) = (rectangle.x / 2.0, rectangle.y / 2.0);
304    // Both limits are cut from the smaller half-extent, so its magnitude is
305    // the scale their rounding lives at (the inner limit's axis is the
306    // outer's, since `ix - iy == hx - hy`).
307    let scale = hx.min(hy);
308    let outer_radius = corner_radius(rectangle.outer_radius, scale, scale, "outer")?;
309    let outer = route(&box_corners(hx, hy, outer_radius))?;
310    let Some(thickness) = rectangle.thickness else {
311        if rectangle.inner_radius.is_some() {
312            return Err(GeomError::InvalidInput(
313                "an inner corner radius needs a hollow rectangle".to_owned(),
314            ));
315        }
316        return Ok(ContourProfile {
317            outer,
318            holes: Vec::new(),
319        });
320    };
321    positive(thickness, "rectangle wall thickness")?;
322    if 2.0 * thickness >= rectangle.x.min(rectangle.y) {
323        return Err(GeomError::Degenerate(format!(
324            "wall thickness {thickness} leaves no opening in {} x {}",
325            rectangle.x, rectangle.y
326        )));
327    }
328    let (ix, iy) = (hx - thickness, hy - thickness);
329    let inner_radius = corner_radius(rectangle.inner_radius, ix.min(iy), scale, "inner")?;
330    let wall_limit = (2.0 + core::f64::consts::SQRT_2) * thickness;
331    if outer_radius - inner_radius >= wall_limit {
332        return Err(GeomError::Degenerate(format!(
333            "outer radius {outer_radius} minus inner radius {inner_radius} must stay \
334             below {wall_limit} or the {thickness}-thick wall vanishes at the corners"
335        )));
336    }
337    let hole = route(&box_corners(ix, iy, inner_radius))?;
338    Ok(ContourProfile {
339        outer,
340        holes: vec![hole],
341    })
342}
343
344/// Counter-clockwise corners of an origin-centred box, all rounded alike.
345fn box_corners(hx: Scalar, hy: Scalar, radius: Scalar) -> [Corner; 4] {
346    let radius = Some(radius);
347    [
348        rounded(-hx, -hy, radius),
349        rounded(hx, -hy, radius),
350        rounded(hx, hy, radius),
351        rounded(-hx, hy, radius),
352    ]
353}
354
355/// How far past its limit, in relative epsilons of the half-extent the
356/// limit is cut from, a corner radius may round and still state the fully
357/// rounded corner (#339).
358///
359/// A radius and the extents it is checked against are converted from the
360/// source units separately, and the inner limit is a difference
361/// `x / 2 - thickness`, so a radius equal to its half-extent in the source
362/// lands up to about one unit in the last place of that half-extent above
363/// it (24 x 24 in, 2 in wall, 10 in inner fillet, in metres: `0.254`
364/// against `0.25399999999999995`, 0.82 epsilons of `0.3048`). Four
365/// epsilons cover that with a margin and are still rounding: the same kind
366/// of relative reach as the boolean snap's (#276, #291), never a modelling
367/// tolerance.
368const RADIUS_REACH_EPSILONS: Scalar = 4.0;
369
370/// Validate a stated corner radius; `None` is a sharp corner.
371///
372/// `limit` is the widest radius the corner admits and `scale` the magnitude
373/// of the half-extent it is cut from. A radius above the limit by at most
374/// [`RADIUS_REACH_EPSILONS`] relative epsilons of `scale` is rounding, and
375/// is snapped onto the limit so the arcs meet exactly; one wider than that
376/// is refused by name, never clamped.
377fn corner_radius(
378    radius: Option<Scalar>,
379    limit: Scalar,
380    scale: Scalar,
381    which: &str,
382) -> GeomResult<Scalar> {
383    let value = radius.unwrap_or(0.0);
384    let reach = RADIUS_REACH_EPSILONS * Scalar::EPSILON * scale;
385    if !value.is_finite() || value < 0.0 || value > limit + reach {
386        return Err(GeomError::InvalidInput(format!(
387            "{which} corner radius must lie in [0, {limit}], got {value}"
388        )));
389    }
390    Ok(value.min(limit))
391}
392
393fn positive(value: Scalar, what: &str) -> GeomResult<()> {
394    if !value.is_finite() || value <= 0.0 {
395        return Err(GeomError::InvalidInput(format!(
396            "section {what} must be positive and finite, got {value}"
397        )));
398    }
399    Ok(())
400}
401/// Lower a parameterised section into an exact contour.
402///
403/// The outline is built counter-clockwise about the section centroid-ish
404/// origin each source entity declares, so the result needs no re-orientation.
405pub fn section_contour(section: &SectionProfile) -> GeomResult<ContourProfile> {
406    let corners = match section {
407        SectionProfile::I {
408            depth,
409            width,
410            web_thickness,
411            flange_thickness,
412            fillet_radius,
413            flange_edge_radius,
414            flange_slope,
415        } => {
416            let slope = checked_slope(*flange_slope, "I section")?;
417            i_corners(
418                *depth,
419                *width,
420                *width,
421                *web_thickness,
422                *flange_thickness,
423                *flange_thickness,
424                *fillet_radius,
425                *fillet_radius,
426                *flange_edge_radius,
427                *flange_edge_radius,
428                slope,
429                slope,
430            )?
431        }
432        SectionProfile::AsymmetricI {
433            depth,
434            web_thickness,
435            bottom_flange_width,
436            bottom_flange_thickness,
437            bottom_fillet_radius,
438            bottom_flange_edge_radius,
439            bottom_flange_slope,
440            top_flange_width,
441            top_flange_thickness,
442            top_fillet_radius,
443            top_flange_edge_radius,
444            top_flange_slope,
445        } => {
446            let bottom_slope = checked_slope(*bottom_flange_slope, "I section")?;
447            let top_slope = checked_slope(*top_flange_slope, "I section")?;
448            i_corners(
449                *depth,
450                *bottom_flange_width,
451                *top_flange_width,
452                *web_thickness,
453                *bottom_flange_thickness,
454                // The source may omit the top thickness, meaning "same as
455                // the bottom" rather than "zero".
456                top_flange_thickness.unwrap_or(*bottom_flange_thickness),
457                *bottom_fillet_radius,
458                *top_fillet_radius,
459                *bottom_flange_edge_radius,
460                *top_flange_edge_radius,
461                bottom_slope,
462                top_slope,
463            )?
464        }
465        SectionProfile::T {
466            depth,
467            flange_width,
468            web_thickness,
469            flange_thickness,
470            fillet_radius,
471            flange_edge_radius,
472            web_edge_radius,
473            web_slope,
474            flange_slope,
475        } => {
476            // A T's web taper and flange taper are independent faces.
477            let web = checked_slope(*web_slope, "T section web")?;
478            let flange = checked_slope(*flange_slope, "T section flange")?;
479            t_corners(
480                *depth,
481                *flange_width,
482                *web_thickness,
483                *flange_thickness,
484                &RadiiT {
485                    fillet: *fillet_radius,
486                    flange_edge: *flange_edge_radius,
487                    web_edge: *web_edge_radius,
488                },
489                &TaperT { web, flange },
490            )?
491        }
492        SectionProfile::U {
493            depth,
494            flange_width,
495            web_thickness,
496            flange_thickness,
497            fillet_radius,
498            edge_radius,
499            flange_slope,
500        } => {
501            let slope = checked_slope(*flange_slope, "U section")?;
502            u_corners(
503                *depth,
504                *flange_width,
505                *web_thickness,
506                *flange_thickness,
507                *fillet_radius,
508                *edge_radius,
509                slope,
510            )?
511        }
512        SectionProfile::L {
513            depth,
514            width,
515            thickness,
516            fillet_radius,
517            edge_radius,
518            leg_slope,
519        } => {
520            let slope = checked_slope(*leg_slope, "L section")?;
521            l_corners(
522                *depth,
523                // An absent width means an EQUAL angle, not a zero one.
524                width.unwrap_or(*depth),
525                *thickness,
526                *fillet_radius,
527                *edge_radius,
528                slope,
529            )?
530        }
531        SectionProfile::Z {
532            depth,
533            flange_width,
534            web_thickness,
535            flange_thickness,
536            fillet_radius,
537            edge_radius,
538        } => z_corners(
539            *depth,
540            *flange_width,
541            *web_thickness,
542            *flange_thickness,
543            *fillet_radius,
544            *edge_radius,
545        )?,
546        SectionProfile::C {
547            depth,
548            width,
549            wall_thickness,
550            girth,
551            internal_fillet_radius,
552        } => c_corners(
553            *depth,
554            *width,
555            *wall_thickness,
556            *girth,
557            *internal_fillet_radius,
558        )?,
559        SectionProfile::Trapezium {
560            bottom_x,
561            top_x,
562            y,
563            top_offset,
564        } => trapezium_corners(*bottom_x, *top_x, *y, *top_offset)?,
565        _ => return Err(unsupported("section profile of an unsupported kind")),
566    };
567
568    Ok(ContourProfile {
569        outer: route(&corners)?,
570        holes: Vec::new(),
571    })
572}
573/// Twelve-corner I outline, walked counter-clockwise from the bottom-right.
574///
575/// Handles the asymmetric case directly; the symmetric variant passes equal
576/// top and bottom dimensions rather than going through a separate routine
577/// that could drift from this one.
578#[allow(clippy::too_many_arguments)]
579fn i_corners(
580    depth: Scalar,
581    bottom_width: Scalar,
582    top_width: Scalar,
583    web_thickness: Scalar,
584    bottom_flange: Scalar,
585    top_flange: Scalar,
586    bottom_fillet: Option<Scalar>,
587    top_fillet: Option<Scalar>,
588    bottom_edge: Option<Scalar>,
589    top_edge: Option<Scalar>,
590    bottom_slope: Scalar,
591    top_slope: Scalar,
592) -> GeomResult<Vec<Corner>> {
593    positive(depth, "depth")?;
594    positive(bottom_width, "flange width")?;
595    positive(top_width, "flange width")?;
596    positive(web_thickness, "web thickness")?;
597    positive(bottom_flange, "flange thickness")?;
598    positive(top_flange, "flange thickness")?;
599    if bottom_flange + top_flange >= depth {
600        return Err(GeomError::Degenerate(format!(
601            "flanges {bottom_flange} + {top_flange} leave no web in depth {depth}"
602        )));
603    }
604    if web_thickness >= bottom_width.min(top_width) {
605        return Err(GeomError::Degenerate(format!(
606            "web thickness {web_thickness} is not narrower than the flange"
607        )));
608    }
609
610    let (hd, hw) = (depth / 2.0, web_thickness / 2.0);
611    let (hb, ht) = (bottom_width / 2.0, top_width / 2.0);
612    let bottom_top = -hd + bottom_flange;
613    let top_bottom = hd - top_flange;
614
615    // A tapered flange's inner face is inclined, so its height depends on x.
616    // The face pivots about the MID-POINT between the web face and the flange
617    // tip, which keeps `flange_thickness` the MEAN thickness -- the value
618    // section tables state. Pivoting about the tip or the web instead would
619    // silently change the declared thickness and with it the area.
620    let bottom_rise = |x: Scalar| (x - (hw + hb) / 2.0) * bottom_slope.tan();
621    let top_rise = |x: Scalar| (x - (hw + ht) / 2.0) * top_slope.tan();
622
623    Ok(vec![
624        sharp(hb, -hd),
625        rounded(hb, bottom_top - bottom_rise(hb), bottom_edge),
626        rounded(hw, bottom_top - bottom_rise(hw), bottom_fillet),
627        rounded(hw, top_bottom + top_rise(hw), top_fillet),
628        rounded(ht, top_bottom + top_rise(ht), top_edge),
629        sharp(ht, hd),
630        sharp(-ht, hd),
631        rounded(-ht, top_bottom + top_rise(ht), top_edge),
632        rounded(-hw, top_bottom + top_rise(hw), top_fillet),
633        rounded(-hw, bottom_top - bottom_rise(hw), bottom_fillet),
634        rounded(-hb, bottom_top - bottom_rise(hb), bottom_edge),
635        sharp(-hb, -hd),
636    ])
637}
638
639/// Eight-corner T outline: flange on top, web hanging below.
640/// The two independent tapers a T section can declare.
641///
642/// Grouped rather than passed loose so the web angle cannot be handed to the
643/// flange by accident: the two are the same type and adjacent in the argument
644/// list, which is exactly the shape of a silent swap.
645/// The three optional radii a T section can declare.
646struct RadiiT {
647    fillet: Option<Scalar>,
648    flange_edge: Option<Scalar>,
649    web_edge: Option<Scalar>,
650}
651
652struct TaperT {
653    web: Scalar,
654    flange: Scalar,
655}
656
657fn t_corners(
658    depth: Scalar,
659    flange_width: Scalar,
660    web_thickness: Scalar,
661    flange_thickness: Scalar,
662    radii: &RadiiT,
663    taper: &TaperT,
664) -> GeomResult<Vec<Corner>> {
665    let (web_slope, flange_slope) = (taper.web, taper.flange);
666    let (fillet, flange_edge, web_edge) = (radii.fillet, radii.flange_edge, radii.web_edge);
667    positive(depth, "depth")?;
668    positive(flange_width, "flange width")?;
669    positive(web_thickness, "web thickness")?;
670    positive(flange_thickness, "flange thickness")?;
671    if flange_thickness >= depth {
672        return Err(GeomError::Degenerate(format!(
673            "flange {flange_thickness} leaves no web in depth {depth}"
674        )));
675    }
676    if web_thickness >= flange_width {
677        return Err(GeomError::Degenerate(format!(
678            "web thickness {web_thickness} is not narrower than the flange"
679        )));
680    }
681
682    let (hd, hw, hf) = (depth / 2.0, web_thickness / 2.0, flange_width / 2.0);
683    let flange_bottom = hd - flange_thickness;
684
685    // Flange underside inclines about the mid-point between web face and tip,
686    // keeping `flange_thickness` the mean. The web's side faces incline about
687    // the mid-height of the exposed web run for the same reason.
688    let flange_rise = |x: Scalar| (x - (hw + hf) / 2.0) * flange_slope.tan();
689    let web_mid = (-hd + flange_bottom) / 2.0;
690    let web_out = |y: Scalar| (y - web_mid) * web_slope.tan();
691
692    Ok(vec![
693        rounded(hw + web_out(-hd), -hd, web_edge),
694        rounded(hw + web_out(flange_bottom), flange_bottom, fillet),
695        rounded(hf, flange_bottom - flange_rise(hf), flange_edge),
696        sharp(hf, hd),
697        sharp(-hf, hd),
698        rounded(-hf, flange_bottom - flange_rise(hf), flange_edge),
699        rounded(-hw - web_out(flange_bottom), flange_bottom, fillet),
700        rounded(-hw - web_out(-hd), -hd, web_edge),
701    ])
702}
703
704/// Eight-corner U (channel) outline: web on the left, flanges to the right.
705fn u_corners(
706    depth: Scalar,
707    flange_width: Scalar,
708    web_thickness: Scalar,
709    flange_thickness: Scalar,
710    fillet: Option<Scalar>,
711    edge: Option<Scalar>,
712    flange_slope: Scalar,
713) -> GeomResult<Vec<Corner>> {
714    positive(depth, "depth")?;
715    positive(flange_width, "flange width")?;
716    positive(web_thickness, "web thickness")?;
717    positive(flange_thickness, "flange thickness")?;
718    if 2.0 * flange_thickness >= depth {
719        return Err(GeomError::Degenerate(format!(
720            "flanges {flange_thickness} leave no web in depth {depth}"
721        )));
722    }
723    if web_thickness >= flange_width {
724        return Err(GeomError::Degenerate(format!(
725            "web thickness {web_thickness} is not narrower than the flange"
726        )));
727    }
728
729    let hd = depth / 2.0;
730    let inner = web_thickness;
731    // Same convention as the I: pivot about the mid-point of the inner face so
732    // the stated flange thickness stays the mean.
733    let rise = |x: Scalar| (x - (inner + flange_width) / 2.0) * flange_slope.tan();
734
735    Ok(vec![
736        sharp(flange_width, -hd),
737        rounded(
738            flange_width,
739            -hd + flange_thickness - rise(flange_width),
740            edge,
741        ),
742        rounded(inner, -hd + flange_thickness - rise(inner), fillet),
743        rounded(inner, hd - flange_thickness + rise(inner), fillet),
744        rounded(
745            flange_width,
746            hd - flange_thickness + rise(flange_width),
747            edge,
748        ),
749        sharp(flange_width, hd),
750        sharp(0.0, hd),
751        sharp(0.0, -hd),
752    ])
753}
754
755/// Six-corner L (angle) outline with the heel at the origin.
756fn l_corners(
757    depth: Scalar,
758    width: Scalar,
759    thickness: Scalar,
760    fillet: Option<Scalar>,
761    edge: Option<Scalar>,
762    leg_slope: Scalar,
763) -> GeomResult<Vec<Corner>> {
764    positive(depth, "depth")?;
765    positive(width, "width")?;
766    positive(thickness, "thickness")?;
767    if thickness >= depth.min(width) {
768        return Err(GeomError::Degenerate(format!(
769            "thickness {thickness} is not thinner than the legs"
770        )));
771    }
772
773    // Each leg's inner face inclines about the mid-point of its run, so the
774    // stated thickness remains the mean thickness of the leg.
775    let tan = leg_slope.tan();
776    let horizontal = |x: Scalar| (x - (thickness + width) / 2.0) * tan;
777    let vertical = |y: Scalar| (y - (thickness + depth) / 2.0) * tan;
778
779    Ok(vec![
780        sharp(0.0, 0.0),
781        sharp(width, 0.0),
782        rounded(width, thickness - horizontal(width), edge),
783        rounded(thickness, thickness, fillet),
784        rounded(thickness - vertical(depth), depth, edge),
785        sharp(0.0, depth),
786    ])
787}
788/// Eight-corner Z outline: flanges point in opposite directions.
789fn z_corners(
790    depth: Scalar,
791    flange_width: Scalar,
792    web_thickness: Scalar,
793    flange_thickness: Scalar,
794    fillet: Option<Scalar>,
795    edge: Option<Scalar>,
796) -> GeomResult<Vec<Corner>> {
797    positive(depth, "depth")?;
798    positive(flange_width, "flange width")?;
799    positive(web_thickness, "web thickness")?;
800    positive(flange_thickness, "flange thickness")?;
801    if 2.0 * flange_thickness >= depth {
802        return Err(GeomError::Degenerate(format!(
803            "flanges {flange_thickness} leave no web in depth {depth}"
804        )));
805    }
806
807    let (hd, hw) = (depth / 2.0, web_thickness / 2.0);
808    let bottom_top = -hd + flange_thickness;
809    let top_bottom = hd - flange_thickness;
810
811    // Bottom flange runs right, top flange runs left.
812    Ok(vec![
813        sharp(hw + flange_width, -hd),
814        rounded(hw + flange_width, bottom_top, edge),
815        rounded(hw, bottom_top, fillet),
816        sharp(hw, hd),
817        sharp(-hw - flange_width, hd),
818        rounded(-hw - flange_width, top_bottom, edge),
819        rounded(-hw, top_bottom, fillet),
820        sharp(-hw, -hd),
821    ])
822}
823
824/// Twelve-corner C (lipped channel) outline.
825///
826/// Unlike `U`, this is a THIN-WALLED section: the boundary follows the wall
827/// all the way round, including the returned lips, so the enclosed area is
828/// the wall material rather than the full channel envelope.
829fn c_corners(
830    depth: Scalar,
831    width: Scalar,
832    wall_thickness: Scalar,
833    girth: Scalar,
834    fillet: Option<Scalar>,
835) -> GeomResult<Vec<Corner>> {
836    positive(depth, "depth")?;
837    positive(width, "width")?;
838    positive(wall_thickness, "wall thickness")?;
839    positive(girth, "girth")?;
840    if 2.0 * wall_thickness >= depth || 2.0 * wall_thickness >= width {
841        return Err(GeomError::Degenerate(format!(
842            "wall thickness {wall_thickness} leaves no opening"
843        )));
844    }
845    if girth <= wall_thickness {
846        return Err(GeomError::Degenerate(format!(
847            "lip girth {girth} is not longer than the wall thickness"
848        )));
849    }
850
851    let hd = depth / 2.0;
852    let t = wall_thickness;
853
854    Ok(vec![
855        // Outer boundary: up the web, out along each flange, down each lip.
856        sharp(width, -hd),
857        sharp(width, -hd + girth),
858        sharp(width - t, -hd + girth),
859        rounded(width - t, -hd + t, fillet),
860        rounded(t, -hd + t, fillet),
861        rounded(t, hd - t, fillet),
862        rounded(width - t, hd - t, fillet),
863        sharp(width - t, hd - girth),
864        sharp(width, hd - girth),
865        sharp(width, hd),
866        sharp(0.0, hd),
867        sharp(0.0, -hd),
868    ])
869}
870
871/// Four-corner trapezium.
872fn trapezium_corners(
873    bottom_x: Scalar,
874    top_x: Scalar,
875    y: Scalar,
876    top_offset: Scalar,
877) -> GeomResult<Vec<Corner>> {
878    positive(bottom_x, "bottom width")?;
879    positive(top_x, "top width")?;
880    positive(y, "height")?;
881    if !top_offset.is_finite() {
882        return Err(GeomError::InvalidInput(format!(
883            "trapezium top offset must be finite, got {top_offset}"
884        )));
885    }
886
887    Ok(vec![
888        sharp(0.0, 0.0),
889        sharp(bottom_x, 0.0),
890        sharp(top_offset + top_x, y),
891        sharp(top_offset, y),
892    ])
893}
894
895#[cfg(test)]
896mod tests {
897    use super::*;
898
899    /// A radius past its limit by rounding is snapped onto the limit, bit
900    /// for bit, and the reach is exactly [`RADIUS_REACH_EPSILONS`] relative
901    /// epsilons (four) of the scale: a little inside it is accepted, a little past
902    /// it refused (#339).
903    #[test]
904    fn a_radius_past_its_limit_by_rounding_snaps_and_no_further() {
905        // The Annex E inner corner: 10 in against 12 in - 2 in, in metres.
906        let scale = 24.0 * 0.0254 / 2.0;
907        let limit = scale - 2.0 * 0.0254;
908        let radius = 10.0 * 0.0254;
909        assert!(radius > limit, "the case must round past its limit");
910        let snapped = corner_radius(Some(radius), limit, scale, "inner").expect("rounding");
911        assert_eq!(snapped.to_bits(), limit.to_bits(), "snapped onto the limit");
912
913        // Spelled out rather than read from the constant, so a changed
914        // reach turns this red.
915        let reach = 4.0 * Scalar::EPSILON * scale;
916        let inside = limit + 0.75 * reach;
917        assert!(inside > limit);
918        assert_eq!(
919            corner_radius(Some(inside), limit, scale, "inner").map(Scalar::to_bits),
920            Ok(limit.to_bits())
921        );
922        let past = limit + 1.5 * reach;
923        assert!(
924            matches!(
925                corner_radius(Some(past), limit, scale, "inner"),
926                Err(GeomError::InvalidInput(_))
927            ),
928            "a radius past the reach is refused"
929        );
930        // Within the limit nothing moves.
931        let below = limit * 0.5;
932        assert_eq!(
933            corner_radius(Some(below), limit, scale, "inner").map(Scalar::to_bits),
934            Ok(below.to_bits())
935        );
936    }
937
938    /// The corner router must round a NON-right corner correctly.
939    ///
940    /// Every rounded corner reachable through `SectionProfile` today is a
941    /// right angle, and at 90 degrees `r * tan(45) == r`, so a setback that
942    /// ignored the turn angle would be indistinguishable there. The router is
943    /// general, so its generality is tested directly rather than left to a
944    /// variant that happens not to exercise it.
945    ///
946    /// The invariant checked is TANGENCY: the arc must meet both adjacent
947    /// edges at a point whose distance to the arc centre equals the radius,
948    /// and the centre must sit exactly `radius` from each edge line.
949    #[test]
950    fn a_non_right_corner_is_rounded_tangentially() {
951        // A 116.565-degree turn: setback is r*tan(58.28) = 1.618 r, not r.
952        let radius = 0.02;
953        let corners = vec![
954            sharp(0.0, 0.0),
955            Corner {
956                point: Point2::new(0.4, 0.0),
957                radius,
958            },
959            sharp(0.3, 0.2),
960            sharp(0.05, 0.2),
961        ];
962        let contour = route(&corners).expect("a trapezoidal ring routes");
963
964        let arc = contour
965            .segments
966            .iter()
967            .find_map(|segment| match &segment.curve {
968                Curve2::Circle(circle) => Some(*circle),
969                _ => None,
970            })
971            .expect("the rounded corner produced an arc");
972        assert!(
973            (arc.radius - radius).abs() < 1e-12,
974            "arc must carry the stated radius, got {}",
975            arc.radius
976        );
977
978        // Distance from the arc centre to each adjacent edge LINE must equal
979        // the radius. That is tangency, and it holds only for the correct
980        // setback.
981        let distance_to_line = |a: Point2, b: Point2| {
982            let along = (b - a).normalize();
983            let normal = Vec2::new(-along.y, along.x);
984            (arc.frame.origin - a).dot(normal).abs()
985        };
986        let incoming = distance_to_line(Point2::new(0.0, 0.0), Point2::new(0.4, 0.0));
987        let outgoing = distance_to_line(Point2::new(0.4, 0.0), Point2::new(0.3, 0.2));
988        assert!(
989            (incoming - radius).abs() < 1e-12,
990            "arc must be tangent to the incoming edge, distance {incoming}"
991        );
992        assert!(
993            (outgoing - radius).abs() < 1e-12,
994            "arc must be tangent to the outgoing edge, distance {outgoing}"
995        );
996    }
997}