use axiolid_brep_audit::geometric_audit;
use axiolid_construct::extrude::extrude_profile_exact;
use axiolid_construct::section_lower::section_contour;
use axiolid_core::{Tolerance, Vec3};
use axiolid_curve::Curve2;
use axiolid_profile::{Profile, SectionProfile};
use axiolid_surface::Surface;
const DEPTH: f64 = 2.0;
fn fillet_area(radius: f64) -> f64 {
radius * radius * (1.0 - core::f64::consts::FRAC_PI_4)
}
fn contour_area(profile: &Profile) -> f64 {
let section = match profile {
Profile::Section(section) => section,
_ => unreachable!("only sections here"),
};
let contour = section_contour(section).expect("a section lowers");
let mut total = 0.0;
for segment in &contour.outer.segments {
let (from, to) = if segment.same_sense {
(segment.domain.start, segment.domain.end)
} else {
(segment.domain.end, segment.domain.start)
};
match &segment.curve {
Curve2::Line(line) => {
let a = line.origin + line.direction * from;
let b = line.origin + line.direction * to;
total += a.perp_dot(b);
}
Curve2::Circle(circle) => {
let point = |t: f64| {
let (s, c) = t.sin_cos();
circle.frame.origin
+ circle.frame.x * (circle.radius * c)
+ circle.frame.y * (circle.radius * s)
};
let handedness = circle.frame.x.perp_dot(circle.frame.y);
let sweep = (to - from) * handedness.signum();
let a = point(from);
let b = point(to);
total += a.perp_dot(b);
total += circle.radius * circle.radius * (sweep - sweep.sin());
}
other => panic!("unexpected segment kind {other:?}"),
}
}
total / 2.0
}
fn volume_of(profile: &Profile) -> f64 {
let solid = extrude_profile_exact(profile, Vec3::Z, DEPTH, Tolerance::METRE)
.expect("a section extrudes");
let health = geometric_audit(&solid, Tolerance::METRE);
assert!(
health.is_consistent(),
"section solid must audit clean, found {:?}",
health.defects()
);
let expected = contour_area(profile) * DEPTH;
let measured = axiolid_measure::exact_properties(&solid, Tolerance::METRE)
.expect("a section solid is measurable")
.signed_volume;
assert!(
(measured - expected).abs() <= 1e-11 * expected.abs(),
"solid measures {measured}, contour gives {expected}"
);
expected
}
fn i_section(fillet: Option<f64>) -> Profile {
Profile::Section(SectionProfile::I {
depth: 0.4,
width: 0.3,
web_thickness: 0.011,
flange_thickness: 0.019,
fillet_radius: fillet,
flange_edge_radius: None,
flange_slope: None,
})
}
fn i_sloped(fillet: Option<f64>, slope: Option<f64>) -> Profile {
Profile::Section(SectionProfile::I {
depth: 0.4,
width: 0.3,
web_thickness: 0.011,
flange_thickness: 0.019,
fillet_radius: fillet,
flange_edge_radius: None,
flange_slope: slope,
})
}
fn section_of(profile: &Profile) -> &SectionProfile {
match profile {
Profile::Section(section) => section,
_ => panic!("expected a section profile"),
}
}
#[test]
fn a_sharp_i_section_matches_its_closed_form_area() {
let (d, b, tw, tf) = (0.4, 0.3, 0.011, 0.019);
let expected = (2.0 * b * tf + (d - 2.0 * tf) * tw) * DEPTH;
let got = volume_of(&i_section(None));
assert!(
(got - expected).abs() < 1e-12,
"expected {expected}, got {got}"
);
}
#[test]
fn the_root_fillets_carry_real_area() {
let (d, b, tw, tf, r) = (0.4, 0.3, 0.011, 0.019, 0.021);
let sharp = (2.0 * b * tf + (d - 2.0 * tf) * tw) * DEPTH;
let expected = sharp + 4.0 * fillet_area(r) * DEPTH;
let got = volume_of(&i_section(Some(r)));
assert!(
(got - expected).abs() < 1e-12,
"expected {expected}, got {got}"
);
let share = (expected - sharp) / expected;
assert!(
(share - 0.024).abs() < 0.001,
"root fillets should be about 2.4% of the section, got {share}"
);
assert!(got > sharp, "fillets must ADD material, not remove it");
}
#[test]
fn a_filleted_section_carries_exact_cylindrical_walls() {
let solid = extrude_profile_exact(&i_section(Some(0.021)), Vec3::Z, DEPTH, Tolerance::METRE)
.expect("a filleted I extrudes");
let radii: Vec<f64> = solid
.surfaces()
.iter()
.filter_map(|s| match s {
Surface::Cylinder(c) => Some(c.radius),
_ => None,
})
.collect();
assert_eq!(radii.len(), 4, "four root fillets, got {radii:?}");
for radius in &radii {
assert!(
(radius - 0.021).abs() < 1e-12,
"each fillet must carry the stated radius, got {radius}"
);
}
}
#[test]
fn a_t_section_matches_its_closed_form_area() {
let (d, bf, tw, tf, r) = (0.3, 0.2, 0.01, 0.015, 0.012);
let profile = Profile::Section(SectionProfile::T {
depth: d,
flange_width: bf,
web_thickness: tw,
flange_thickness: tf,
fillet_radius: Some(r),
flange_edge_radius: None,
web_edge_radius: None,
web_slope: None,
flange_slope: None,
});
let expected = (bf * tf + (d - tf) * tw + 2.0 * fillet_area(r)) * DEPTH;
let got = volume_of(&profile);
assert!(
(got - expected).abs() < 1e-12,
"expected {expected}, got {got}"
);
}
#[test]
fn a_u_section_matches_its_closed_form_area() {
let (d, bf, tw, tf, r) = (0.3, 0.1, 0.0075, 0.0125, 0.01);
let profile = Profile::Section(SectionProfile::U {
depth: d,
flange_width: bf,
web_thickness: tw,
flange_thickness: tf,
fillet_radius: Some(r),
edge_radius: None,
flange_slope: None,
});
let expected = (d * tw + 2.0 * (bf - tw) * tf + 2.0 * fillet_area(r)) * DEPTH;
let got = volume_of(&profile);
assert!(
(got - expected).abs() < 1e-12,
"expected {expected}, got {got}"
);
}
#[test]
fn an_l_section_matches_its_closed_form_area() {
let (d, w, t, r) = (0.15, 0.1, 0.012, 0.009);
let profile = Profile::Section(SectionProfile::L {
depth: d,
width: Some(w),
thickness: t,
fillet_radius: Some(r),
edge_radius: None,
leg_slope: None,
});
let expected = (w * t + (d - t) * t + fillet_area(r)) * DEPTH;
let got = volume_of(&profile);
assert!(
(got - expected).abs() < 1e-12,
"expected {expected}, got {got}"
);
}
#[test]
fn an_absent_l_width_means_an_equal_angle() {
let (d, t) = (0.12, 0.01);
let implied = Profile::Section(SectionProfile::L {
depth: d,
width: None,
thickness: t,
fillet_radius: None,
edge_radius: None,
leg_slope: None,
});
let stated = Profile::Section(SectionProfile::L {
depth: d,
width: Some(d),
thickness: t,
fillet_radius: None,
edge_radius: None,
leg_slope: None,
});
let a = volume_of(&implied);
let b = volume_of(&stated);
assert!((a - b).abs() < 1e-15, "{a} vs {b}");
}
#[test]
fn a_trapezium_matches_its_closed_form_area() {
let (bottom, top, y, offset) = (0.4, 0.25, 0.2, 0.05);
let profile = Profile::Section(SectionProfile::Trapezium {
bottom_x: bottom,
top_x: top,
y,
top_offset: offset,
});
let expected = (0.5 * (bottom + top) * y) * DEPTH;
let got = volume_of(&profile);
assert!(
(got - expected).abs() < 1e-12,
"expected {expected}, got {got}"
);
}
#[test]
fn an_asymmetric_i_keeps_its_two_flange_widths() {
let make = |top: f64| {
Profile::Section(SectionProfile::AsymmetricI {
depth: 0.4,
web_thickness: 0.011,
bottom_flange_width: 0.3,
bottom_flange_thickness: 0.019,
bottom_fillet_radius: None,
bottom_flange_edge_radius: None,
bottom_flange_slope: None,
top_flange_width: top,
top_flange_thickness: Some(0.019),
top_fillet_radius: None,
top_flange_edge_radius: None,
top_flange_slope: None,
})
};
let narrow = volume_of(&make(0.2));
let wide = volume_of(&make(0.3));
let expected = (0.3 - 0.2) * 0.019 * DEPTH;
assert!(
((wide - narrow) - expected).abs() < 1e-12,
"expected {expected}, got {}",
wide - narrow
);
}
#[test]
fn an_absent_top_thickness_means_the_bottom_thickness() {
let make = |top: Option<f64>| {
Profile::Section(SectionProfile::AsymmetricI {
depth: 0.4,
web_thickness: 0.011,
bottom_flange_width: 0.3,
bottom_flange_thickness: 0.019,
bottom_fillet_radius: None,
bottom_flange_edge_radius: None,
bottom_flange_slope: None,
top_flange_width: 0.25,
top_flange_thickness: top,
top_fillet_radius: None,
top_flange_edge_radius: None,
top_flange_slope: None,
})
};
let a = volume_of(&make(None));
let b = volume_of(&make(Some(0.019)));
assert!(
(a - b).abs() < 1e-15,
"absent must mean the bottom value: {a} vs {b}"
);
}
#[test]
fn a_z_section_matches_its_closed_form_area() {
let (d, bf, tw, tf, r) = (0.2, 0.075, 0.008, 0.012, 0.008);
let profile = Profile::Section(SectionProfile::Z {
depth: d,
flange_width: bf,
web_thickness: tw,
flange_thickness: tf,
fillet_radius: Some(r),
edge_radius: None,
});
let expected = (d * tw + 2.0 * bf * tf + 2.0 * fillet_area(r)) * DEPTH;
let got = volume_of(&profile);
assert!(
(got - expected).abs() < 1e-12,
"expected {expected}, got {got}"
);
}
#[test]
fn a_c_section_encloses_its_wall_not_its_envelope() {
let (d, w, t, g) = (0.2, 0.075, 0.002, 0.02);
let profile = Profile::Section(SectionProfile::C {
depth: d,
width: w,
wall_thickness: t,
girth: g,
internal_fillet_radius: None,
});
let got = volume_of(&profile);
let envelope = d * w * DEPTH;
assert!(
got < envelope * 0.5,
"a thin wall must be far less than the envelope {envelope}, got {got}"
);
let wall = (d + 2.0 * (w - t) + 2.0 * (g - t)) * t * DEPTH;
assert!(
(got - wall).abs() < wall * 0.05,
"expected about {wall}, got {got}"
);
}
#[test]
fn a_declared_zero_slope_is_not_a_taper() {
let profile = Profile::Section(SectionProfile::I {
depth: 0.4,
width: 0.3,
web_thickness: 0.011,
flange_thickness: 0.019,
fillet_radius: None,
flange_edge_radius: None,
flange_slope: Some(0.0),
});
assert!(extrude_profile_exact(&profile, Vec3::Z, DEPTH, Tolerance::METRE).is_ok());
}
#[test]
fn impossible_dimensions_are_refused() {
let no_web = Profile::Section(SectionProfile::I {
depth: 0.03,
width: 0.3,
web_thickness: 0.011,
flange_thickness: 0.019,
fillet_radius: None,
flange_edge_radius: None,
flange_slope: None,
});
let fat_web = Profile::Section(SectionProfile::I {
depth: 0.4,
width: 0.01,
web_thickness: 0.011,
flange_thickness: 0.019,
fillet_radius: None,
flange_edge_radius: None,
flange_slope: None,
});
for profile in [no_web, fat_web] {
assert!(extrude_profile_exact(&profile, Vec3::Z, DEPTH, Tolerance::METRE).is_err());
}
}
#[test]
fn a_fillet_too_large_for_its_edge_is_refused() {
let profile = i_section(Some(0.5));
let error = extrude_profile_exact(&profile, Vec3::Z, DEPTH, Tolerance::METRE)
.expect_err("the radius does not fit");
assert!(
format!("{error:?}").contains("too large for the edge"),
"got {error:?}"
);
}
#[test]
fn a_tapered_flange_keeps_the_declared_mean_thickness() {
let parallel = i_sloped(None, None);
let tapered = i_sloped(None, Some(8.0_f64.to_radians()));
let flat = contour_area(¶llel);
let sloped = contour_area(&tapered);
assert!(
(flat - sloped).abs() < 1e-12,
"taper must preserve the mean thickness: {flat} vs {sloped}"
);
}
#[test]
fn a_tapered_flange_actually_slopes() {
let contour = section_contour(section_of(&i_sloped(None, Some(8.0_f64.to_radians()))))
.expect("tapered lowers");
let parallel = section_contour(section_of(&i_sloped(None, None))).expect("parallel lowers");
let heights = |profile: &axiolid_profile::ContourProfile| -> Vec<f64> {
profile
.outer
.segments
.iter()
.filter_map(|segment| match &segment.curve {
Curve2::Line(line) => Some(line.origin.y),
_ => None,
})
.collect()
};
assert_ne!(
heights(&contour),
heights(¶llel),
"a declared taper must change the outline"
);
}
#[test]
fn a_tapered_fillet_stays_tangent_to_the_inclined_face() {
let radius = 0.021;
let tapered = i_sloped(Some(radius), Some(8.0_f64.to_radians()));
let contour = section_contour(section_of(&tapered)).expect("tapered lowers");
let arcs: Vec<_> = contour
.outer
.segments
.iter()
.filter_map(|segment| match &segment.curve {
Curve2::Circle(circle) => Some(*circle),
_ => None,
})
.collect();
assert_eq!(arcs.len(), 4, "four root fillets, got {}", arcs.len());
for arc in &arcs {
assert!(
(arc.radius - radius).abs() < 1e-12,
"fillet radius {} should be {radius}",
arc.radius
);
}
let segments = &contour.outer.segments;
for (index, segment) in segments.iter().enumerate() {
let Curve2::Circle(arc) = &segment.curve else {
continue;
};
let before = &segments[(index + segments.len() - 1) % segments.len()];
let after = &segments[(index + 1) % segments.len()];
for neighbour in [before, after] {
let Curve2::Line(line) = &neighbour.curve else {
continue;
};
let direction = line.direction.normalize();
let to_centre = arc.frame.origin - line.origin;
let distance = (to_centre - direction * to_centre.dot(direction)).length();
assert!(
(distance - radius).abs() < 1e-9,
"fillet must sit one radius from its ADJACENT face, got {distance}"
);
}
}
}
#[test]
fn an_absurdly_steep_slope_is_refused() {
let section = i_sloped(None, Some(1.5));
assert!(
section_contour(section_of(§ion)).is_err(),
"a near-right-angle taper leaves no flange and must be refused"
);
}