axiolid-construct 0.3.21

Solid generation: profiles, lofts, sweeps, revolutions and half-space clipping
Documentation
use std::f64::consts::TAU;

use axiolid_construct::extrude::extrude_profile_exact;
use axiolid_contracts::{GeomError, Operation};
use axiolid_core::{Tolerance, Vec2, Vec3};
use axiolid_curve::Curve2;
use axiolid_evaluate::{evaluate2, evaluate3};
use axiolid_profile::{CircleProfile, Profile, RectangleProfile};
use axiolid_surface::Surface;
use axiolid_topology::{audit_brep, Orientation};

fn rectangle(thickness: Option<f64>) -> Profile {
    Profile::Rectangle(RectangleProfile {
        x: 4.0,
        y: 2.0,
        thickness,
        outer_radius: None,
        inner_radius: None,
    })
}

fn assert_complete_and_closed(value: &axiolid_construct::ExactBRep) {
    let topology = value.topology();
    assert!(audit_brep(topology).is_closed_manifold());
    assert_eq!(topology.shells().len(), 1);
    assert!(topology.shells()[0].closed);
    assert_eq!(topology.solids().len(), 1);

    for index in 0..topology.edges().len() {
        let edge = topology.edge_id_at(index).expect("dense edge");
        assert!(value.edge_interval(edge).is_some(), "edge {index} interval");
    }
    for (loop_index, wire) in topology.loops().iter().enumerate() {
        let loop_id = topology.loop_id_at(loop_index).expect("dense loop");
        for use_index in 0..wire.edges.len() {
            assert!(
                value.pcurve_interval(loop_id, use_index).is_some(),
                "loop {loop_index} use {use_index} pcurve interval"
            );
        }
    }
}

fn assert_pcurve_surface_agreement(brep: &axiolid_construct::ExactBRep) {
    let topology = brep.topology();
    for face in topology.faces() {
        let surface_id = face.surface.expect("exact face support");
        let surface = &brep.surfaces()[surface_id.index()];
        for bound in &face.bounds {
            let wire = &topology.loops()[bound.loop_id.index()];
            for (use_index, edge_use) in wire.edges.iter().enumerate() {
                let pcurve_id = edge_use.pcurve.expect("exact pcurve");
                let pcurve = &brep.curves2()[pcurve_id.index()];
                let pspan = brep
                    .pcurve_interval(bound.loop_id, use_index)
                    .expect("pcurve span");
                let edge = &topology.edges()[edge_use.edge.index()];
                let curve_id = edge.curve.expect("exact edge support");
                let curve = &brep.curves3()[curve_id.index()];
                let espan = brep.edge_interval(edge_use.edge).expect("edge span");

                for alpha in [0.0, 0.5, 1.0] {
                    let p_t = pspan.start + (pspan.end - pspan.start) * alpha;
                    let e_t = match edge_use.orientation {
                        Orientation::Forward => espan.start + (espan.end - espan.start) * alpha,
                        Orientation::Reversed => espan.end + (espan.start - espan.end) * alpha,
                    };
                    let uv = evaluate2(pcurve, p_t).expect("pcurve evaluation");
                    let from_surface = axiolid_evaluate::surface::evaluate(surface, uv.x, uv.y)
                        .expect("surface evaluation");
                    let from_curve = evaluate3(curve, e_t).expect("edge evaluation");
                    assert!(
                        from_surface.distance(from_curve) <= 1.0e-10,
                        "pcurve/surface mismatch: {from_surface:?} vs {from_curve:?}"
                    );
                }
            }
        }
    }
}

#[test]
fn filled_rectangle_extrusion_is_an_exact_planar_solid() {
    let value = extrude_profile_exact(
        &rectangle(None),
        Vec3::new(0.25, 0.5, 1.0),
        3.0,
        Tolerance::METRE,
    )
    .expect("exact rectangle extrusion");

    assert_complete_and_closed(&value);
    assert_pcurve_surface_agreement(&value);
    assert_eq!(value.topology().vertices().len(), 8);
    assert_eq!(value.topology().edges().len(), 12);
    assert_eq!(value.topology().faces().len(), 6);
    assert_eq!(value.surfaces().len(), 6);
    assert!(value
        .surfaces()
        .iter()
        .all(|surface| matches!(surface, Surface::Plane(_))));
}

#[test]
fn hollow_rectangle_uses_cap_inner_bounds_and_through_passage_faces() {
    let value = extrude_profile_exact(&rectangle(Some(0.25)), Vec3::Z, 2.0, Tolerance::METRE)
        .expect("exact hollow rectangle extrusion");

    assert_complete_and_closed(&value);
    assert_pcurve_surface_agreement(&value);
    assert_eq!(value.topology().vertices().len(), 16);
    assert_eq!(value.topology().edges().len(), 24);
    assert_eq!(value.topology().faces().len(), 10);
    assert_eq!(value.topology().faces()[0].bounds.len(), 2);
    assert_eq!(value.topology().faces()[1].bounds.len(), 2);
    assert_eq!(value.topology().solids()[0].voids.len(), 0);
}

