BREP_RANSAC 0.1.2

Topology-aware analytic surface recognition for CAD triangle meshes
Documentation
use brep_ransac::{
    recognize_surfaces_with_unresolved, reconstruct_surface, synthetic, AnalyticSurface,
    ConeSurface, CylinderSurface, RecognitionOptions, SamplingMode, SphereSurface, SurfaceHint,
    SurfaceType, Vec3,
};
use std::f64::consts::{FRAC_PI_2, PI, TAU};

fn options() -> RecognitionOptions {
    RecognitionOptions {
        distance_tolerance: 1.0e-6,
        relative_tolerance: 1.0e-8,
        normal_tolerance: 0.05,
        minimum_support: 4,
        sampling: SamplingMode::Vertices,
        deterministic_seed: Some(0x434f_4e44_4954_494f),
        ..Default::default()
    }
}

fn cylinder_patch(angular: [f64; 2], axial: [f64; 2]) -> brep_ransac::Mesh {
    let surface = AnalyticSurface::Cylinder(CylinderSurface {
        axis_origin: Vec3::new(2.0, -3.0, 1.0),
        axis: Vec3::Z,
        radius: 7.0,
    });
    synthetic::tessellate_with_normals(
        surface,
        synthetic::Patch {
            u: angular,
            v: axial,
            u_segments: 32,
            v_segments: 8,
            ..Default::default()
        },
    )
}

#[test]
fn explicit_partial_and_extreme_aspect_cylinders_are_recognized() {
    for (name, angular, axial) in [
        ("half", [0.0, PI], [-2.0, 3.0]),
        ("quarter", [0.0, FRAC_PI_2], [-2.0, 3.0]),
        ("very long", [0.0, TAU], [-1.0e5, 1.0e5]),
        ("very short", [0.0, TAU], [2.0, 2.0001]),
    ] {
        let mesh = cylinder_patch(angular, axial);
        let result = recognize_surfaces_with_unresolved(&mesh, &options())
            .unwrap_or_else(|error| panic!("{name} cylinder: {error}"));
        assert_eq!(result.regions.len(), 1, "{name}: {result:?}");
        assert_eq!(
            result.regions[0].surface.surface_type(),
            SurfaceType::Cylinder,
            "{name}: {:?}",
            result.regions[0]
        );
        assert!(result.unresolved_triangles.is_empty(), "{name}");
    }
}

#[test]
fn steep_truncated_cone_is_recovered_by_unknown_and_known_type_paths() {
    let truth = ConeSurface {
        apex: Vec3::new(-2.0, 3.0, 0.5),
        axis: Vec3::new(0.2, -0.3, 0.9).normalized().unwrap(),
        half_angle: 1.2,
    };
    let mesh = synthetic::tessellate_with_normals(
        AnalyticSurface::Cone(truth),
        synthetic::Patch {
            u: [0.0, TAU],
            v: [2.0, 5.0],
            u_segments: 32,
            v_segments: 12,
            ..Default::default()
        },
    );

    let unknown = recognize_surfaces_with_unresolved(&mesh, &options()).unwrap();
    assert!(unknown.unresolved_triangles.is_empty(), "{unknown:?}");
    assert_eq!(unknown.regions.len(), 1, "{unknown:?}");
    let AnalyticSurface::Cone(unknown_cone) = unknown.regions[0].surface else {
        panic!(
            "unknown recognition selected {:?}",
            unknown.regions[0].surface
        );
    };

    let ids = (0..mesh.triangles.len()).collect::<Vec<_>>();
    let known = reconstruct_surface(
        &mesh,
        &ids,
        &SurfaceHint::KnownType {
            surface_type: SurfaceType::Cone,
        },
        &options(),
    )
    .unwrap();
    let AnalyticSurface::Cone(known_cone) = known.surface else {
        panic!("known-type fit selected {:?}", known.surface);
    };

    for recovered in [unknown_cone, known_cone] {
        assert!(
            recovered.apex.distance(truth.apex) < 1.0e-8,
            "{recovered:?}"
        );
        assert!(
            recovered.axis.dot(truth.axis) > 1.0 - 1.0e-10,
            "{recovered:?}"
        );
        assert!(
            (recovered.half_angle - truth.half_angle).abs() < 1.0e-10,
            "{recovered:?}"
        );
    }
}

