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, RectangleProfile, 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
232/// Lower a rectangle -- rounded, hollow, or both -- into an exact contour.
233///
234/// Corner radii become exact quarter arcs through the same router the
235/// structural sections use, so a rounded corner extrudes to a cylinder wall
236/// rather than a fan of chords. A hollow rectangle's inner boundary comes
237/// back as a hole, built counter-clockwise like the outer ring; the extruder
238/// re-orients holes itself.
239///
240/// Refuses, rather than clamps, a radius that is negative, non-finite, or
241/// wider than the half-extent it rounds, and an inner radius on a filled
242/// rectangle.
243///
244/// Also refuses a hollow section whose corners leave no wall. Two rounded
245/// rectangles nest exactly when their support functions do; along the corner
246/// diagonal that requires `outer - inner < (2 + sqrt 2) * thickness`. Past
247/// that the inner corner reaches the outer arc and the profile crosses itself.
248pub fn rectangle_contour(rectangle: &RectangleProfile) -> GeomResult<ContourProfile> {
249    positive(rectangle.x, "rectangle x extent")?;
250    positive(rectangle.y, "rectangle y extent")?;
251    let (hx, hy) = (rectangle.x / 2.0, rectangle.y / 2.0);
252    let outer_radius = corner_radius(rectangle.outer_radius, hx.min(hy), "outer")?;
253    let outer = route(&box_corners(hx, hy, outer_radius))?;
254    let Some(thickness) = rectangle.thickness else {
255        if rectangle.inner_radius.is_some() {
256            return Err(GeomError::InvalidInput(
257                "an inner corner radius needs a hollow rectangle".to_owned(),
258            ));
259        }
260        return Ok(ContourProfile {
261            outer,
262            holes: Vec::new(),
263        });
264    };
265    positive(thickness, "rectangle wall thickness")?;
266    if 2.0 * thickness >= rectangle.x.min(rectangle.y) {
267        return Err(GeomError::Degenerate(format!(
268            "wall thickness {thickness} leaves no opening in {} x {}",
269            rectangle.x, rectangle.y
270        )));
271    }
272    let (ix, iy) = (hx - thickness, hy - thickness);
273    let inner_radius = corner_radius(rectangle.inner_radius, ix.min(iy), "inner")?;
274    let wall_limit = (2.0 + core::f64::consts::SQRT_2) * thickness;
275    if outer_radius - inner_radius >= wall_limit {
276        return Err(GeomError::Degenerate(format!(
277            "outer radius {outer_radius} minus inner radius {inner_radius} must stay \
278             below {wall_limit} or the {thickness}-thick wall vanishes at the corners"
279        )));
280    }
281    let hole = route(&box_corners(ix, iy, inner_radius))?;
282    Ok(ContourProfile {
283        outer,
284        holes: vec![hole],
285    })
286}
287
288/// Counter-clockwise corners of an origin-centred box, all rounded alike.
289fn box_corners(hx: Scalar, hy: Scalar, radius: Scalar) -> [Corner; 4] {
290    let radius = Some(radius);
291    [
292        rounded(-hx, -hy, radius),
293        rounded(hx, -hy, radius),
294        rounded(hx, hy, radius),
295        rounded(-hx, hy, radius),
296    ]
297}
298
299/// Validate a stated corner radius; `None` is a sharp corner.
300fn corner_radius(radius: Option<Scalar>, limit: Scalar, which: &str) -> GeomResult<Scalar> {
301    let value = radius.unwrap_or(0.0);
302    if !value.is_finite() || value < 0.0 || value > limit {
303        return Err(GeomError::InvalidInput(format!(
304            "{which} corner radius must lie in [0, {limit}], got {value}"
305        )));
306    }
307    Ok(value)
308}
309
310fn positive(value: Scalar, what: &str) -> GeomResult<()> {
311    if !value.is_finite() || value <= 0.0 {
312        return Err(GeomError::InvalidInput(format!(
313            "section {what} must be positive and finite, got {value}"
314        )));
315    }
316    Ok(())
317}
318/// Lower a parameterised section into an exact contour.
319///
320/// The outline is built counter-clockwise about the section centroid-ish
321/// origin each source entity declares, so the result needs no re-orientation.
322pub fn section_contour(section: &SectionProfile) -> GeomResult<ContourProfile> {
323    let corners = match section {
324        SectionProfile::I {
325            depth,
326            width,
327            web_thickness,
328            flange_thickness,
329            fillet_radius,
330            flange_edge_radius,
331            flange_slope,
332        } => {
333            let slope = checked_slope(*flange_slope, "I section")?;
334            i_corners(
335                *depth,
336                *width,
337                *width,
338                *web_thickness,
339                *flange_thickness,
340                *flange_thickness,
341                *fillet_radius,
342                *fillet_radius,
343                *flange_edge_radius,
344                *flange_edge_radius,
345                slope,
346                slope,
347            )?
348        }
349        SectionProfile::AsymmetricI {
350            depth,
351            web_thickness,
352            bottom_flange_width,
353            bottom_flange_thickness,
354            bottom_fillet_radius,
355            bottom_flange_edge_radius,
356            bottom_flange_slope,
357            top_flange_width,
358            top_flange_thickness,
359            top_fillet_radius,
360            top_flange_edge_radius,
361            top_flange_slope,
362        } => {
363            let bottom_slope = checked_slope(*bottom_flange_slope, "I section")?;
364            let top_slope = checked_slope(*top_flange_slope, "I section")?;
365            i_corners(
366                *depth,
367                *bottom_flange_width,
368                *top_flange_width,
369                *web_thickness,
370                *bottom_flange_thickness,
371                // The source may omit the top thickness, meaning "same as
372                // the bottom" rather than "zero".
373                top_flange_thickness.unwrap_or(*bottom_flange_thickness),
374                *bottom_fillet_radius,
375                *top_fillet_radius,
376                *bottom_flange_edge_radius,
377                *top_flange_edge_radius,
378                bottom_slope,
379                top_slope,
380            )?
381        }
382        SectionProfile::T {
383            depth,
384            flange_width,
385            web_thickness,
386            flange_thickness,
387            fillet_radius,
388            flange_edge_radius,
389            web_edge_radius,
390            web_slope,
391            flange_slope,
392        } => {
393            // A T's web taper and flange taper are independent faces.
394            let web = checked_slope(*web_slope, "T section web")?;
395            let flange = checked_slope(*flange_slope, "T section flange")?;
396            t_corners(
397                *depth,
398                *flange_width,
399                *web_thickness,
400                *flange_thickness,
401                &RadiiT {
402                    fillet: *fillet_radius,
403                    flange_edge: *flange_edge_radius,
404                    web_edge: *web_edge_radius,
405                },
406                &TaperT { web, flange },
407            )?
408        }
409        SectionProfile::U {
410            depth,
411            flange_width,
412            web_thickness,
413            flange_thickness,
414            fillet_radius,
415            edge_radius,
416            flange_slope,
417        } => {
418            let slope = checked_slope(*flange_slope, "U section")?;
419            u_corners(
420                *depth,
421                *flange_width,
422                *web_thickness,
423                *flange_thickness,
424                *fillet_radius,
425                *edge_radius,
426                slope,
427            )?
428        }
429        SectionProfile::L {
430            depth,
431            width,
432            thickness,
433            fillet_radius,
434            edge_radius,
435            leg_slope,
436        } => {
437            let slope = checked_slope(*leg_slope, "L section")?;
438            l_corners(
439                *depth,
440                // An absent width means an EQUAL angle, not a zero one.
441                width.unwrap_or(*depth),
442                *thickness,
443                *fillet_radius,
444                *edge_radius,
445                slope,
446            )?
447        }
448        SectionProfile::Z {
449            depth,
450            flange_width,
451            web_thickness,
452            flange_thickness,
453            fillet_radius,
454            edge_radius,
455        } => z_corners(
456            *depth,
457            *flange_width,
458            *web_thickness,
459            *flange_thickness,
460            *fillet_radius,
461            *edge_radius,
462        )?,
463        SectionProfile::C {
464            depth,
465            width,
466            wall_thickness,
467            girth,
468            internal_fillet_radius,
469        } => c_corners(
470            *depth,
471            *width,
472            *wall_thickness,
473            *girth,
474            *internal_fillet_radius,
475        )?,
476        SectionProfile::Trapezium {
477            bottom_x,
478            top_x,
479            y,
480            top_offset,
481        } => trapezium_corners(*bottom_x, *top_x, *y, *top_offset)?,
482        _ => return Err(unsupported("section profile of an unsupported kind")),
483    };
484
485    Ok(ContourProfile {
486        outer: route(&corners)?,
487        holes: Vec::new(),
488    })
489}
490/// Twelve-corner I outline, walked counter-clockwise from the bottom-right.
491///
492/// Handles the asymmetric case directly; the symmetric variant passes equal
493/// top and bottom dimensions rather than going through a separate routine
494/// that could drift from this one.
495#[allow(clippy::too_many_arguments)]
496fn i_corners(
497    depth: Scalar,
498    bottom_width: Scalar,
499    top_width: Scalar,
500    web_thickness: Scalar,
501    bottom_flange: Scalar,
502    top_flange: Scalar,
503    bottom_fillet: Option<Scalar>,
504    top_fillet: Option<Scalar>,
505    bottom_edge: Option<Scalar>,
506    top_edge: Option<Scalar>,
507    bottom_slope: Scalar,
508    top_slope: Scalar,
509) -> GeomResult<Vec<Corner>> {
510    positive(depth, "depth")?;
511    positive(bottom_width, "flange width")?;
512    positive(top_width, "flange width")?;
513    positive(web_thickness, "web thickness")?;
514    positive(bottom_flange, "flange thickness")?;
515    positive(top_flange, "flange thickness")?;
516    if bottom_flange + top_flange >= depth {
517        return Err(GeomError::Degenerate(format!(
518            "flanges {bottom_flange} + {top_flange} leave no web in depth {depth}"
519        )));
520    }
521    if web_thickness >= bottom_width.min(top_width) {
522        return Err(GeomError::Degenerate(format!(
523            "web thickness {web_thickness} is not narrower than the flange"
524        )));
525    }
526
527    let (hd, hw) = (depth / 2.0, web_thickness / 2.0);
528    let (hb, ht) = (bottom_width / 2.0, top_width / 2.0);
529    let bottom_top = -hd + bottom_flange;
530    let top_bottom = hd - top_flange;
531
532    // A tapered flange's inner face is inclined, so its height depends on x.
533    // The face pivots about the MID-POINT between the web face and the flange
534    // tip, which keeps `flange_thickness` the MEAN thickness -- the value
535    // section tables state. Pivoting about the tip or the web instead would
536    // silently change the declared thickness and with it the area.
537    let bottom_rise = |x: Scalar| (x - (hw + hb) / 2.0) * bottom_slope.tan();
538    let top_rise = |x: Scalar| (x - (hw + ht) / 2.0) * top_slope.tan();
539
540    Ok(vec![
541        sharp(hb, -hd),
542        rounded(hb, bottom_top - bottom_rise(hb), bottom_edge),
543        rounded(hw, bottom_top - bottom_rise(hw), bottom_fillet),
544        rounded(hw, top_bottom + top_rise(hw), top_fillet),
545        rounded(ht, top_bottom + top_rise(ht), top_edge),
546        sharp(ht, hd),
547        sharp(-ht, hd),
548        rounded(-ht, top_bottom + top_rise(ht), top_edge),
549        rounded(-hw, top_bottom + top_rise(hw), top_fillet),
550        rounded(-hw, bottom_top - bottom_rise(hw), bottom_fillet),
551        rounded(-hb, bottom_top - bottom_rise(hb), bottom_edge),
552        sharp(-hb, -hd),
553    ])
554}
555
556/// Eight-corner T outline: flange on top, web hanging below.
557/// The two independent tapers a T section can declare.
558///
559/// Grouped rather than passed loose so the web angle cannot be handed to the
560/// flange by accident: the two are the same type and adjacent in the argument
561/// list, which is exactly the shape of a silent swap.
562/// The three optional radii a T section can declare.
563struct RadiiT {
564    fillet: Option<Scalar>,
565    flange_edge: Option<Scalar>,
566    web_edge: Option<Scalar>,
567}
568
569struct TaperT {
570    web: Scalar,
571    flange: Scalar,
572}
573
574fn t_corners(
575    depth: Scalar,
576    flange_width: Scalar,
577    web_thickness: Scalar,
578    flange_thickness: Scalar,
579    radii: &RadiiT,
580    taper: &TaperT,
581) -> GeomResult<Vec<Corner>> {
582    let (web_slope, flange_slope) = (taper.web, taper.flange);
583    let (fillet, flange_edge, web_edge) = (radii.fillet, radii.flange_edge, radii.web_edge);
584    positive(depth, "depth")?;
585    positive(flange_width, "flange width")?;
586    positive(web_thickness, "web thickness")?;
587    positive(flange_thickness, "flange thickness")?;
588    if flange_thickness >= depth {
589        return Err(GeomError::Degenerate(format!(
590            "flange {flange_thickness} leaves no web in depth {depth}"
591        )));
592    }
593    if web_thickness >= flange_width {
594        return Err(GeomError::Degenerate(format!(
595            "web thickness {web_thickness} is not narrower than the flange"
596        )));
597    }
598
599    let (hd, hw, hf) = (depth / 2.0, web_thickness / 2.0, flange_width / 2.0);
600    let flange_bottom = hd - flange_thickness;
601
602    // Flange underside inclines about the mid-point between web face and tip,
603    // keeping `flange_thickness` the mean. The web's side faces incline about
604    // the mid-height of the exposed web run for the same reason.
605    let flange_rise = |x: Scalar| (x - (hw + hf) / 2.0) * flange_slope.tan();
606    let web_mid = (-hd + flange_bottom) / 2.0;
607    let web_out = |y: Scalar| (y - web_mid) * web_slope.tan();
608
609    Ok(vec![
610        rounded(hw + web_out(-hd), -hd, web_edge),
611        rounded(hw + web_out(flange_bottom), flange_bottom, fillet),
612        rounded(hf, flange_bottom - flange_rise(hf), flange_edge),
613        sharp(hf, hd),
614        sharp(-hf, hd),
615        rounded(-hf, flange_bottom - flange_rise(hf), flange_edge),
616        rounded(-hw - web_out(flange_bottom), flange_bottom, fillet),
617        rounded(-hw - web_out(-hd), -hd, web_edge),
618    ])
619}
620
621/// Eight-corner U (channel) outline: web on the left, flanges to the right.
622fn u_corners(
623    depth: Scalar,
624    flange_width: Scalar,
625    web_thickness: Scalar,
626    flange_thickness: Scalar,
627    fillet: Option<Scalar>,
628    edge: Option<Scalar>,
629    flange_slope: Scalar,
630) -> GeomResult<Vec<Corner>> {
631    positive(depth, "depth")?;
632    positive(flange_width, "flange width")?;
633    positive(web_thickness, "web thickness")?;
634    positive(flange_thickness, "flange thickness")?;
635    if 2.0 * flange_thickness >= depth {
636        return Err(GeomError::Degenerate(format!(
637            "flanges {flange_thickness} leave no web in depth {depth}"
638        )));
639    }
640    if web_thickness >= flange_width {
641        return Err(GeomError::Degenerate(format!(
642            "web thickness {web_thickness} is not narrower than the flange"
643        )));
644    }
645
646    let hd = depth / 2.0;
647    let inner = web_thickness;
648    // Same convention as the I: pivot about the mid-point of the inner face so
649    // the stated flange thickness stays the mean.
650    let rise = |x: Scalar| (x - (inner + flange_width) / 2.0) * flange_slope.tan();
651
652    Ok(vec![
653        sharp(flange_width, -hd),
654        rounded(
655            flange_width,
656            -hd + flange_thickness - rise(flange_width),
657            edge,
658        ),
659        rounded(inner, -hd + flange_thickness - rise(inner), fillet),
660        rounded(inner, hd - flange_thickness + rise(inner), fillet),
661        rounded(
662            flange_width,
663            hd - flange_thickness + rise(flange_width),
664            edge,
665        ),
666        sharp(flange_width, hd),
667        sharp(0.0, hd),
668        sharp(0.0, -hd),
669    ])
670}
671
672/// Six-corner L (angle) outline with the heel at the origin.
673fn l_corners(
674    depth: Scalar,
675    width: Scalar,
676    thickness: Scalar,
677    fillet: Option<Scalar>,
678    edge: Option<Scalar>,
679    leg_slope: Scalar,
680) -> GeomResult<Vec<Corner>> {
681    positive(depth, "depth")?;
682    positive(width, "width")?;
683    positive(thickness, "thickness")?;
684    if thickness >= depth.min(width) {
685        return Err(GeomError::Degenerate(format!(
686            "thickness {thickness} is not thinner than the legs"
687        )));
688    }
689
690    // Each leg's inner face inclines about the mid-point of its run, so the
691    // stated thickness remains the mean thickness of the leg.
692    let tan = leg_slope.tan();
693    let horizontal = |x: Scalar| (x - (thickness + width) / 2.0) * tan;
694    let vertical = |y: Scalar| (y - (thickness + depth) / 2.0) * tan;
695
696    Ok(vec![
697        sharp(0.0, 0.0),
698        sharp(width, 0.0),
699        rounded(width, thickness - horizontal(width), edge),
700        rounded(thickness, thickness, fillet),
701        rounded(thickness - vertical(depth), depth, edge),
702        sharp(0.0, depth),
703    ])
704}
705/// Eight-corner Z outline: flanges point in opposite directions.
706fn z_corners(
707    depth: Scalar,
708    flange_width: Scalar,
709    web_thickness: Scalar,
710    flange_thickness: Scalar,
711    fillet: Option<Scalar>,
712    edge: Option<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
724    let (hd, hw) = (depth / 2.0, web_thickness / 2.0);
725    let bottom_top = -hd + flange_thickness;
726    let top_bottom = hd - flange_thickness;
727
728    // Bottom flange runs right, top flange runs left.
729    Ok(vec![
730        sharp(hw + flange_width, -hd),
731        rounded(hw + flange_width, bottom_top, edge),
732        rounded(hw, bottom_top, fillet),
733        sharp(hw, hd),
734        sharp(-hw - flange_width, hd),
735        rounded(-hw - flange_width, top_bottom, edge),
736        rounded(-hw, top_bottom, fillet),
737        sharp(-hw, -hd),
738    ])
739}
740
741/// Twelve-corner C (lipped channel) outline.
742///
743/// Unlike `U`, this is a THIN-WALLED section: the boundary follows the wall
744/// all the way round, including the returned lips, so the enclosed area is
745/// the wall material rather than the full channel envelope.
746fn c_corners(
747    depth: Scalar,
748    width: Scalar,
749    wall_thickness: Scalar,
750    girth: Scalar,
751    fillet: Option<Scalar>,
752) -> GeomResult<Vec<Corner>> {
753    positive(depth, "depth")?;
754    positive(width, "width")?;
755    positive(wall_thickness, "wall thickness")?;
756    positive(girth, "girth")?;
757    if 2.0 * wall_thickness >= depth || 2.0 * wall_thickness >= width {
758        return Err(GeomError::Degenerate(format!(
759            "wall thickness {wall_thickness} leaves no opening"
760        )));
761    }
762    if girth <= wall_thickness {
763        return Err(GeomError::Degenerate(format!(
764            "lip girth {girth} is not longer than the wall thickness"
765        )));
766    }
767
768    let hd = depth / 2.0;
769    let t = wall_thickness;
770
771    Ok(vec![
772        // Outer boundary: up the web, out along each flange, down each lip.
773        sharp(width, -hd),
774        sharp(width, -hd + girth),
775        sharp(width - t, -hd + girth),
776        rounded(width - t, -hd + t, fillet),
777        rounded(t, -hd + t, fillet),
778        rounded(t, hd - t, fillet),
779        rounded(width - t, hd - t, fillet),
780        sharp(width - t, hd - girth),
781        sharp(width, hd - girth),
782        sharp(width, hd),
783        sharp(0.0, hd),
784        sharp(0.0, -hd),
785    ])
786}
787
788/// Four-corner trapezium.
789fn trapezium_corners(
790    bottom_x: Scalar,
791    top_x: Scalar,
792    y: Scalar,
793    top_offset: Scalar,
794) -> GeomResult<Vec<Corner>> {
795    positive(bottom_x, "bottom width")?;
796    positive(top_x, "top width")?;
797    positive(y, "height")?;
798    if !top_offset.is_finite() {
799        return Err(GeomError::InvalidInput(format!(
800            "trapezium top offset must be finite, got {top_offset}"
801        )));
802    }
803
804    Ok(vec![
805        sharp(0.0, 0.0),
806        sharp(bottom_x, 0.0),
807        sharp(top_offset + top_x, y),
808        sharp(top_offset, y),
809    ])
810}
811
812#[cfg(test)]
813mod tests {
814    use super::*;
815
816    /// The corner router must round a NON-right corner correctly.
817    ///
818    /// Every rounded corner reachable through `SectionProfile` today is a
819    /// right angle, and at 90 degrees `r * tan(45) == r`, so a setback that
820    /// ignored the turn angle would be indistinguishable there. The router is
821    /// general, so its generality is tested directly rather than left to a
822    /// variant that happens not to exercise it.
823    ///
824    /// The invariant checked is TANGENCY: the arc must meet both adjacent
825    /// edges at a point whose distance to the arc centre equals the radius,
826    /// and the centre must sit exactly `radius` from each edge line.
827    #[test]
828    fn a_non_right_corner_is_rounded_tangentially() {
829        // A 116.565-degree turn: setback is r*tan(58.28) = 1.618 r, not r.
830        let radius = 0.02;
831        let corners = vec![
832            sharp(0.0, 0.0),
833            Corner {
834                point: Point2::new(0.4, 0.0),
835                radius,
836            },
837            sharp(0.3, 0.2),
838            sharp(0.05, 0.2),
839        ];
840        let contour = route(&corners).expect("a trapezoidal ring routes");
841
842        let arc = contour
843            .segments
844            .iter()
845            .find_map(|segment| match &segment.curve {
846                Curve2::Circle(circle) => Some(*circle),
847                _ => None,
848            })
849            .expect("the rounded corner produced an arc");
850        assert!(
851            (arc.radius - radius).abs() < 1e-12,
852            "arc must carry the stated radius, got {}",
853            arc.radius
854        );
855
856        // Distance from the arc centre to each adjacent edge LINE must equal
857        // the radius. That is tangency, and it holds only for the correct
858        // setback.
859        let distance_to_line = |a: Point2, b: Point2| {
860            let along = (b - a).normalize();
861            let normal = Vec2::new(-along.y, along.x);
862            (arc.frame.origin - a).dot(normal).abs()
863        };
864        let incoming = distance_to_line(Point2::new(0.0, 0.0), Point2::new(0.4, 0.0));
865        let outgoing = distance_to_line(Point2::new(0.4, 0.0), Point2::new(0.3, 0.2));
866        assert!(
867            (incoming - radius).abs() < 1e-12,
868            "arc must be tangent to the incoming edge, distance {incoming}"
869        );
870        assert!(
871            (outgoing - radius).abs() < 1e-12,
872            "arc must be tangent to the outgoing edge, distance {outgoing}"
873        );
874    }
875}