From 401898ae9b1ccbfbb7c8cbfacffdd9f9e0bd8ccf Mon Sep 17 00:00:00 2001 From: Lewis Date: Wed, 8 Jul 2026 12:28:30 +0300 Subject: [PATCH] sweep kernel tests Lewis: May this revision serve well! --- crates/bone-kernel/tests/sweep.rs | 1524 ++++++++++++++++++++++++----- 1 file changed, 1264 insertions(+), 260 deletions(-) diff --git a/crates/bone-kernel/tests/sweep.rs b/crates/bone-kernel/tests/sweep.rs index fa6a9ff..73ba5e3 100644 --- a/crates/bone-kernel/tests/sweep.rs +++ b/crates/bone-kernel/tests/sweep.rs @@ -1,15 +1,19 @@ use bone_kernel::{ - BrepError, BrepSolid, Circle3, Curve3Kind, ExtrudeProfile, Line2, Line3, ProfileDefect, - ProfileEdge, ProfileLoop, SweepFeature, SweepOrientation, SweepPath, SweepProfileKind, ThinWall, - ThinWallDirection, TruckGap, evaluate_sweep, + Arc2, Arc3, BrepError, BrepSolid, Circle3, Curve2Kind, Curve3Kind, ExtrudeProfile, Line2, + Line3, ProfileDefect, ProfileEdge, ProfileLoop, SweepDefect, SweepDirection, SweepFeature, + SweepOrientation, SweepPath, SweepProfileKind, SweepRails, SweepTangency, SweepTwist, ThinWall, + ThinWallDirection, TruckGap, TwistUnit, evaluate_sweep, }; use bone_types::{ - AngleTolerance, ChordHeightTolerance, FeatureId, Length, Plane3, Point2, Point3, - PositiveLength, SketchEntityId, SketchId, Tolerance, UnitVec3, millimeter, + Angle, AngleTolerance, BodyOrdinal, BodyRef, ChordHeightTolerance, FaceRole, FeatureId, Length, + Plane3, Point2, Point3, PositiveLength, SketchEntityId, SketchId, Tolerance, UnitVec3, + millimeter, radian, }; -use core::f64::consts::TAU; +use core::f64::consts::{FRAC_PI_2, PI, TAU}; use slotmap::{Key, SlotMap}; +const TOLERANCE: Tolerance = Tolerance::new(1.0e-9); + fn sweep_gap(result: &Result) -> Option { match result { Err(BrepError::TruckUnsupported { detail }) => Some(*detail), @@ -17,7 +21,19 @@ fn sweep_gap(result: &Result) -> Option { } } -const TOLERANCE: Tolerance = Tolerance::new(1.0e-9); +fn sweep_defect(result: &Result) -> Option { + match result { + Err(BrepError::InvalidSweep { reason }) => Some(*reason), + _ => None, + } +} + +fn profile_defect(result: &Result) -> Option { + match result { + Err(BrepError::InvalidProfile { reason }) => Some(*reason), + _ => None, + } +} struct Ids { features: SlotMap, @@ -45,6 +61,10 @@ fn point(x: f64, y: f64) -> Point2 { Point2::from_mm(x, y) } +fn pt(x: f64, y: f64, z: f64) -> Point3 { + Point3::from_mm(x, y, z) +} + fn line(a: Point2, b: Point2) -> bone_kernel::Curve2Kind { let Ok(segment) = Line2::new(a, b, TOLERANCE) else { panic!("line endpoints are distinct"); @@ -67,6 +87,20 @@ fn xy_plane() -> Plane3 { plane(Point3::origin(), UnitVec3::x_axis(), UnitVec3::y_axis()) } +fn ring(ids: &mut Ids, corners: &[Point2], closed: bool) -> Vec { + let n = corners.len(); + let count = if closed { n } else { n - 1 }; + (0..count) + .map(|i| { + ProfileEdge::new( + line(corners[i], corners[(i + 1) % n]), + ids.entity(), + ids.entity(), + ) + }) + .collect() +} + fn rectangle(ids: &mut Ids, x0: f64, y0: f64, width: f64, height: f64) -> ProfileLoop { let corners = [ point(x0, y0), @@ -74,14 +108,7 @@ fn rectangle(ids: &mut Ids, x0: f64, y0: f64, width: f64, height: f64) -> Profil point(x0 + width, y0 + height), point(x0, y0 + height), ]; - let edges = (0..4) - .map(|index| { - let start = corners[index]; - let end = corners[(index + 1) % 4]; - ProfileEdge::new(line(start, end), ids.entity(), ids.entity()) - }) - .collect(); - ProfileLoop::Open(edges) + ProfileLoop::Open(ring(ids, &corners, true)) } fn rectangle_cw(ids: &mut Ids, x0: f64, y0: f64, width: f64, height: f64) -> ProfileLoop { @@ -91,32 +118,27 @@ fn rectangle_cw(ids: &mut Ids, x0: f64, y0: f64, width: f64, height: f64) -> Pro point(x0 + width, y0 + height), point(x0 + width, y0), ]; - let edges = (0..4) - .map(|index| { - let start = corners[index]; - let end = corners[(index + 1) % 4]; - ProfileEdge::new(line(start, end), ids.entity(), ids.entity()) - }) - .collect(); - ProfileLoop::Open(edges) + ProfileLoop::Open(ring(ids, &corners, true)) } fn open_chain(ids: &mut Ids) -> ProfileLoop { - let corners = [point(0.0, 0.0), point(2.0, 0.0), point(2.0, 1.0), point(0.0, 1.0)]; - let edges = (0..3) - .map(|index| ProfileEdge::new(line(corners[index], corners[index + 1]), ids.entity(), ids.entity())) - .collect(); - ProfileLoop::Chain(edges) + let corners = [ + point(0.0, 0.0), + point(2.0, 0.0), + point(2.0, 1.0), + point(0.0, 1.0), + ]; + ProfileLoop::Chain(ring(ids, &corners, false)) } fn bowtie(ids: &mut Ids) -> ProfileLoop { - let corners = [point(0.0, 0.0), point(2.0, 2.0), point(2.0, 0.0), point(0.0, 2.0)]; - let edges = (0..4) - .map(|index| { - ProfileEdge::new(line(corners[index], corners[(index + 1) % 4]), ids.entity(), ids.entity()) - }) - .collect(); - ProfileLoop::Open(edges) + let corners = [ + point(0.0, 0.0), + point(2.0, 2.0), + point(2.0, 0.0), + point(0.0, 2.0), + ]; + ProfileLoop::Open(ring(ids, &corners, true)) } fn disc(ids: &mut Ids, radius_mm: f64) -> ProfileLoop { @@ -133,6 +155,53 @@ fn disc(ids: &mut Ids, radius_mm: f64) -> ProfileLoop { } } +fn disc_as_arcs(ids: &mut Ids, radius_mm: f64) -> ProfileLoop { + let edges = (0..4) + .map(|quadrant| { + let start = FRAC_PI_2 * f64::from(quadrant); + let Ok(arc) = Arc2::new( + Point2::origin(), + Length::new::(radius_mm), + Angle::new::(start), + Angle::new::(FRAC_PI_2), + TOLERANCE, + ) else { + panic!("quarter arc is valid"); + }; + ProfileEdge::new(Curve2Kind::Arc(arc), ids.entity(), ids.entity()) + }) + .collect(); + ProfileLoop::Open(edges) +} + +fn ellipse_loop(ids: &mut Ids, semi_major_mm: f64, semi_minor_mm: f64) -> ProfileLoop { + let Ok(ellipse) = bone_kernel::Ellipse2::new( + Point2::origin(), + Length::new::(semi_major_mm), + Length::new::(semi_minor_mm), + Angle::new::(0.0), + TOLERANCE, + ) else { + panic!("ellipse axes are positive"); + }; + ProfileLoop::Closed { + curve: bone_kernel::Curve2Kind::Ellipse(ellipse), + curve_entity: ids.entity(), + } +} + +fn xy_rect(ids: &mut Ids, x: f64, y: f64, w: f64, h: f64) -> ExtrudeProfile { + ExtrudeProfile::new(xy_plane(), vec![rectangle(ids, x, y, w, h)]) +} + +fn xz_rect(ids: &mut Ids, x: f64, y: f64, w: f64, h: f64) -> ExtrudeProfile { + ExtrudeProfile::new(xz_plane(), vec![rectangle(ids, x, y, w, h)]) +} + +fn xy_loop(loop_: ProfileLoop) -> ExtrudeProfile { + ExtrudeProfile::new(xy_plane(), vec![loop_]) +} + fn circle_path(normal_plane: Plane3, radius_mm: f64) -> Curve3Kind { let Ok(circle) = Circle3::new( normal_plane, @@ -151,12 +220,107 @@ fn line_path(start: Point3, end: Point3) -> Curve3Kind { segment.as_kind() } +fn zline(len: f64) -> Vec { + vec![line_path(Point3::origin(), pt(0.0, 0.0, len))] +} + +fn xline(len: f64) -> Vec { + vec![line_path(Point3::origin(), pt(len, 0.0, 0.0))] +} + +fn arc3(base: Plane3, radius_mm: f64, start_rad: f64, sweep_rad: f64) -> Arc3 { + let Ok(arc) = Arc3::new( + base, + Length::new::(radius_mm), + Angle::new::(start_rad), + Angle::new::(sweep_rad), + TOLERANCE, + ) else { + panic!("arc parameters are valid"); + }; + arc +} + +fn arc_path_xz(center: Point3, radius_mm: f64, start_rad: f64, sweep_rad: f64) -> Curve3Kind { + let arc_plane = plane(center, UnitVec3::x_axis(), UnitVec3::z_axis()); + arc3(arc_plane, radius_mm, start_rad, sweep_rad).as_kind() +} + +fn arc10() -> [Curve3Kind; 1] { + [arc_path_xz(pt(10.0, 0.0, 0.0), 10.0, PI, -FRAC_PI_2)] +} + +fn line_arc_line_bend(radius: f64, rise: f64, run: f64) -> [Curve3Kind; 3] { + [ + line_path(Point3::origin(), pt(0.0, 0.0, rise)), + arc_path_xz(pt(radius, 0.0, rise), radius, PI, -FRAC_PI_2), + line_path( + pt(radius, 0.0, rise + radius), + pt(radius + run, 0.0, rise + radius), + ), + ] +} + +fn right_angle_corner_path() -> [Curve3Kind; 2] { + [ + line_path(Point3::origin(), pt(0.0, 0.0, 4.0)), + line_path(pt(0.0, 0.0, 4.0), pt(4.0, 0.0, 4.0)), + ] +} + +fn line_arc_corner_path() -> [Curve3Kind; 2] { + let Ok(line) = Line3::new(Point3::origin(), pt(2.0, 0.0, 0.0), TOLERANCE) else { + panic!("distinct endpoints"); + }; + let arc_plane = plane(pt(1.0, 0.0, 0.0), UnitVec3::x_axis(), UnitVec3::y_axis()); + [ + Curve3Kind::Line(line), + arc3(arc_plane, 1.0, 0.0, FRAC_PI_2).as_kind(), + ] +} + +fn two_arc_circle(radius_mm: f64) -> [Curve3Kind; 2] { + [ + arc3(xy_plane(), radius_mm, 0.0, PI).as_kind(), + arc3(xy_plane(), radius_mm, PI, PI).as_kind(), + ] +} + +fn off_perpendicular_path() -> [Curve3Kind; 1] { + [line_path(Point3::origin(), pt(4.0, 0.0, 0.0))] +} + +fn out_of_plane_bend() -> [Curve3Kind; 2] { + let first_plane = plane(pt(0.0, 10.0, 0.0), UnitVec3::x_axis(), UnitVec3::y_axis()); + let second_plane = plane(pt(10.0, 10.0, 5.0), UnitVec3::y_axis(), UnitVec3::z_axis()); + [ + arc3(first_plane, 10.0, -FRAC_PI_2, FRAC_PI_2).as_kind(), + arc3(second_plane, 5.0, -FRAC_PI_2, FRAC_PI_2).as_kind(), + ] +} + +fn azimuth_arc_guide() -> Vec { + let Ok(tilt) = bone_types::Vec3::from_mm(0.0, 1.0, 1.0).try_normalize(TOLERANCE) else { + panic!("nonzero tilt axis"); + }; + let guide_plane = plane(pt(0.0, 0.0, 4.0), UnitVec3::x_axis(), tilt); + let radius = 50.0_f64.sqrt(); + let reach = (0.8_f64).asin(); + vec![arc3(guide_plane, radius, -reach, 2.0 * reach).as_kind()] +} + fn boss(profile_kind: SweepProfileKind, orientation: SweepOrientation) -> SweepFeature { SweepFeature { profile: SketchId::null(), path: SweepPath::Sketch(SketchId::null()), + guides: Vec::new(), profile_kind, orientation, + direction: SweepDirection::DirectionOne, + start_tangency: SweepTangency::None, + end_tangency: SweepTangency::None, + alignment: bone_kernel::SweepAlignment::MinimumTwist, + twist: None, thin_wall: None, flip_side: false, operation: bone_kernel::ExtrudeOperation::default(), @@ -164,14 +328,168 @@ fn boss(profile_kind: SweepProfileKind, orientation: SweepOrientation) -> SweepF } } +fn follow() -> SweepFeature { + boss(SweepProfileKind::Sketch, SweepOrientation::FollowPath) +} + +fn keep_normal() -> SweepFeature { + boss( + SweepProfileKind::Sketch, + SweepOrientation::KeepNormalConstant, + ) +} + +fn directed(direction: SweepDirection) -> SweepFeature { + SweepFeature { + direction, + ..follow() + } +} + +fn all_faces() -> SweepFeature { + SweepFeature { + alignment: bone_kernel::SweepAlignment::AllFaces, + ..follow() + } +} + +fn positive_mm(value_mm: f64) -> PositiveLength { + let Ok(value) = PositiveLength::new(Length::new::(value_mm)) else { + panic!("{value_mm} mm is a positive length"); + }; + value +} + +fn circular_boss(diameter_mm: f64) -> SweepFeature { + boss( + SweepProfileKind::Circular { + diameter: positive_mm(diameter_mm), + }, + SweepOrientation::FollowPath, + ) +} + +fn circular_tube(diameter_mm: f64, wall_mm: f64) -> SweepFeature { + SweepFeature { + thin_wall: Some(thin_spec(ThinWallDirection::Inward, wall_mm, None, false)), + ..circular_boss(diameter_mm) + } +} + +fn thin_spec( + direction: ThinWallDirection, + wall_mm: f64, + second_mm: Option, + cap_ends: bool, +) -> ThinWall { + ThinWall { + thickness: positive_mm(wall_mm), + direction, + thickness2: second_mm.map(positive_mm), + cap_ends, + auto_fillet: None, + } +} + +fn thin_boss(thin: ThinWall) -> SweepFeature { + SweepFeature { + thin_wall: Some(thin), + ..follow() + } +} + +fn twist(degrees: f64) -> SweepTwist { + SweepTwist::new( + Angle::new::(degrees.to_radians()), + TwistUnit::Degrees, + ) +} + +fn twist_rad(radians: f64) -> SweepTwist { + SweepTwist::new(Angle::new::(radians), TwistUnit::Radians) +} + +fn direction_vector() -> bone_kernel::SweepAlignment { + bone_kernel::SweepAlignment::DirectionVector { + axis: bone_kernel::RevolveAxis::SketchLine(SketchEntityId::null()), + } +} + +fn dir_rails(direction: UnitVec3) -> SweepRails<'static> { + SweepRails { + guides: &[], + direction: Some(direction), + tool: None, + bodies: &[], + } +} + +fn body_rails<'a>(bodies: &'a [&'a BrepSolid]) -> SweepRails<'a> { + SweepRails { + guides: &[], + direction: None, + tool: None, + bodies, + } +} + +fn mid_line_setup(ids: &mut Ids, plane_z: f64) -> (ExtrudeProfile, [Curve3Kind; 1]) { + let section = plane( + pt(0.0, 0.0, plane_z), + UnitVec3::x_axis(), + UnitVec3::y_axis(), + ); + let profile = ExtrudeProfile::new(section, vec![rectangle(ids, 0.0, 0.0, 2.0, 1.0)]); + (profile, [line_path(Point3::origin(), pt(0.0, 0.0, 10.0))]) +} + +fn cylinder_tool(ids: &mut Ids, start: Point3, end: Point3, diameter_mm: f64) -> BrepSolid { + let profile = xy_rect(ids, 0.0, 0.0, 1.0, 1.0); + build( + ids, + &profile, + &[line_path(start, end)], + &circular_boss(diameter_mm), + ) +} + +fn tool_rails(tool: &BrepSolid) -> SweepRails<'_> { + SweepRails { + guides: &[], + direction: None, + tool: Some(tool), + bodies: &[], + } +} + +fn solid_tool_cut() -> SweepFeature { + SweepFeature { + operation: bone_kernel::ExtrudeOperation::Cut, + ..boss( + SweepProfileKind::Solid { + tool: BodyRef::new(FeatureId::default(), BodyOrdinal::origin()), + }, + SweepOrientation::FollowPath, + ) + } +} + +fn eval( + ids: &mut Ids, + profile: &ExtrudeProfile, + path: &[Curve3Kind], + feature: &SweepFeature, +) -> Result { + evaluate_sweep(ids.feature(), profile, path, SweepRails::default(), feature) +} + fn build( ids: &mut Ids, profile: &ExtrudeProfile, path: &[Curve3Kind], feature: &SweepFeature, ) -> BrepSolid { - let sweep = ids.feature(); - let solid = match evaluate_sweep(sweep, profile, path, feature) { + let solid = match eval(ids, profile, path, feature) { Ok(solid) => solid, Err(error) => panic!("the profile and path describe a buildable sweep: {error:?}"), }; @@ -182,10 +500,38 @@ fn build( solid } +fn build_guided( + ids: &mut Ids, + profile: &ExtrudeProfile, + path: &[Curve3Kind], + guides: &[Vec], + feature: &SweepFeature, +) -> BrepSolid { + let rails = SweepRails { + guides, + direction: None, + tool: None, + bodies: &[], + }; + let solid = match evaluate_sweep(ids.feature(), profile, path, rails, feature) { + Ok(solid) => solid, + Err(error) => panic!("the guided sweep builds: {error:?}"), + }; + assert!( + solid.validate(TOLERANCE).is_ok(), + "guided sweep is a closed manifold solid" + ); + solid +} + fn mesh_volume(solid: &BrepSolid) -> f64 { + mesh_volume_at(solid, 0.0002, 0.01) +} + +fn mesh_volume_at(solid: &BrepSolid, chord_mm: f64, angle_rad: f64) -> f64 { let Ok(mesh) = solid.tessellate( - ChordHeightTolerance::from_mm(0.0002), - AngleTolerance::from_radians(0.01), + ChordHeightTolerance::from_mm(chord_mm), + AngleTolerance::from_radians(angle_rad), ) else { panic!("a valid sweep tessellates"); }; @@ -219,332 +565,990 @@ fn dump(solid: &BrepSolid) -> String { ) } -#[test] -fn circular_path_sweep_revolves_the_profile_about_the_arc_axis() { +fn face_census(solid: &BrepSolid) -> (usize, usize, usize) { + solid + .iter_faces() + .fold((0, 0, 0), |(starts, ends, sides), face| { + match face.label().role { + FaceRole::StartCap => (starts + 1, ends, sides), + FaceRole::EndCap => (starts, ends + 1, sides), + FaceRole::Side { .. } => (starts, ends, sides + 1), + FaceRole::Imported { .. } => (starts, ends, sides), + } + }) +} + +fn max_z(solid: &BrepSolid) -> f64 { + solid + .iter_vertices() + .map(|vertex| vertex.position().coords_mm().2) + .fold(f64::MIN, f64::max) +} + +fn max_y(solid: &BrepSolid) -> f64 { + solid + .iter_vertices() + .map(|vertex| vertex.position().coords_mm().1) + .fold(f64::MIN, f64::max) +} + +type Fixture = dyn FnOnce(&mut Ids) -> (ExtrudeProfile, Vec, SweepFeature); + +fn vol(setup: Box, expected: f64, tol: f64) { + vol_at(setup, expected, tol, 0.0002, 0.01); +} + +fn vol_at(setup: Box, expected: f64, tol: f64, chord: f64, angle: f64) { let mut ids = Ids::new(); - let profile = ExtrudeProfile::new(xz_plane(), vec![rectangle(&mut ids, 2.0, 0.0, 1.0, 1.0)]); - let path = [circle_path(xy_plane(), 2.5)]; - let solid = build( - &mut ids, - &profile, - &path, - &boss(SweepProfileKind::Sketch, SweepOrientation::FollowPath), - ); - // Pappus: a unit square at radius 2..3 (centroid 2.5) swept a full turn. - let expected = 1.0 * TAU * 2.5; + let (profile, path, feature) = setup(&mut ids); + let solid = build(&mut ids, &profile, &path, &feature); + let volume = mesh_volume_at(&solid, chord, angle).abs(); assert!( - (mesh_volume(&solid).abs() - expected).abs() < 0.05, - "full circular sweep matches the Pappus volume: got {}", - mesh_volume(&solid).abs() + (volume - expected).abs() < tol, + "a swept solid matches its analytic volume: got {volume}, want {expected}" ); } -#[test] -fn a_circular_profile_along_a_line_is_a_cylinder() { +fn expect_defect(setup: Box, expected: SweepDefect) { let mut ids = Ids::new(); - let profile = ExtrudeProfile::new(xy_plane(), vec![rectangle(&mut ids, 0.0, 0.0, 2.0, 1.0)]); - let path = [line_path(Point3::origin(), Point3::from_mm(0.0, 0.0, 5.0))]; - let Ok(diameter) = PositiveLength::new(Length::new::(2.0)) else { - panic!("2 mm is a positive length"); - }; - let solid = build( - &mut ids, - &profile, - &path, - &boss( - SweepProfileKind::Circular { diameter }, - SweepOrientation::FollowPath, + let (profile, path, feature) = setup(&mut ids); + let result = eval(&mut ids, &profile, &path, &feature); + assert_eq!( + sweep_defect(&result), + Some(expected), + "an invalid sweep configuration reports its typed defect, not a fabricated solid" + ); +} + +#[test] +#[rustfmt::skip] +fn smooth_reductions_match_analytic_volume() { + let area = 1.0 * 1.0 - 0.7 * 0.7; + let bend20 = 3.0 + 20.0 * FRAC_PI_2 + 3.0; + let bend13 = 3.0 + 1.3 * FRAC_PI_2 + 3.0; + vol(Box::new(|i| (xz_rect(i, 2.0, 0.0, 1.0, 1.0), vec![circle_path(xy_plane(), 2.5)], follow())), TAU * 2.5, 0.05); + vol(Box::new(|i| (xy_rect(i, 0.0, 0.0, 2.0, 1.0), zline(5.0), circular_boss(2.0))), PI * 5.0, 0.05); + vol(Box::new(|i| (xy_rect(i, 0.0, 0.0, 1.0, 1.0), vec![circle_path(xy_plane(), 5.0)], circular_boss(2.0))), 2.0 * PI * PI * 5.0, 2.0 * PI * PI * 5.0 * 0.01); + vol(Box::new(|i| (xy_rect(i, 0.0, 0.0, 1.0, 1.0), vec![arc_path_xz(Point3::origin(), 5.0, 0.0, FRAC_PI_2)], circular_boss(2.0))), PI * 5.0 * FRAC_PI_2, PI * 5.0 * FRAC_PI_2 * 0.01); + vol(Box::new(|i| (xy_rect(i, 0.0, 0.0, 1.0, 1.0), zline(5.0), circular_tube(2.0, 0.3))), PI * area * 5.0, PI * area * 5.0 * 0.01); + vol(Box::new(|i| (xy_rect(i, 0.0, 0.0, 1.0, 1.0), vec![circle_path(xy_plane(), 5.0)], circular_tube(2.0, 0.3))), 2.0 * PI * PI * 5.0 * area, 2.0 * PI * PI * 5.0 * area * 0.02); + vol(Box::new(|i| (xy_loop(disc_as_arcs(i, 1.0)), xline(4.0), follow())), PI * 4.0, PI * 4.0 * 0.02); + vol(Box::new(|i| (xy_loop(disc(i, 1.0)), zline(5.0), follow())), PI * 5.0, 0.02); + vol(Box::new(|i| (xy_rect(i, 0.0, 0.0, 2.0, 1.0), zline(4.0), follow())), 8.0, 1.0e-6); + vol(Box::new(|i| (xy_rect(i, 0.0, 0.0, 2.0, 1.0), off_perpendicular_path().to_vec(), follow())), 8.0, 1.0e-2); + vol(Box::new(|i| (xy_loop(disc(i, 1.0)), off_perpendicular_path().to_vec(), follow())), PI * 4.0, PI * 4.0 * 0.02); + vol(Box::new(|i| (xy_loop(ellipse_loop(i, 2.0, 1.0)), off_perpendicular_path().to_vec(), follow())), PI * 2.0 * 4.0, PI * 2.0 * 4.0 * 0.02); + vol(Box::new(|i| { let outer = rectangle(i, 0.0, 0.0, 4.0, 4.0); let hole = rectangle_cw(i, 1.0, 1.0, 2.0, 2.0); (ExtrudeProfile::new(xy_plane(), vec![outer, hole]), off_perpendicular_path().to_vec(), follow()) }), 4.0f64.mul_add(4.0, -(2.0 * 2.0)) * 4.0, 1.0e-2); + vol_at(Box::new(|i| (xy_rect(i, -1.0, -0.5, 2.0, 1.0), line_arc_line_bend(20.0, 3.0, 3.0).to_vec(), follow())), 2.0 * bend20, 2.0 * bend20 * 0.02, 0.1, 0.15); + vol_at(Box::new(|i| (xy_rect(i, 0.0, 0.0, 1.0, 1.0), line_arc_line_bend(1.3, 3.0, 3.0).to_vec(), circular_boss(2.0))), PI * bend13, PI * bend13 * 0.05, 0.02, 0.03); + vol_at(Box::new(|i| (xy_rect(i, 0.0, 0.0, 1.0, 1.0), line_arc_line_bend(20.0, 3.0, 3.0).to_vec(), circular_boss(2.0))), PI * bend20, PI * bend20 * 0.03, 0.05, 0.05); +} + +#[test] +#[rustfmt::skip] +fn miter_reductions_match_analytic_volume() { + let staircase = vec![ + line_path(Point3::origin(), pt(0.0, 0.0, 3.0)), + line_path(pt(0.0, 0.0, 3.0), pt(3.0, 0.0, 3.0)), + line_path(pt(3.0, 0.0, 3.0), pt(3.0, 0.0, 6.0)), + ]; + let oblique_path = vec![ + line_path(Point3::origin(), pt(0.0, 0.0, 4.0)), + line_path(pt(0.0, 0.0, 4.0), pt(4.0, 0.0, 8.0)), + ]; + let oblique = 4.0 * (4.0 + (4.0_f64 * 4.0 + 4.0 * 4.0).sqrt()); + let yz = plane(Point3::origin(), UnitVec3::y_axis(), UnitVec3::z_axis()); + vol(Box::new(move |i| (xy_rect(i, -0.5, -0.5, 1.0, 1.0), staircase, follow())), 9.0, 9.0 * 0.005); + vol(Box::new(|i| (xy_rect(i, 0.0, 0.0, 1.0, 1.0), right_angle_corner_path().to_vec(), circular_boss(2.0))), PI * 8.0, PI * 8.0 * 0.01); + vol(Box::new(move |i| (xy_rect(i, -1.0, -1.0, 2.0, 2.0), oblique_path, follow())), oblique, oblique * 0.005); + vol_at(Box::new(move |i| (ExtrudeProfile::new(yz, vec![rectangle(i, -0.2, -0.2, 0.4, 0.4)]), line_arc_corner_path().to_vec(), follow())), 0.16 * (2.0 + FRAC_PI_2), 0.16 * (2.0 + FRAC_PI_2) * 0.035, 0.003, 0.02); + vol(Box::new(|i| (xy_loop(ellipse_loop(i, 2.0, 1.0)), right_angle_corner_path().to_vec(), follow())), PI * 2.0 * 8.0, PI * 2.0 * 8.0 * 0.01); +} + +#[test] +#[rustfmt::skip] +fn thin_walls_match_spec_volume() { + let spec = |d, t, t2, cap| thin_boss(thin_spec(d, t, t2, cap)); + vol(Box::new(move |i| (xy_rect(i, 0.0, 0.0, 4.0, 4.0), zline(5.0), spec(ThinWallDirection::Inward, 0.5, None, false))), (4.0 * 4.0 - 3.0 * 3.0) * 5.0, (4.0 * 4.0 - 3.0 * 3.0) * 5.0 * 0.01); + vol(Box::new(move |i| (xy_rect(i, 0.0, 0.0, 4.0, 4.0), zline(5.0), spec(ThinWallDirection::MidPlane, 0.5, None, false))), (4.5 * 4.5 - 3.5 * 3.5) * 5.0, (4.5 * 4.5 - 3.5 * 3.5) * 5.0 * 0.01); + vol(Box::new(move |i| (xy_rect(i, 0.0, 0.0, 4.0, 4.0), zline(5.0), spec(ThinWallDirection::TwoDirection, 0.5, Some(0.25), false))), (5.0 * 5.0 - 3.5 * 3.5) * 5.0, (5.0 * 5.0 - 3.5 * 3.5) * 5.0 * 0.01); + vol(Box::new(move |i| (xy_rect(i, 0.0, 0.0, 4.0, 4.0), zline(5.0), spec(ThinWallDirection::Inward, 0.5, None, true))), 4.0 * 4.0 * 5.0 - 3.0 * 3.0 * 4.0, (4.0 * 4.0 * 5.0 - 3.0 * 3.0 * 4.0) * 0.01); + vol(Box::new(move |i| (xy_rect(i, 0.0, 0.0, 2.0, 1.0), off_perpendicular_path().to_vec(), spec(ThinWallDirection::Inward, 0.2, None, false))), 2.0f64.mul_add(1.0, -(1.6 * 0.6)) * 4.0, 1.0e-2); + vol(Box::new(move |i| (xy_rect(i, 0.0, 0.0, 2.0, 1.0), off_perpendicular_path().to_vec(), spec(ThinWallDirection::Inward, 0.2, None, true))), 2.0f64.mul_add(1.0, 0.0) * 4.0 - 1.6 * 0.6 * (4.0 - 2.0 * 0.2), 1.0e-2); +} + +#[test] +#[rustfmt::skip] +fn invalid_sweep_configurations_report_typed_defects() { + expect_defect(Box::new(|i| (xy_rect(i, -6.0, -6.0, 12.0, 12.0), right_angle_corner_path().to_vec(), follow())), SweepDefect::CornerTooTight); + expect_defect(Box::new(|i| (xy_rect(i, -1.0, -1.0, 2.0, 2.0), vec![line_path(Point3::origin(), pt(0.0, 0.0, 4.0)), line_path(pt(0.0, 0.0, 4.0), pt(0.0, 0.0, 1.0))], follow())), SweepDefect::CornerTooTight); + expect_defect(Box::new(|i| (xy_rect(i, -1.0, -1.0, 2.0, 2.0), vec![line_path(Point3::origin(), pt(0.0, 0.0, 4.0)), line_path(pt(0.0, 0.0, 4.0), pt(0.1, 0.0, 0.5))], follow())), SweepDefect::CornerTooTight); + expect_defect(Box::new(|i| (xy_rect(i, -2.0, -1.0, 4.0, 2.0), line_arc_line_bend(1.0, 2.0, 2.0).to_vec(), follow())), SweepDefect::PathTooTight); + expect_defect(Box::new(|i| (xy_rect(i, 0.0, 0.0, 2.0, 1.0), vec![line_path(Point3::origin(), pt(0.0, 0.0, 4.0)), line_path(pt(0.0, 0.0, 6.0), pt(0.0, 0.0, 10.0))], follow())), SweepDefect::DisconnectedPath); + expect_defect(Box::new(|i| { let (profile, path) = mid_line_setup(i, 20.0); (profile, path.to_vec(), directed(SweepDirection::DirectionOne)) }), SweepDefect::PathMissesProfile); + expect_defect(Box::new(|i| { let (profile, path) = mid_line_setup(i, 0.0); (profile, path.to_vec(), directed(SweepDirection::DirectionTwo)) }), SweepDefect::EmptySpan); + expect_defect(Box::new(|i| { let (profile, path) = mid_line_setup(i, 4.0); (profile, path.to_vec(), SweepFeature { end_tangency: SweepTangency::PathTangent, ..directed(SweepDirection::Bidirectional) }) }), SweepDefect::TangencyBidirectional); +} + +#[test] +fn degenerate_profiles_are_rejected_not_silently_repaired() { + [ + ( + open_chain as fn(&mut Ids) -> ProfileLoop, + ProfileDefect::OpenLoop, ), + (bowtie, ProfileDefect::SelfIntersectingLoop), + ] + .into_iter() + .for_each(|(loop_fn, expected)| { + let mut ids = Ids::new(); + let profile = xy_loop(loop_fn(&mut ids)); + let result = eval(&mut ids, &profile, &off_perpendicular_path(), &follow()); + assert_eq!( + profile_defect(&result), + Some(expected), + "a degenerate profile is refused, not silently closed" + ); + }); +} + +#[test] +fn a_capped_thin_sweep_along_an_arc_seals_a_recessed_void() { + let mut ids = Ids::new(); + let profile_plane = plane(pt(5.0, 0.0, 0.0), UnitVec3::x_axis(), UnitVec3::y_axis()); + let profile = ExtrudeProfile::new( + profile_plane, + vec![rectangle(&mut ids, -1.0, -0.5, 2.0, 1.0)], ); - let expected = core::f64::consts::PI * 1.0 * 1.0 * 5.0; + let feature = thin_boss(thin_spec(ThinWallDirection::Inward, 0.2, None, true)); + let path = [arc_path_xz(Point3::origin(), 5.0, 0.0, FRAC_PI_2)]; + let solid = build(&mut ids, &profile, &path, &feature); + let volume = mesh_volume_at(&solid, 0.005, 0.02).abs(); + let outer = 2.0 * 5.0 * FRAC_PI_2; + let inner = 0.96 * 5.0 * (FRAC_PI_2 - 2.0 * 0.2 / 5.0); + let expected = outer - inner; assert!( - (mesh_volume(&solid).abs() - expected).abs() < 0.05, - "a circular profile swept along a line is a cylinder of pi r squared times length: got {}", - mesh_volume(&solid).abs() + (volume - expected).abs() / expected < 0.03, + "a capped thin frame swept along a quarter arc seals a recessed void: got {volume}, want {expected}" + ); + let (starts, ends, _) = face_census(&solid); + assert_eq!( + (starts, ends), + (2, 2), + "the outer wall cap and the inner void cap stay distinct faces at each end: got {starts} start, {ends} end" ); } #[test] -fn a_closed_circle_profile_along_a_line_is_a_cylinder() { +fn a_right_angle_miter_elbow_caps_extends_and_fills() { let mut ids = Ids::new(); - let profile = ExtrudeProfile::new(xy_plane(), vec![disc(&mut ids, 1.0)]); - let path = [line_path(Point3::origin(), Point3::from_mm(0.0, 0.0, 5.0))]; - let solid = build( - &mut ids, - &profile, - &path, - &boss(SweepProfileKind::Sketch, SweepOrientation::FollowPath), + let profile = xy_rect(&mut ids, -1.0, -1.0, 2.0, 2.0); + let solid = build(&mut ids, &profile, &right_angle_corner_path(), &follow()); + let expected = 2.0 * 2.0 * 8.0; + let volume = mesh_volume(&solid); + assert!( + (volume - expected).abs() / expected < 0.005, + "a right-angle rectangle sweep miters into an elbow of area times centerline: got {volume}, want {expected}" + ); + let (starts, ends, sides) = face_census(&solid); + assert_eq!( + (starts, ends), + (1, 1), + "the elbow keeps one start cap and one end cap" + ); + assert_eq!( + sides, + 8, + "two straight runs each carry the four-edge profile into four side faces: got {}", + dump(&solid) ); - let expected = core::f64::consts::PI * 1.0 * 1.0 * 5.0; assert!( - (mesh_volume(&solid).abs() - expected).abs() < 0.02, - "a sketched circle swept along a line reduces to a cylinder: got {}", - mesh_volume(&solid).abs() + (max_z(&solid) - 5.0).abs() < 1.0e-6, + "the bisecting miter carries the inner corner to z = 5, one profile half-width past the z = 4 vertex, not a square cap: got {}", + max_z(&solid) ); } #[test] -fn straight_path_sweep_is_a_prism() { +fn a_square_and_asymmetric_profile_sweep_a_torus() { let mut ids = Ids::new(); - let profile = ExtrudeProfile::new(xy_plane(), vec![rectangle(&mut ids, 0.0, 0.0, 2.0, 1.0)]); - let path = [line_path(Point3::origin(), Point3::from_mm(0.0, 0.0, 4.0))]; - let solid = build( - &mut ids, - &profile, - &path, - &boss(SweepProfileKind::Sketch, SweepOrientation::FollowPath), + let rho = 3.0; + let profile_plane = plane(pt(rho, 0.0, 0.0), UnitVec3::x_axis(), UnitVec3::z_axis()); + let square = ExtrudeProfile::new( + profile_plane, + vec![rectangle(&mut ids, -0.3, -0.3, 0.6, 0.6)], + ); + let square_solid = build(&mut ids, &square, &two_arc_circle(rho), &follow()); + let square_volume = mesh_volume_at(&square_solid, 0.005, 0.03).abs(); + let square_expected = 0.36 * 2.0 * PI * rho; + assert!( + (square_volume - square_expected).abs() / square_expected < 0.03, + "a 0.6 mm square swept around a radius-3 circle is a {square_expected} mm^3 torus: got {square_volume}" ); - let expected = 2.0 * 1.0 * 4.0; + let asym = ExtrudeProfile::new( + profile_plane, + vec![rectangle(&mut ids, -0.3, -0.1, 0.6, 0.2)], + ); + let asym_solid = build(&mut ids, &asym, &two_arc_circle(rho), &follow()); + let (lo, hi) = asym_solid + .iter_vertices() + .map(|vertex| vertex.position().coords_mm().2) + .fold((f64::MAX, f64::MIN), |(lo, hi), z| (lo.min(z), hi.max(z))); + assert!( + hi - lo < 0.4, + "the 0.2 mm axial side stays axial rather than the 0.6 mm radial side twisting onto it: got {}", + hi - lo + ); + let asym_volume = mesh_volume_at(&asym_solid, 0.005, 0.03).abs(); + let asym_expected = 0.6 * 0.2 * 2.0 * PI * rho; assert!( - (mesh_volume(&solid).abs() - expected).abs() < 1.0e-6, - "straight sweep is a box of the profile area times the path length: got {}", - mesh_volume(&solid).abs() + (asym_volume - asym_expected).abs() / asym_expected < 0.03, + "the asymmetric profile still sweeps a {asym_expected} mm^3 torus: got {asym_volume}" ); } #[test] -fn the_same_sweep_builds_identically_twice() { - let mut first_ids = Ids::new(); - let first_profile = ExtrudeProfile::new( - xz_plane(), - vec![rectangle(&mut first_ids, 2.0, 0.0, 1.0, 1.0)], - ); - let first = build( - &mut first_ids, - &first_profile, - &[circle_path(xy_plane(), 2.5)], - &boss(SweepProfileKind::Sketch, SweepOrientation::FollowPath), +fn a_twisted_corner_on_a_curved_run_stays_gated() { + let mut ids = Ids::new(); + let profile = xy_rect(&mut ids, -0.5, -0.5, 1.0, 1.0); + let feature = SweepFeature { + twist: Some(twist(90.0)), + ..follow() + }; + let error = eval(&mut ids, &profile, &line_arc_corner_path(), &feature); + assert_eq!( + sweep_gap(&error), + Some(TruckGap::SweepPathCorner), + "twist along a mitered curved corner is not yet supported and stays gated" ); +} - let mut second_ids = Ids::new(); - let second_profile = ExtrudeProfile::new( - xz_plane(), - vec![rectangle(&mut second_ids, 2.0, 0.0, 1.0, 1.0)], +#[test] +fn a_reversed_profile_winding_still_builds_an_outward_solid() { + let mut ids = Ids::new(); + let standard = xy_rect(&mut ids, 0.0, 0.0, 2.0, 1.0); + let reversed = + ExtrudeProfile::new(xy_plane(), vec![rectangle_cw(&mut ids, 0.0, 0.0, 2.0, 1.0)]); + let standard_solid = build(&mut ids, &standard, &off_perpendicular_path(), &follow()); + let reversed_solid = build(&mut ids, &reversed, &off_perpendicular_path(), &follow()); + let standard_volume = mesh_volume(&standard_solid); + let reversed_volume = mesh_volume(&reversed_solid); + assert!( + standard_volume > 0.0, + "a counter-clockwise profile builds an outward solid: got {standard_volume}" ); - let second = build( - &mut second_ids, - &second_profile, - &[circle_path(xy_plane(), 2.5)], - &boss(SweepProfileKind::Sketch, SweepOrientation::FollowPath), + assert!( + reversed_volume > 0.0, + "a reversed winding is normalized to an outward solid, not an inverted void: got {reversed_volume}" ); + assert!( + (standard_volume - reversed_volume).abs() < 1.0e-3, + "both windings describe the same solid: {standard_volume} vs {reversed_volume}" + ); +} +#[test] +fn a_keep_normal_arc_sweep_holds_parallel_and_tessellates_watertight() +-> Result<(), Box> { + use std::collections::HashMap; + let mut ids = Ids::new(); + let profile = xz_rect(&mut ids, 2.0, 0.0, 1.0, 1.0); + let path = [arc_path_xz(Point3::origin(), 4.0, 0.0, FRAC_PI_2)]; + let solid = build(&mut ids, &profile, &path, &keep_normal()); + let follow_solid = build(&mut ids, &profile, &path, &follow()); + let keep_normal_volume = mesh_volume_at(&solid, 0.05, 0.25); + let follow_path_volume = mesh_volume_at(&follow_solid, 0.05, 0.25); + assert!( + (keep_normal_volume - 4.0).abs() < 0.04, + "keep-normal holds the section parallel, so it fills area times the 4 mm Z-extent: got {keep_normal_volume}\n{}", + dump(&solid) + ); + assert!( + (keep_normal_volume - follow_path_volume).abs() > 0.04, + "keep-normal must not collapse into the follow-path revolve: keep-normal {keep_normal_volume}, follow-path {follow_path_volume}" + ); + let mesh = solid.tessellate( + ChordHeightTolerance::from_mm(0.05), + AngleTolerance::from_radians(0.25), + )?; + let mut edges: HashMap<[(u64, u64, u64); 2], i32> = HashMap::new(); + mesh.faces().iter().for_each(|slab| { + let pos = slab.positions(); + slab.triangles().iter().for_each(|tri| { + let key = |i: u32| { + let (x, y, z) = pos[i as usize].coords_mm(); + (x.to_bits(), y.to_bits(), z.to_bits()) + }; + [(tri[0], tri[1]), (tri[1], tri[2]), (tri[2], tri[0])] + .into_iter() + .for_each(|(a, b)| { + let (ka, kb) = (key(a), key(b)); + let ordered = if ka <= kb { [ka, kb] } else { [kb, ka] }; + *edges.entry(ordered).or_insert(0) += if ka <= kb { 1 } else { -1 }; + }); + }); + }); + let open = edges.values().filter(|balance| **balance != 0).count(); assert_eq!( - dump(&first), - dump(&second), - "sweep topology is deterministic" + open, 0, + "every mesh edge pairs with an oppositely-wound twin, so the coarse skin is closed" ); assert!( - (mesh_volume(&first) - mesh_volume(&second)).abs() < f64::EPSILON, - "sweep geometry is deterministic" + mesh.validate(TOLERANCE).is_ok(), + "the coarse skin mesh winds consistently and is manifold" ); + Ok(()) } #[test] -fn a_multi_segment_path_reports_the_general_path_gap() { +fn a_twisted_prism_keeps_volume_side_keys_and_rotates_the_far_cap() { let mut ids = Ids::new(); - let profile = ExtrudeProfile::new(xy_plane(), vec![rectangle(&mut ids, 0.0, 0.0, 2.0, 1.0)]); - let path = [ - line_path(Point3::origin(), Point3::from_mm(0.0, 0.0, 4.0)), - line_path( - Point3::from_mm(0.0, 0.0, 4.0), - Point3::from_mm(4.0, 0.0, 4.0), - ), - ]; - let sweep = ids.feature(); - let error = evaluate_sweep( - sweep, - &profile, - &path, - &boss(SweepProfileKind::Sketch, SweepOrientation::FollowPath), + let profile = xy_rect(&mut ids, -1.0, -1.0, 2.0, 2.0); + let path = zline(6.0); + let half = SweepFeature { + twist: Some(twist(180.0)), + ..follow() + }; + let half_solid = build(&mut ids, &profile, &path, &half); + let half_volume = mesh_volume_at(&half_solid, 0.02, 0.1); + assert!( + (half_volume - 24.0).abs() < 0.3, + "a twisted prism keeps the untwisted enclosed volume: got {half_volume}" ); + let twisted = |angle_rad: f64| SweepFeature { + twist: Some(twist_rad(angle_rad)), + ..follow() + }; + let strong = build(&mut ids, &profile, &path, &twisted(FRAC_PI_2)); + let gentle = build(&mut ids, &profile, &path, &twisted(FRAC_PI_2 / 4.0)); + let side_keys = |solid: &BrepSolid| { + let mut keys: Vec = solid + .iter_faces() + .filter_map(|face| match face.label().role { + FaceRole::Side { from, .. } => Some(from), + _ => None, + }) + .collect(); + keys.sort_unstable(); + keys + }; assert_eq!( - sweep_gap(&error), - Some(TruckGap::SweepGeneralPath), - "a two-segment path is gated, not faked" + side_keys(&strong), + side_keys(&gentle), + "changing the twist angle must not re-key the side faces" + ); + assert_eq!( + face_census(&strong), + (1, 1, 4), + "a quarter-turn twisted skin keeps one start cap, one end cap, and four sides: {}", + dump(&strong) + ); + assert_eq!( + face_census(&gentle), + (1, 1, 4), + "a gentler twist keeps the same cap and side census: {}", + dump(&gentle) + ); + + let cap_profile = xy_rect(&mut ids, -1.0, -0.5, 2.0, 1.0); + let cap_path = zline(4.0); + let plain = build(&mut ids, &cap_profile, &cap_path, &follow()); + let quarter = SweepFeature { + twist: Some(twist(90.0)), + ..follow() + }; + let quarter_solid = build(&mut ids, &cap_profile, &cap_path, &quarter); + let far_cap_reach_x = |solid: &BrepSolid| { + solid + .iter_vertices() + .filter_map(|vertex| { + let (x, _y, z) = vertex.position().coords_mm(); + (z > 3.5).then_some(x.abs()) + }) + .fold(0.0_f64, f64::max) + }; + let plain_x = far_cap_reach_x(&plain); + let twisted_x = far_cap_reach_x(&quarter_solid); + assert!( + (plain_x - 1.0).abs() < 1.0e-6, + "the untwisted far cap keeps the long axis on x: got {plain_x}" + ); + assert!( + (twisted_x - 0.5).abs() < 1.0e-6, + "a quarter-turn twist rotates the far cap's long axis off x: got {twisted_x}" ); } #[test] -fn an_off_perpendicular_profile_builds_via_the_generative_skin() { +fn a_mid_line_sweep_spans_the_expected_volume_by_direction() { + let cases: [(SweepFeature, f64, f64); 4] = [ + (directed(SweepDirection::DirectionOne), 12.0, 1.0e-3), + (directed(SweepDirection::DirectionTwo), 8.0, 1.0e-3), + (directed(SweepDirection::Bidirectional), 20.0, 1.0e-3), + ( + SweepFeature { + twist: Some(twist_rad(FRAC_PI_2)), + ..directed(SweepDirection::Bidirectional) + }, + 20.0, + 20.0 * 0.01, + ), + ]; + cases.into_iter().for_each(|(feature, expected, tol)| { + let mut ids = Ids::new(); + let (profile, path) = mid_line_setup(&mut ids, 4.0); + let solid = build(&mut ids, &profile, &path, &feature); + let volume = mesh_volume(&solid).abs(); + assert!( + (volume - expected).abs() < tol, + "the mid-line sweep spans its direction volume: got {volume}, want {expected}" + ); + }); +} + +#[test] +fn a_mid_arc_profile_splits_the_revolve_by_direction() { let mut ids = Ids::new(); - let profile = ExtrudeProfile::new(xy_plane(), vec![rectangle(&mut ids, 0.0, 0.0, 2.0, 1.0)]); - // Path runs along X while the profile normal is Z, so the profile is parallel to - // the path rather than perpendicular to it. - let path = [line_path(Point3::origin(), Point3::from_mm(4.0, 0.0, 0.0))]; - let solid = build( + let profile = xz_rect(&mut ids, 2.0, 0.0, 1.0, 1.0); + let path = [arc3(xy_plane(), 2.5, -FRAC_PI_2, PI).as_kind()]; + let quarter = 1.0 * FRAC_PI_2 * 2.5; + let one = build( &mut ids, &profile, &path, - &boss(SweepProfileKind::Sketch, SweepOrientation::FollowPath), + &directed(SweepDirection::DirectionOne), ); - let volume = mesh_volume(&solid).abs(); + let one_volume = mesh_volume(&one).abs(); + assert!( + (one_volume - quarter).abs() < 0.05, + "direction 1 revolves the quarter turn past the profile plane: got {one_volume}, want {quarter}" + ); + let both = build( + &mut ids, + &profile, + &path, + &directed(SweepDirection::Bidirectional), + ); + let both_volume = mesh_volume(&both).abs(); assert!( - (volume - 8.0).abs() < 1.0e-2, - "the frame reorients the off-perpendicular section into a length-4 prism: got {volume}" + (both_volume - 2.0 * quarter).abs() < 0.05, + "bidirectional revolves the whole half turn through the profile plane: got {both_volume}" ); } #[test] -fn gated_options_report_their_own_typed_gap() { - let mut ids = Ids::new(); - let profile = ExtrudeProfile::new(xy_plane(), vec![rectangle(&mut ids, 0.0, 0.0, 2.0, 1.0)]); - let path = [line_path(Point3::origin(), Point3::from_mm(0.0, 0.0, 4.0))]; - let sweep = ids.feature(); +fn a_mid_arm_elbow_spans_the_expected_volume_by_direction() { + [ + (SweepDirection::Bidirectional, 2.0 * 2.0 * 8.0), + (SweepDirection::DirectionOne, 2.0 * 2.0 * 6.0), + ] + .into_iter() + .for_each(|(direction, expected)| { + let mut ids = Ids::new(); + let section = plane(pt(0.0, 0.0, 2.0), UnitVec3::x_axis(), UnitVec3::y_axis()); + let profile = ExtrudeProfile::new(section, vec![rectangle(&mut ids, -1.0, -1.0, 2.0, 2.0)]); + let solid = build( + &mut ids, + &profile, + &right_angle_corner_path(), + &directed(direction), + ); + let volume = mesh_volume(&solid).abs(); + assert!( + (volume - expected).abs() / expected < 0.005, + "seeding the miter mid-arm spans its direction volume: got {volume}, want {expected}" + ); + }); +} - let Ok(diameter) = PositiveLength::new(Length::new::(1.0)) else { - panic!("1 mm is a positive length"); - }; - let circular_on_closed_path = evaluate_sweep( - sweep, - &profile, - &[circle_path(xy_plane(), 2.5)], - &boss( - SweepProfileKind::Circular { diameter }, - SweepOrientation::FollowPath, - ), +#[test] +fn a_guide_scales_and_a_short_guide_truncates_the_sweep() { + let mut ids = Ids::new(); + let profile = xy_loop(disc(&mut ids, 2.0)); + let path = zline(8.0); + let horn_guide = vec![line_path(pt(2.0, 0.0, 0.0), pt(0.5, 0.0, 8.0))]; + let horn = build_guided(&mut ids, &profile, &path, &[horn_guide], &follow()); + let horn_expected = 14.0 * PI; + let horn_volume = mesh_volume(&horn).abs(); + assert!( + (horn_volume - horn_expected).abs() / horn_expected < 0.02, + "a linearly converging guide tapers the cylinder into a horn: got {horn_volume}, want {horn_expected}" ); - assert_eq!( - sweep_gap(&circular_on_closed_path), - Some(TruckGap::SweepProfileKind), - "a circular profile around a closed path is a torus tube and stays gated" + let short_guide = vec![line_path(pt(2.0, 0.0, 0.0), pt(1.4, 0.0, 4.0))]; + let short = build_guided(&mut ids, &profile, &path, &[short_guide], &follow()); + let short_expected = PI * (8.0 - 1.4_f64.powi(3)) / 0.45; + let short_volume = mesh_volume(&short).abs(); + assert!( + (short_volume - short_expected).abs() / short_expected < 0.03, + "the sweep stops where the shortest guide ends: got {short_volume}, want {short_expected}" + ); + assert!( + (max_z(&short) - 4.0).abs() < 0.1, + "the truncated sweep ends at the guide terminus: got z {}", + max_z(&short) + ); +} + +#[test] +fn two_guides_span_the_section_anisotropically() { + let mut ids = Ids::new(); + let profile = xy_rect(&mut ids, -1.0, 0.0, 2.0, 1.0); + let path = zline(8.0); + let first = vec![line_path(pt(1.0, 0.0, 0.0), pt(2.0, 0.0, 8.0))]; + let second = vec![line_path(pt(0.0, 1.0, 0.0), pt(0.0, 3.0, 8.0))]; + let solid = build_guided(&mut ids, &profile, &path, &[first, second], &follow()); + let expected = 16.0 * (1.0 + 1.5 + 2.0 / 3.0); + let volume = mesh_volume(&solid).abs(); + assert!( + (volume - expected).abs() / expected < 0.02, + "two guides stretch width and height independently: got {volume}, want {expected}" ); +} - let twisted = evaluate_sweep( - sweep, +#[test] +fn the_first_guide_orientation_twists_the_section_with_the_guide_azimuth() { + let mut ids = Ids::new(); + let contact_x = 50.0_f64.sqrt() * 0.6; + let profile = xy_rect(&mut ids, contact_x - 1.0, -4.0, 2.0, 1.0); + let path = zline(8.0); + let tracked = build_guided( + &mut ids, &profile, &path, + &[azimuth_arc_guide()], &boss( SweepProfileKind::Sketch, - SweepOrientation::KeepNormalConstant, + SweepOrientation::FollowPathAndFirstGuide, ), ); - assert_eq!( - sweep_gap(&twisted), - Some(TruckGap::SweepOrientation), - "a keep-normal-constant orientation is gated" + let plain = build_guided(&mut ids, &profile, &path, &[azimuth_arc_guide()], &follow()); + assert!( + max_y(&tracked) > 3.0, + "tracking guide 1 swings the section to the far azimuth: max y {}", + max_y(&tracked) + ); + assert!( + max_y(&plain) < 0.0, + "plain follow-path only scales toward the guide, it never rotates: max y {}", + max_y(&plain) ); } -fn off_perpendicular_path() -> [Curve3Kind; 1] { - [line_path(Point3::origin(), Point3::from_mm(4.0, 0.0, 0.0))] +#[test] +fn guide_configuration_defects_are_typed() { + let mut ids = Ids::new(); + let profile = xy_loop(disc(&mut ids, 2.0)); + let path = zline(8.0); + let touching = || vec![line_path(pt(2.0, 0.0, 0.0), pt(1.0, 0.0, 8.0))]; + let missing = || vec![line_path(pt(5.0, 0.0, 0.0), pt(5.0, 0.0, 8.0))]; + let run = |ids: &mut Ids, guides: &[Vec], feature: &SweepFeature| { + let rails = SweepRails { + guides, + direction: None, + tool: None, + bodies: &[], + }; + evaluate_sweep(ids.feature(), &profile, &path, rails, feature) + }; + let cases: [(Vec>, SweepFeature, SweepDefect); 5] = [ + (vec![missing()], follow(), SweepDefect::GuideMissesProfile), + ( + vec![touching()], + SweepFeature { + direction: SweepDirection::Bidirectional, + ..follow() + }, + SweepDefect::GuideBidirectional, + ), + ( + vec![touching()], + SweepFeature { + twist: Some(twist_rad(1.0)), + ..follow() + }, + SweepDefect::GuideTwist, + ), + ( + vec![touching()], + keep_normal(), + SweepDefect::GuideKeepNormal, + ), + ( + vec![touching()], + boss( + SweepProfileKind::Sketch, + SweepOrientation::FollowFirstAndSecondGuide, + ), + SweepDefect::OrientationNeedsGuides, + ), + ]; + cases.into_iter().for_each(|(guides, feature, expected)| { + assert_eq!( + sweep_defect(&run(&mut ids, &guides, &feature)), + Some(expected), + "a guide misconfiguration reports its typed defect" + ); + }); + assert_eq!( + sweep_gap(&run( + &mut ids, + &[touching(), touching(), touching()], + &follow() + )), + Some(TruckGap::SweepGuideCount), + "more than two guides stays an honest gap" + ); } #[test] -fn a_thin_wall_off_path_sweep_reports_the_thin_wall_gap() { +fn a_keep_normal_arc_skin_labels_caps_reshapes_with_tangency_and_caps_a_path_vertex() { let mut ids = Ids::new(); - let profile = ExtrudeProfile::new(xy_plane(), vec![rectangle(&mut ids, 0.0, 0.0, 2.0, 1.0)]); - let Ok(thickness) = PositiveLength::new(Length::new::(0.2)) else { - panic!("0.2 mm is a positive length"); - }; - let feature = SweepFeature { - thin_wall: Some(ThinWall { - thickness, - direction: ThinWallDirection::Inward, - thickness2: None, - cap_ends: false, - auto_fillet: None, - }), - ..boss(SweepProfileKind::Sketch, SweepOrientation::FollowPath) + let profile = xy_rect(&mut ids, -1.0, -1.0, 2.0, 2.0); + let path = arc10(); + let plain = build(&mut ids, &profile, &path, &keep_normal()); + assert_eq!( + face_census(&plain), + (1, 1, 4), + "a keep-normal skin keeps one start cap, one end cap, and one side face per profile segment: {}", + dump(&plain) + ); + let tangent_feature = SweepFeature { + start_tangency: SweepTangency::PathTangent, + end_tangency: SweepTangency::PathTangent, + ..keep_normal() }; - let sweep = ids.feature(); - let result = evaluate_sweep(sweep, &profile, &off_perpendicular_path(), &feature); + let clamped = build(&mut ids, &profile, &path, &tangent_feature); + let plain_volume = mesh_volume_at(&plain, 0.1, 0.15).abs(); + let clamped_volume = mesh_volume_at(&clamped, 0.1, 0.15).abs(); + let perpendicular = 4.0 * 10.0 * FRAC_PI_2; + assert!( + clamped_volume > plain_volume * 1.1, + "path tangency reorients the ends toward the path, enlarging a keep-normal skin past its sheared volume: {plain_volume} vs {clamped_volume}" + ); + assert!( + clamped_volume < perpendicular * 1.05, + "the reshaped skin stays bounded by a fully path-perpendicular sweep: {clamped_volume} vs {perpendicular}" + ); + + let on_path = xy_rect(&mut ids, 0.0, 0.0, 2.0, 1.0); + let shifted = xy_rect(&mut ids, 1.0, 0.0, 2.0, 1.0); + let on_solid = build(&mut ids, &on_path, &path, &keep_normal()); + let off_solid = build(&mut ids, &shifted, &path, &keep_normal()); + let (starts, ends, _) = face_census(&on_solid); assert_eq!( - sweep_gap(&result), - Some(TruckGap::SweepThinWall), - "a thin-wall off-path profile is gated as a thin-wall sweep, not misreported as off-path" + (starts, ends), + (1, 1), + "a profile corner sitting on the path start still caps both ends: {}", + dump(&on_solid) + ); + let on_volume = mesh_volume(&on_solid).abs(); + let off_volume = mesh_volume(&off_solid).abs(); + assert!( + on_volume > 0.0, + "the on-path skin encloses volume: got {on_volume}" + ); + assert!( + off_volume > 0.0, + "the shifted skin encloses volume: got {off_volume}" + ); + assert!( + (on_volume - off_volume).abs() / off_volume < 0.02, + "shifting the profile off the path start keeps the transported volume: got {on_volume} vs {off_volume}" ); } #[test] -fn a_multi_loop_profile_off_path_is_gated_not_skinned() { +fn a_direction_vector_locks_the_up_axis() -> Result<(), Box> { let mut ids = Ids::new(); - let outer = rectangle(&mut ids, 0.0, 0.0, 4.0, 4.0); - let hole = rectangle_cw(&mut ids, 1.0, 1.0, 2.0, 2.0); - let profile = ExtrudeProfile::new(xy_plane(), vec![outer, hole]); - let sweep = ids.feature(); + let locked = || SweepFeature { + alignment: direction_vector(), + ..follow() + }; + let straight_profile = xy_rect(&mut ids, 0.0, 0.0, 2.0, 1.0); + let straight_path = zline(6.0); + let straight = evaluate_sweep( + ids.feature(), + &straight_profile, + &straight_path, + dir_rails(UnitVec3::x_axis()), + &locked(), + )?; + let straight_volume = mesh_volume(&straight).abs(); + assert!( + (straight_volume - 12.0).abs() < 1.0e-3, + "a straight direction-locked sweep is still the drawn prism: got {straight_volume}" + ); + let arc = arc10(); + let swing_profile = xy_rect(&mut ids, -1.0, -0.25, 2.0, 0.5); + let follow_solid = build(&mut ids, &swing_profile, &arc, &follow()); + let tilted = bone_types::Vec3::from_mm(1.0, 1.0, 0.0).try_normalize(TOLERANCE)?; + let rolled = evaluate_sweep( + ids.feature(), + &swing_profile, + &arc, + dir_rails(tilted), + &locked(), + )?; + assert!( + (max_y(&rolled) - max_y(&follow_solid)).abs() > 0.05, + "locking the up-vector to a tilted direction rolls the end section out of the arc plane: {} vs {}", + max_y(&rolled), + max_y(&follow_solid) + ); + let refuse_profile = xy_rect(&mut ids, 0.0, 0.0, 2.0, 1.0); let result = evaluate_sweep( - sweep, - &profile, - &off_perpendicular_path(), - &boss(SweepProfileKind::Sketch, SweepOrientation::FollowPath), + ids.feature(), + &refuse_profile, + &arc, + dir_rails(UnitVec3::z_axis()), + &locked(), ); assert_eq!( - sweep_gap(&result), - Some(TruckGap::SweepProfileKind), - "a holed profile is a tube sweep and stays gated, not silently skinned as a single loop" + sweep_defect(&result), + Some(SweepDefect::AlignmentAlongPath), + "the quarter arc starts tangent to z, so a z direction vector cannot orient the frame" ); + Ok(()) } #[test] -fn an_open_chain_profile_is_rejected_not_silently_closed() { +fn planar_alignment_accepts_a_flat_path_and_refuses_a_bent_one() { let mut ids = Ids::new(); - let profile = ExtrudeProfile::new(xy_plane(), vec![open_chain(&mut ids)]); - let sweep = ids.feature(); - let result = evaluate_sweep( - sweep, - &profile, - &off_perpendicular_path(), - &boss(SweepProfileKind::Sketch, SweepOrientation::FollowPath), + let profile = xy_rect(&mut ids, -1.0, -0.5, 2.0, 1.0); + let feature = SweepFeature { + alignment: bone_kernel::SweepAlignment::NonePlanar, + ..follow() + }; + let solid = build(&mut ids, &profile, &arc10(), &feature); + assert!( + mesh_volume(&solid).abs() > 1.0, + "planar alignment builds along a planar arc" + ); + let result = eval(&mut ids, &profile, &out_of_plane_bend(), &feature); + assert_eq!( + sweep_defect(&result), + Some(SweepDefect::PathNotPlanar), + "planar alignment must refuse a path that leaves its plane" ); - match result { - Err(BrepError::InvalidProfile { - reason: ProfileDefect::OpenLoop, - }) => {} - Err(other) => panic!("an open chain must report an open loop, got {other:?}"), - Ok(_) => panic!("an open chain must not fabricate a closed solid"), - } } #[test] -fn a_self_intersecting_profile_is_rejected() { +fn all_faces_alignment_needs_a_body() { let mut ids = Ids::new(); - let profile = ExtrudeProfile::new(xy_plane(), vec![bowtie(&mut ids)]); - let sweep = ids.feature(); - let result = evaluate_sweep( - sweep, + let (profile, path) = mid_line_setup(&mut ids, 0.0); + let feature = all_faces(); + let result = eval(&mut ids, &profile, &path, &feature); + assert!( + matches!(result, Err(BrepError::SweepAlignmentUnresolved)), + "all-faces alignment needs an in-scope face the path lies on" + ); +} + +#[test] +fn all_faces_alignment_lays_the_section_on_the_face_normal() +-> Result<(), Box> { + let mut ids = Ids::new(); + let plate_profile = xy_rect(&mut ids, -10.0, -10.0, 20.0, 20.0); + let plate = build(&mut ids, &plate_profile, &zline(4.0), &follow()); + let profile_plane = plane(pt(-5.0, 0.0, 4.0), UnitVec3::y_axis(), UnitVec3::z_axis()); + let profile = ExtrudeProfile::new( + profile_plane, + vec![rectangle(&mut ids, -0.1, -0.5, 0.2, 1.0)], + ); + let path = [line_path(pt(-5.0, 0.0, 4.0), pt(5.0, 0.0, 4.0))]; + let feature = all_faces(); + let bodies = [&plate]; + let solid = evaluate_sweep( + ids.feature(), &profile, - &off_perpendicular_path(), - &boss(SweepProfileKind::Sketch, SweepOrientation::FollowPath), + &path, + body_rails(&bodies), + &feature, + )?; + let heights: Vec = solid + .iter_vertices() + .map(|vertex| vertex.position().coords_mm().2) + .collect(); + assert!( + heights.iter().all(|&z| (3.85..=4.15).contains(&z)), + "the section up-vector tracks the +Z face normal, so the thin ribbon hugs z=4: got {heights:?}" ); - match result { - Err(BrepError::InvalidProfile { - reason: ProfileDefect::SelfIntersectingLoop, - }) => {} - Err(other) => panic!("a bowtie profile must report a self-intersection, got {other:?}"), - Ok(_) => panic!("a self-intersecting profile must not build a solid"), - } + Ok(()) } #[test] -fn a_reversed_profile_winding_still_builds_an_outward_solid() { +fn all_faces_alignment_wraps_a_ribbon_onto_a_cylinder() -> Result<(), Box> { let mut ids = Ids::new(); - let standard = ExtrudeProfile::new(xy_plane(), vec![rectangle(&mut ids, 0.0, 0.0, 2.0, 1.0)]); - let reversed = - ExtrudeProfile::new(xy_plane(), vec![rectangle_cw(&mut ids, 0.0, 0.0, 2.0, 1.0)]); - let standard_solid = build( - &mut ids, - &standard, - &off_perpendicular_path(), - &boss(SweepProfileKind::Sketch, SweepOrientation::FollowPath), + let cylinder = cylinder_tool(&mut ids, Point3::origin(), pt(0.0, 0.0, 10.0), 6.0); + let profile_plane = plane(pt(3.0, 0.0, 5.0), UnitVec3::x_axis(), UnitVec3::z_axis()); + let profile = ExtrudeProfile::new( + profile_plane, + vec![rectangle(&mut ids, -0.2, -0.5, 0.4, 1.0)], ); - let reversed_solid = build( - &mut ids, - &reversed, - &off_perpendicular_path(), - &boss(SweepProfileKind::Sketch, SweepOrientation::FollowPath), + let arc_plane = plane(pt(0.0, 0.0, 5.0), UnitVec3::x_axis(), UnitVec3::y_axis()); + let arc = [arc3(arc_plane, 3.0, 0.0, FRAC_PI_2).as_kind()]; + let feature = all_faces(); + let bodies = [&cylinder]; + let solid = evaluate_sweep(ids.feature(), &profile, &arc, body_rails(&bodies), &feature)?; + let radii: Vec = solid + .iter_vertices() + .map(|vertex| { + let (x, y, _) = vertex.position().coords_mm(); + x.hypot(y) + }) + .collect(); + assert!( + radii.iter().all(|&r| (2.7..=3.3).contains(&r)), + "the section up-vector tracks the varying radial face normal, so every vertex hugs radius 3: got {radii:?}" ); - let standard_volume = mesh_volume(&standard_solid); - let reversed_volume = mesh_volume(&reversed_solid); + Ok(()) +} + +#[test] +fn a_cylindrical_tool_sweeps_a_coaxial_envelope() -> Result<(), Box> { + let mut ids = Ids::new(); + let tool = cylinder_tool(&mut ids, Point3::origin(), pt(0.0, 0.0, 4.0), 2.0); + let profile = xy_rect(&mut ids, 0.0, 0.0, 1.0, 1.0); + let path = zline(6.0); + let envelope = evaluate_sweep( + ids.feature(), + &profile, + &path, + tool_rails(&tool), + &solid_tool_cut(), + )?; + let expected = PI * 1.0 * 1.0 * 10.0; + let volume = mesh_volume(&envelope).abs(); assert!( - standard_volume > 0.0, - "a counter-clockwise profile builds an outward solid: got {standard_volume}" + (volume - expected).abs() / expected < 0.02, + "a cylinder tool swept along its own axis is one longer cylinder: got {volume}, want {expected}" ); + let (min_z, max_z) = envelope + .iter_vertices() + .map(|vertex| vertex.position().coords_mm().2) + .fold((f64::MAX, f64::MIN), |(lo, hi), z| (lo.min(z), hi.max(z))); assert!( - reversed_volume > 0.0, - "a reversed winding is normalized to an outward solid, not an inverted void: got {reversed_volume}" + min_z.abs() < 1.0e-6 && (max_z - 10.0).abs() < 1.0e-6, + "the envelope spans from the rear cap to the far cap plus the travel: got {min_z}..{max_z}" + ); + Ok(()) +} + +#[test] +fn a_solid_tool_refuses_invalid_configurations() { + let mut ids = Ids::new(); + let tool = cylinder_tool(&mut ids, Point3::origin(), pt(0.0, 0.0, 4.0), 2.0); + let profile = xy_rect(&mut ids, 0.0, 0.0, 1.0, 1.0); + let cases: [(SweepFeature, Vec, SweepDefect); 3] = [ + ( + boss( + SweepProfileKind::Solid { + tool: BodyRef::new(FeatureId::default(), BodyOrdinal::origin()), + }, + SweepOrientation::FollowPath, + ), + zline(6.0), + SweepDefect::SolidToolBoss, + ), + ( + solid_tool_cut(), + right_angle_corner_path().to_vec(), + SweepDefect::SolidToolCorner, + ), + ( + solid_tool_cut(), + vec![line_path(pt(0.0, 0.0, 20.0), pt(0.0, 0.0, 26.0))], + SweepDefect::SolidToolOffBody, + ), + ]; + cases.into_iter().for_each(|(feature, path, defect)| { + let result = evaluate_sweep(ids.feature(), &profile, &path, tool_rails(&tool), &feature); + assert_eq!( + sweep_defect(&result), + Some(defect), + "a solid tool refuses an invalid configuration" + ); + }); +} + +#[test] +fn a_non_cylindrical_tool_is_gated() { + let mut ids = Ids::new(); + let prism_profile = xy_rect(&mut ids, 0.0, 0.0, 2.0, 2.0); + let tool = build(&mut ids, &prism_profile, &zline(4.0), &follow()); + let profile = xy_rect(&mut ids, 0.0, 0.0, 1.0, 1.0); + let result = evaluate_sweep( + ids.feature(), + &profile, + &zline(6.0), + tool_rails(&tool), + &solid_tool_cut(), + ); + assert_eq!( + sweep_gap(&result), + Some(TruckGap::SweepSolidTool), + "a box prism tool body stays an honest gap" + ); +} + +#[test] +fn a_solid_tool_envelope_stays_gated() { + let mut ids = Ids::new(); + let tool = cylinder_tool(&mut ids, Point3::origin(), pt(0.0, 0.0, 4.0), 2.0); + let profile = xy_rect(&mut ids, 0.0, 0.0, 1.0, 1.0); + let cases: [(SweepFeature, Vec); 3] = [ + (solid_tool_cut(), xline(6.0)), + ( + SweepFeature { + twist: Some(twist_rad(1.0)), + ..solid_tool_cut() + }, + zline(6.0), + ), + ( + SweepFeature { + direction: SweepDirection::Bidirectional, + ..solid_tool_cut() + }, + zline(6.0), + ), + ]; + cases.into_iter().for_each(|(feature, path)| { + let result = evaluate_sweep(ids.feature(), &profile, &path, tool_rails(&tool), &feature); + assert_eq!( + sweep_gap(&result), + Some(TruckGap::SweepSolidEnvelope), + "a solid tool sweep past the cylindrical fast path stays an honest gap" + ); + }); +} + +fn assert_builds_identically_twice(fixture: impl Fn(&mut Ids) -> BrepSolid) { + let mut first_ids = Ids::new(); + let first = fixture(&mut first_ids); + let mut second_ids = Ids::new(); + let second = fixture(&mut second_ids); + assert_eq!( + dump(&first), + dump(&second), + "sweep topology is deterministic" ); assert!( - (standard_volume - reversed_volume).abs() < 1.0e-3, - "both windings describe the same solid: {standard_volume} vs {reversed_volume}" + (mesh_volume(&first) - mesh_volume(&second)).abs() < f64::EPSILON, + "sweep geometry is deterministic" ); } + +#[test] +fn a_sweep_builds_identically_twice() { + let revolved = |ids: &mut Ids| { + let profile = xz_rect(ids, 2.0, 0.0, 1.0, 1.0); + build(ids, &profile, &[circle_path(xy_plane(), 2.5)], &follow()) + }; + let mitered = |ids: &mut Ids| { + let profile = xy_rect(ids, -1.0, -1.0, 2.0, 2.0); + build(ids, &profile, &right_angle_corner_path(), &follow()) + }; + assert_builds_identically_twice(revolved); + assert_builds_identically_twice(mitered); +} -- 2.51.2