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::{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/// One boundary corner: a position and the radius rounding it.
36#[derive(Debug, Clone, Copy)]
37struct Corner {
38    point: Point2,
39    /// Zero means a sharp corner.
40    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        // `None` means the source did not state a radius, which is a sharp
54        // corner here; `Some(0.0)` states one explicitly and agrees.
55        radius: radius.unwrap_or(0.0).max(0.0),
56    }
57}
58
59/// Turn a closed counter-clockwise corner ring into an exact contour.
60///
61/// At each corner with a positive radius the boundary is cut back along both
62/// adjacent edges by `r * tan(alpha / 2)`, where `alpha` is the turn angle,
63/// and joined by an arc tangent to both. That setback is what makes the arc
64/// tangent rather than merely near the corner.
65fn 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    // Setback and arc geometry per corner, or `None` when sharp.
74    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            // Collinear: there is no corner to round.
94            continue;
95        }
96        let turn = incoming.dot(outgoing).clamp(-1.0, 1.0).acos();
97        let setback = here.radius * (turn / 2.0).tan();
98        // Distance from the corner to the arc centre along the interior
99        // bisector.
100        //
101        // `turn` is the EXTERIOR deflection, so the interior angle is
102        // `pi - turn` and the centre sits `r / sin(interior / 2)` away, which
103        // is `r / cos(turn / 2)`. Using `sin(turn / 2)` here is wrong
104        // everywhere EXCEPT at a right angle, where the two coincide -- and
105        // every rounded corner in every section variant is a right angle, so
106        // the error is invisible to them.
107        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    // Both ends of an edge draw from the same edge length.
121    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}
133/// Emit segments: a straight run between consecutive corners, plus an arc at
134/// each rounded corner.
135fn 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        // Where this corner hands over to the straight run that follows.
143        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        // A fully consumed edge leaves the two arcs touching; emitting a
157        // zero-length line there would be a degenerate segment.
158        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
172/// A tangent arc from `start`, about `centre`, turning by `sweep`.
173///
174/// The frame's x-axis points at the arc start, so the segment domain begins
175/// at zero. A negative sweep is carried by a LEFT-handed frame rather than a
176/// negative domain, matching the convention the contour lowering already
177/// uses: the parameter always increases, and handedness says which way the
178/// world turn goes.
179fn 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
201/// Reject a stated taper.
202///
203/// Validate a declared taper and return it as an angle.
204///
205/// A slope is an angle from the horizontal, so it must stay well inside a
206/// quarter turn: at a right angle the inner face would be parallel to the web
207/// and the section would have no flange at all. The bound is deliberately
208/// generous -- rolled sections taper by 5 to 14 degrees -- because the job
209/// here is to exclude nonsense, not to second-guess a source that states an
210/// unusual but buildable value.
211///
212/// `None` means the source did not state a taper, which is a parallel flange.
213/// `Some(0.0)` states one explicitly and gives the same geometry.
214fn 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    // A quarter turn is the hard limit; stop short of it so the tangent stays
222    // usable rather than exploding.
223    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}
240/// Lower a parameterised section into an exact contour.
241///
242/// The outline is built counter-clockwise about the section centroid-ish
243/// origin each source entity declares, so the result needs no re-orientation.
244pub 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                // The source may omit the top thickness, meaning "same as
294                // the bottom" rather than "zero".
295                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            // A T's web taper and flange taper are independent faces.
316            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                // An absent width means an EQUAL angle, not a zero one.
363                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/// Twelve-corner I outline, walked counter-clockwise from the bottom-right.
413///
414/// Handles the asymmetric case directly; the symmetric variant passes equal
415/// top and bottom dimensions rather than going through a separate routine
416/// that could drift from this one.
417#[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    // A tapered flange's inner face is inclined, so its height depends on x.
455    // The face pivots about the MID-POINT between the web face and the flange
456    // tip, which keeps `flange_thickness` the MEAN thickness -- the value
457    // section tables state. Pivoting about the tip or the web instead would
458    // silently change the declared thickness and with it the area.
459    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
478/// Eight-corner T outline: flange on top, web hanging below.
479/// The two independent tapers a T section can declare.
480///
481/// Grouped rather than passed loose so the web angle cannot be handed to the
482/// flange by accident: the two are the same type and adjacent in the argument
483/// list, which is exactly the shape of a silent swap.
484/// The three optional radii a T section can declare.
485struct 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    // Flange underside inclines about the mid-point between web face and tip,
525    // keeping `flange_thickness` the mean. The web's side faces incline about
526    // the mid-height of the exposed web run for the same reason.
527    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
543/// Eight-corner U (channel) outline: web on the left, flanges to the right.
544fn 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    // Same convention as the I: pivot about the mid-point of the inner face so
571    // the stated flange thickness stays the mean.
572    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
594/// Six-corner L (angle) outline with the heel at the origin.
595fn 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    // Each leg's inner face inclines about the mid-point of its run, so the
613    // stated thickness remains the mean thickness of the leg.
614    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}
627/// Eight-corner Z outline: flanges point in opposite directions.
628fn 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    // Bottom flange runs right, top flange runs left.
651    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
663/// Twelve-corner C (lipped channel) outline.
664///
665/// Unlike `U`, this is a THIN-WALLED section: the boundary follows the wall
666/// all the way round, including the returned lips, so the enclosed area is
667/// the wall material rather than the full channel envelope.
668fn 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        // Outer boundary: up the web, out along each flange, down each lip.
695        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
710/// Four-corner trapezium.
711fn 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    /// The corner router must round a NON-right corner correctly.
739    ///
740    /// Every rounded corner reachable through `SectionProfile` today is a
741    /// right angle, and at 90 degrees `r * tan(45) == r`, so a setback that
742    /// ignored the turn angle would be indistinguishable there. The router is
743    /// general, so its generality is tested directly rather than left to a
744    /// variant that happens not to exercise it.
745    ///
746    /// The invariant checked is TANGENCY: the arc must meet both adjacent
747    /// edges at a point whose distance to the arc centre equals the radius,
748    /// and the centre must sit exactly `radius` from each edge line.
749    #[test]
750    fn a_non_right_corner_is_rounded_tangentially() {
751        // A 116.565-degree turn: setback is r*tan(58.28) = 1.618 r, not r.
752        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        // Distance from the arc centre to each adjacent edge LINE must equal
779        // the radius. That is tangency, and it holds only for the correct
780        // setback.
781        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}