#[test]
fn axial_circle_extrusion_preserves_cylinder_and_distinct_seam_charts() {
    let value = extrude_profile_exact(
        &Profile::Circle(CircleProfile {
            radius: 2.0,
            thickness: None,
        }),
        Vec3::Z,
        5.0,
        Tolerance::METRE,
    )
    .expect("exact cylinder");

    assert_complete_and_closed(&value);
    assert_pcurve_surface_agreement(&value);
    assert_eq!(value.topology().vertices().len(), 2);
    assert_eq!(value.topology().edges().len(), 3);
    assert_eq!(value.topology().faces().len(), 3);
    assert_eq!(
        value
            .surfaces()
            .iter()
            .filter(|surface| matches!(surface, Surface::Cylinder(_)))
            .count(),
        1
    );
    assert_eq!(
        value
            .surfaces()
            .iter()
            .filter(|surface| matches!(surface, Surface::Plane(_)))
            .count(),
        2
    );

    let side = &value.topology().loops()[2];
    let seam_u = side
        .edges
        .iter()
        .filter_map(|edge_use| edge_use.pcurve)
        .filter_map(|id| match &value.curves2()[id.index()] {
            Curve2::Line(line) if line.direction.x == 0.0 => Some(line.origin.x),
            _ => None,
        })
        .collect::<Vec<_>>();
    assert_eq!(seam_u.len(), 2);
    assert!(seam_u.contains(&0.0));
    assert!(seam_u.contains(&TAU));
}

#[test]
fn exact_extrusion_refuses_families_whose_supports_are_not_populated() {
    // A rounded rectangle used to sit in this list. It now extrudes exactly
    // through the contour path (kernel#111); see tests/rounded_rectangle.rs.
    let annulus = Profile::Circle(CircleProfile {
        radius: 1.0,
        thickness: Some(0.2),
    });
    // A list rather than one case: every family that still refuses belongs
    // here, and a list keeps adding the next one a one-line change.
    let cases = vec![(annulus, "annular circle extrusion")];
    for (profile, family) in cases {
        let error = extrude_profile_exact(&profile, Vec3::Z, 1.0, Tolerance::METRE)
            .expect_err("unsupported exact family must refuse");
        assert!(matches!(
            error,
            GeomError::UnsupportedInput {
                operation: Operation::Sweep,
                input,
                ..
            } if input == family
        ));
    }
}

/// An oblique circle sweeps an oblique circular cylinder (#280), never a
/// right cylinder mislabelled as its support: the wall is the elliptical
/// cylinder with its axis along the direction.
#[test]
fn oblique_circle_extrusion_builds_an_oblique_cylinder_not_a_right_one() {
    let profile = Profile::Circle(CircleProfile {
        radius: 1.0,
        thickness: None,
    });
    let direction = Vec3::new(1.0, 0.0, 1.0);
    let solid = extrude_profile_exact(&profile, direction, 1.0, Tolerance::METRE)
        .expect("an oblique circle extrudes exactly");
    assert_complete_and_closed(&solid);
    assert_pcurve_surface_agreement(&solid);
    let walls: Vec<_> = solid
        .surfaces()
        .iter()
        .filter(|surface| !matches!(surface, Surface::Plane(_)))
        .collect();
    assert_eq!(walls.len(), 1);
    let Surface::EllipticalCylinder(wall) = walls[0] else {
        panic!(
            "an oblique circle's wall is not a right cylinder: {:?}",
            walls[0]
        );
    };
    let unit = direction.normalize();
    assert!((wall.frame.z - unit).length() < 1e-15);
    assert!((wall.semi_axis_x - 1.0).abs() < 1e-15);
    assert!((wall.semi_axis_y - unit.z).abs() < 1e-15);
}

/// A lean below tolerance is still a lean: the exact constructor builds the
/// oblique cylinder it denotes rather than snapping it to the normal.
#[test]
fn sub_tolerance_oblique_circle_is_not_snapped_to_the_normal() {
    let profile = Profile::Circle(CircleProfile {
        radius: 1.0,
        thickness: None,
    });
    let direction = Vec3::new(Tolerance::METRE.linear() * 0.5, 0.0, 1.0);
    let solid = extrude_profile_exact(&profile, direction, 1.0, Tolerance::METRE)
        .expect("a slightly leaning circle extrudes exactly");
    assert_complete_and_closed(&solid);
    assert_pcurve_surface_agreement(&solid);
    assert!(solid
        .surfaces()
        .iter()
        .any(|surface| matches!(surface, Surface::EllipticalCylinder(_))));
}

#[test]
fn nonzero_direction_magnitude_is_not_a_model_length() {
    let direction = Vec3::Z * (Tolerance::METRE.linear() * 0.5);
    let value = extrude_profile_exact(
        &Profile::Circle(CircleProfile {
            radius: 1.0,
            thickness: None,
        }),
        direction,
        1.0,
        Tolerance::METRE,
    )
    .expect("direction magnitude is unitless and normalization preserves the axis");

    assert_complete_and_closed(&value);
    assert_pcurve_surface_agreement(&value);
}

#[test]
fn finite_nonzero_extreme_direction_magnitudes_normalize_safely() {
    let profile = Profile::Circle(CircleProfile {
        radius: 1.0,
        thickness: None,
    });

    for magnitude in [f64::MIN_POSITIVE, f64::MAX] {
        let value = extrude_profile_exact(
            &profile,
            Vec3::new(0.0, 0.0, magnitude),
            1.0,
            Tolerance::METRE,
        )
        .expect("finite nonzero unitless directions must normalize without range loss");

        assert_complete_and_closed(&value);
        assert_pcurve_surface_agreement(&value);
    }
}

#[test]
fn mixed_extreme_direction_refuses_nonzero_component_loss() {
    let error = extrude_profile_exact(
        &rectangle(None),
        Vec3::new(f64::MIN_POSITIVE, 0.0, f64::MAX),
        1.0,
        Tolerance::METRE,
    )
    .expect_err("normalization must not silently erase a nonzero direction component");

    assert!(matches!(error, GeomError::Degenerate(_)));
}

#[test]
fn invalid_exact_extrusion_inputs_are_not_reclassified_as_unsupported() {
    let error = extrude_profile_exact(
        &rectangle(None),
        Vec2::ZERO.extend(0.0),
        1.0,
        Tolerance::METRE,
    )
    .expect_err("zero direction");
    assert!(matches!(error, GeomError::InvalidInput(_)));
}