#[test]
fn explicit_scale_origin_and_aspect_fixtures_reconstruct_their_carriers() {
    for (name, (truth, mesh)) in [
        ("very small", synthetic::very_small_patch()),
        ("large scale", synthetic::large_scale_patch()),
        ("far origin", synthetic::far_origin_patch()),
        ("high aspect", synthetic::high_aspect_patch()),
    ] {
        let ids = (0..mesh.triangles.len()).collect::<Vec<_>>();
        let fit = reconstruct_surface(
            &mesh,
            &ids,
            &SurfaceHint::KnownType {
                surface_type: truth.surface_type(),
            },
            &options(),
        )
        .unwrap_or_else(|error| panic!("{name}: {error}"));
        assert_eq!(fit.surface.surface_type(), truth.surface_type(), "{name}");
        let tolerance = options().distance_tolerance
            + options().relative_tolerance * mesh.analyze(&Default::default()).unwrap().diagonal;
        assert!(
            fit.metrics.max_error <= tolerance.max(1.0e-6),
            "{name}: {fit:?}"
        );
    }
}

#[test]
fn tiny_radius_sphere_is_recognized_end_to_end() {
    let truth = AnalyticSurface::Sphere(SphereSurface {
        center: Vec3::new(4.0e-7, -3.0e-7, 2.0e-7),
        radius: 1.0e-7,
    });
    let mesh = synthetic::tessellate_with_normals(
        truth,
        synthetic::Patch {
            u: [0.0, TAU],
            v: [-1.1, 1.1],
            u_segments: 18,
            v_segments: 12,
            ..Default::default()
        },
    );
    let ids = (0..mesh.triangles.len()).collect::<Vec<_>>();
    let tiny_options = RecognitionOptions {
        distance_tolerance: 1.0e-13,
        relative_tolerance: 0.0,
        normal_tolerance: 0.05,
        minimum_support: 4,
        sampling: SamplingMode::Vertices,
        ..options()
    };
    for hint in [
        SurfaceHint::Unknown,
        SurfaceHint::KnownType {
            surface_type: SurfaceType::Sphere,
        },
    ] {
        let fit = reconstruct_surface(&mesh, &ids, &hint, &tiny_options).unwrap_or_else(|error| {
            let probes = [
                SurfaceType::Plane,
                SurfaceType::Sphere,
                SurfaceType::Cylinder,
                SurfaceType::Cone,
                SurfaceType::Torus,
            ]
            .map(|surface_type| {
                (
                    surface_type,
                    reconstruct_surface(
                        &mesh,
                        &ids,
                        &SurfaceHint::KnownType { surface_type },
                        &tiny_options,
                    ),
                )
            });
            panic!("{hint:?}: {error}; probes={probes:?}")
        });
        assert_eq!(fit.surface.surface_type(), SurfaceType::Sphere);
        assert!(fit.metrics.max_error <= 1.0e-13, "{hint:?}: {fit:?}");
    }
}

#[test]
fn one_mesh_with_five_orders_of_carrier_scale_recovers_both_regions() {
    let fixture = synthetic::mixed_scale_model();
    let mixed_options = RecognitionOptions {
        distance_tolerance: 1.0e-8,
        relative_tolerance: 0.0,
        normal_tolerance: 0.05,
        minimum_support: 4,
        sampling: SamplingMode::Vertices,
        ..options()
    };
    let result = recognize_surfaces_with_unresolved(&fixture.mesh, &mixed_options).unwrap();
    assert!(result.unresolved_triangles.is_empty(), "{result:?}");
    assert_eq!(result.regions.len(), 2, "{result:?}");
    let recovered = result
        .regions
        .iter()
        .map(|region| region.surface.surface_type())
        .collect::<std::collections::BTreeSet<_>>();
    assert_eq!(
        recovered,
        [SurfaceType::Sphere, SurfaceType::Cylinder]
            .into_iter()
            .collect()
    );
}

#[test]
fn noisy_jittered_cylinder_is_recognized_deterministically() {
    let truth = CylinderSurface {
        axis_origin: Vec3::new(2.0, -3.0, 1.0),
        axis: Vec3::new(0.2, -0.3, 0.9).normalized().unwrap(),
        radius: 7.0,
    };
    let mesh = synthetic::tessellate_with_normals(
        AnalyticSurface::Cylinder(truth),
        synthetic::Patch {
            u: [0.0, TAU],
            v: [-2.0, 3.0],
            u_segments: 32,
            v_segments: 12,
            jitter: 5.0e-8,
            position_noise: 2.0e-5,
            normal_noise: 5.0e-5,
            irregular_triangulation: true,
            density_power: [1.3, 0.8],
            seed: 0x4e4f_4953_5943_594c,
        },
    );
    let noisy_options = RecognitionOptions {
        distance_tolerance: 3.0e-5,
        relative_tolerance: 0.0,
        normal_tolerance: 1.0e-3,
        minimum_support: 16,
        sampling: SamplingMode::Vertices,
        deterministic_seed: Some(0x4e4f_4953_5943_594c),
        ..options()
    };

    let first = recognize_surfaces_with_unresolved(&mesh, &noisy_options).unwrap();
    let second = recognize_surfaces_with_unresolved(&mesh, &noisy_options).unwrap();
    assert_eq!(first, second);
    assert!(first.unresolved_triangles.is_empty(), "{first:?}");
    assert_eq!(first.regions.len(), 1, "{first:?}");
    let AnalyticSurface::Cylinder(recovered) = first.regions[0].surface else {
        panic!("selected {:?}", first.regions[0].surface);
    };
    assert!(recovered.axis.dot(truth.axis).abs() > 1.0 - 1.0e-8);
    assert!((recovered.radius - truth.radius).abs() < 5.0e-6);
    assert!(first.regions[0].metrics.max_error <= 3.0e-5);
}

#[test]
fn noisy_jittered_sphere_known_type_recovers_ground_truth() {
    let truth = SphereSurface {
        center: Vec3::new(-4.0, 1.5, 2.0),
        radius: 3.25,
    };
    let mesh = synthetic::tessellate_with_normals(
        AnalyticSurface::Sphere(truth),
        synthetic::Patch {
            u: [0.15, 5.9],
            v: [-1.2, 1.15],
            u_segments: 28,
            v_segments: 16,
            jitter: 2.0e-8,
            position_noise: 1.0e-5,
            normal_noise: 5.0e-5,
            irregular_triangulation: true,
            density_power: [0.8, 1.4],
            seed: 0x4e4f_4953_5953_5048,
        },
    );
    let ids = (0..mesh.triangles.len()).collect::<Vec<_>>();
    let noisy_options = RecognitionOptions {
        distance_tolerance: 1.5e-5,
        relative_tolerance: 0.0,
        normal_tolerance: 1.0e-3,
        sampling: SamplingMode::Vertices,
        deterministic_seed: Some(0x4e4f_4953_5953_5048),
        ..options()
    };
    let hint = SurfaceHint::KnownType {
        surface_type: SurfaceType::Sphere,
    };

    let first = reconstruct_surface(&mesh, &ids, &hint, &noisy_options).unwrap();
    let second = reconstruct_surface(&mesh, &ids, &hint, &noisy_options).unwrap();
    assert_eq!(first, second);
    let AnalyticSurface::Sphere(recovered) = first.surface else {
        panic!("selected {:?}", first.surface);
    };
    assert!(recovered.center.distance(truth.center) < 3.0e-6);
    assert!((recovered.radius - truth.radius).abs() < 2.0e-6);
    assert!(first.metrics.max_error <= 1.5e-5);
}

#[test]
fn nearly_collinear_planar_strip_remains_recognizable() {
    let truth = AnalyticSurface::Plane(brep_ransac::PlaneSurface {
        origin: Vec3::new(1.0, -2.0, 3.0),
        normal: Vec3::new(0.2, -0.1, 0.97).normalized().unwrap(),
    });
    let mesh = synthetic::tessellate(
        truth,
        synthetic::Patch {
            u: [-1.0, 1.0],
            v: [-5.0e-9, 5.0e-9],
            u_segments: 16,
            v_segments: 2,
            ..Default::default()
        },
    );
    let strip_options = RecognitionOptions {
        distance_tolerance: 1.0e-12,
        relative_tolerance: 0.0,
        normal_tolerance: 1.0e-6,
        minimum_support: 4,
        sampling: SamplingMode::TriangleCentroids,
        ..options()
    };

    let result = recognize_surfaces_with_unresolved(&mesh, &strip_options).unwrap();
    assert!(result.unresolved_triangles.is_empty(), "{result:?}");
    assert_eq!(result.regions.len(), 1, "{result:?}");
    assert_eq!(result.regions[0].surface.surface_type(), SurfaceType::Plane);
    assert!(result.regions[0].metrics.max_error <= 1.0e-12);
}