BREP_kernel 0.2.0

A boundary representation (BREP) geometry kernel for building CAD applications.
Documentation
use super::*;
use crate::make_line;

fn square(z: f64, scale: f64) -> Vec<NurbsCurve> {
    let points = [
        Vec3::new(-scale, -scale, z),
        Vec3::new(scale, -scale, z),
        Vec3::new(scale, scale, z),
        Vec3::new(-scale, scale, z),
    ];
    (0..4)
        .map(|index| make_line(points[index], points[(index + 1) % 4]).unwrap())
        .collect()
}

#[test]
fn three_section_loft_has_shared_skin_topology() {
    let solid =
        loft_profile_brep(&[square(0.0, 2.0), square(3.0, 3.0), square(8.0, 1.0)]).unwrap();
    assert!(solid.validate().is_empty());
    assert_eq!(solid.vertices.len(), 8);
    assert_eq!(solid.edges.len(), 12);
    assert_eq!(solid.shells[0].faces.len(), 6);
}

/// Clockwise sections are winding-normalized internally; emitted side
/// faces must still correspond to the input curves index-for-index
/// (the extrude/revolve wall-naming permutation, loft edition).
#[test]
fn clockwise_sections_keep_side_faces_in_input_order() {
    let clockwise = |z: f64, scale: f64| -> Vec<NurbsCurve> {
        let points = [
            Vec3::new(-scale, -scale, z),
            Vec3::new(-scale, scale, z),
            Vec3::new(scale, scale, z),
            Vec3::new(scale, -scale, z),
        ];
        (0..4)
            .map(|index| make_line(points[index], points[(index + 1) % 4]).unwrap())
            .collect()
    };
    let bottom = clockwise(0.0, 2.0);
    let solid = loft_profile_brep(&[bottom.clone(), clockwise(5.0, 2.0)]).unwrap();
    assert!(solid.validate().is_empty());
    let faces = &solid.shells[0].faces;
    for (index, curve) in bottom.iter().enumerate() {
        let [start, end] = curve.domain().unwrap();
        let midpoint = curve.evaluate((start + end) / 2.0).unwrap();
        let projection =
            crate::project_point_to_surface(&faces[index].surface, midpoint).unwrap();
        assert!(
            projection.distance < 1e-9,
            "side face {index} does not carry input curve {index} \
             (distance {})",
            projection.distance
        );
    }
}

/// A 2×2 square (XY plane, +Z normal) centered at `center`, as four lines.
fn square_at(center: Vec3, half: f64) -> Vec<NurbsCurve> {
    let points = [
        center.add(Vec3::new(-half, -half, 0.0)),
        center.add(Vec3::new(half, -half, 0.0)),
        center.add(Vec3::new(half, half, 0.0)),
        center.add(Vec3::new(-half, half, 0.0)),
    ];
    (0..4)
        .map(|index| make_line(points[index], points[(index + 1) % 4]).unwrap())
        .collect()
}

/// The AABB of a solid, sampling every edge curve (the loft's vertical edges
/// are skin isocurves that follow the guide's bulge, so they capture the
/// bend a corner-vertex-only box would miss).
fn solid_aabb(solid: &BrepSolid) -> ([f64; 3], [f64; 3]) {
    let mut min = [f64::INFINITY; 3];
    let mut max = [f64::NEG_INFINITY; 3];
    let mut swallow = |point: Vec3| {
        min[0] = min[0].min(point.x);
        min[1] = min[1].min(point.y);
        min[2] = min[2].min(point.z);
        max[0] = max[0].max(point.x);
        max[1] = max[1].max(point.y);
        max[2] = max[2].max(point.z);
    };
    for vertex in &solid.vertices {
        swallow(vertex.point);
    }
    for edge in &solid.edges {
        let [start, end] = edge.curve.domain().unwrap();
        for index in 0..=24 {
            swallow(
                edge.curve
                    .evaluate(start + (end - start) * index as f64 / 24.0)
                    .unwrap(),
            );
        }
    }
    (min, max)
}

/// Two identical squares lofted along a SEMICIRCULAR guide that bulges in +X
/// must produce a valid solid whose skin BENDS along the guide: the solid's
/// AABB reaches out to the arc's bulge (max_x ≈ arc radius + square half),
/// far beyond the straight centroid-to-centroid loft (which never leaves
/// x ∈ [−1, 1]).
#[test]
fn guided_loft_bends_two_squares_along_an_arc() {
    use crate::{make_arc, solid_mass_properties};
    // Sections at the guide's two endpoints on the Z axis.
    let bottom = square_at(Vec3::new(0.0, 0.0, 0.0), 1.0);
    let top = square_at(Vec3::new(0.0, 0.0, 8.0), 1.0);
    // Semicircle (r=4) from (0,0,0) to (0,0,8) bulging to +X, apex (4,0,4).
    let guide = make_arc(
        Vec3::new(0.0, 0.0, 4.0),
        Vec3::new(0.0, 0.0, -1.0),
        Vec3::new(1.0, 0.0, 0.0),
        4.0,
        0.0,
        std::f64::consts::PI,
    )
    .unwrap();

    let solid =
        loft_profile_brep_guided(&[bottom.clone(), top.clone()], &guide, Some("Guided"))
            .unwrap();
    assert!(
        solid.validate().is_empty(),
        "guided loft is invalid: {:?}",
        solid.validate()
    );
    let volume = solid_mass_properties(&solid).unwrap().volume;
    assert!(volume > 0.0, "guided loft has non-positive volume {volume}");

    // The guided solid must reach the arc's +X bulge; the straight loft of
    // the same two squares stays within x ∈ [−1, 1].
    let (guided_min, guided_max) = solid_aabb(&solid);
    let plain = loft_profile_brep(&[bottom, top]).unwrap();
    let (_plain_min, plain_max) = solid_aabb(&plain);
    assert!(
        plain_max[0] < 1.5,
        "straight loft should not bulge in +X (max_x {})",
        plain_max[0]
    );
    // Arc apex is at x = 4; sections are ±1 wide, so the bent skin reaches
    // well past x = 3 while the straight loft cannot.
    assert!(
        guided_max[0] > 3.0,
        "guided loft did not bend along the guide (max_x {} <= 3.0)",
        guided_max[0]
    );
    // The bend keeps the full Z span of the two end sections.
    assert!(
        guided_min[2] <= 1e-6 && guided_max[2] >= 8.0 - 1e-6,
        "guided loft z-span [{}, {}] does not cover the sections",
        guided_min[2],
        guided_max[2]
    );
}

/// A STRAIGHT guide between two sections reproduces the plain loft: the
/// centroid path is already the straight chord, so every station's blend
/// needs no translation and the guided volume matches loft_profile_brep
/// within a few percent.
#[test]
fn guided_loft_straight_guide_matches_plain_loft() {
    use crate::solid_mass_properties;
    let bottom = square_at(Vec3::new(0.0, 0.0, 0.0), 2.0);
    let top = square_at(Vec3::new(0.0, 0.0, 6.0), 1.0);
    let guide = make_line(Vec3::new(0.0, 0.0, 0.0), Vec3::new(0.0, 0.0, 6.0)).unwrap();

    let guided =
        loft_profile_brep_guided(&[bottom.clone(), top.clone()], &guide, None).unwrap();
    assert!(
        guided.validate().is_empty(),
        "straight-guided loft is invalid: {:?}",
        guided.validate()
    );
    let plain = loft_profile_brep(&[bottom, top]).unwrap();

    let guided_volume = solid_mass_properties(&guided).unwrap().volume;
    let plain_volume = solid_mass_properties(&plain).unwrap().volume;
    assert!(guided_volume > 0.0, "guided volume {guided_volume}");
    let error = (guided_volume - plain_volume).abs() / plain_volume;
    assert!(
        error < 0.03,
        "straight-guided volume {guided_volume} not within 3% of plain loft {plain_volume} (error {error})"
    );
}

/// A 2h×2h square as four lines through the given corner list (used to
/// build sections with an EXACT rigid correspondence between stations).
fn square_from_corners(corners: [Vec3; 4]) -> Vec<NurbsCurve> {
    (0..4)
        .map(|index| make_line(corners[index], corners[(index + 1) % 4]).unwrap())
        .collect()
}

/// §5.8 rotation-to-frame: two unit-half squares at the ends of a quarter
/// circle (r = 10, XZ plane), each PERPENDICULAR to the guide tangent at
/// its end (the end section is the start section carried by the exact
/// rigid motion that transports the start frame to the end frame).  The
/// frame-mode loft is a quarter square-torus, so Pappus applies exactly:
/// V = A·L = 4 · (π/2 · 10) = 62.8319.  Translation mode (v1) blends the
/// two mutually rotated squares through a degenerate diagonal mid-section
/// (area 2√2 < 4) and misses Pappus badly — pinning that the rotation
/// actually happens, and that both modes stay available and distinct.
#[test]
fn frame_guided_loft_is_pappus_exact_on_a_quarter_arc() {
    use crate::{make_arc, solid_mass_properties};
    let guide = make_arc(
        Vec3::new(0.0, 0.0, 0.0),
        Vec3::new(1.0, 0.0, 0.0),
        Vec3::new(0.0, 0.0, 1.0),
        10.0,
        0.0,
        std::f64::consts::FRAC_PI_2,
    )
    .unwrap();
    // Start section ⟂ tangent (0,0,1) at (10,0,0); end section is its
    // image under the frame transport rotY(−90°): (x,y,z) → (−z,y,x).
    let start_corners = [
        Vec3::new(9.0, -1.0, 0.0),
        Vec3::new(11.0, -1.0, 0.0),
        Vec3::new(11.0, 1.0, 0.0),
        Vec3::new(9.0, 1.0, 0.0),
    ];
    let end_corners = [
        Vec3::new(0.0, -1.0, 9.0),
        Vec3::new(0.0, -1.0, 11.0),
        Vec3::new(0.0, 1.0, 11.0),
        Vec3::new(0.0, 1.0, 9.0),
    ];
    let sections = vec![
        square_from_corners(start_corners),
        square_from_corners(end_corners),
    ];

    let framed = loft_profile_brep_guided_frame(&sections, &guide, None).unwrap();
    assert!(
        framed.validate().is_empty(),
        "frame-guided loft is invalid: {:?}",
        framed.validate()
    );
    let pappus = 4.0 * (std::f64::consts::FRAC_PI_2 * 10.0);
    let framed_volume = solid_mass_properties(&framed).unwrap().volume;
    let framed_error = (framed_volume - pappus).abs() / pappus;
    assert!(
        framed_error < 2e-3,
        "frame-guided volume {framed_volume} not within 0.2% of Pappus {pappus} (error {framed_error})"
    );

    // User sections are reproduced EXACTLY: every input corner is a solid
    // vertex (localize→reconstruct through the same frame is the identity).
    for corner in start_corners.iter().chain(end_corners.iter()) {
        let hit = framed
            .vertices
            .iter()
            .any(|vertex| vertex.point.sub(*corner).length() < 1e-9);
        assert!(hit, "user-section corner {corner:?} is not a solid vertex");
    }

    // Translation mode on the same input is far from Pappus.
    let translated = loft_profile_brep_guided(&sections, &guide, None).unwrap();
    let translated_volume = solid_mass_properties(&translated).unwrap().volume;
    let translated_error = (translated_volume - pappus).abs() / pappus;
    assert!(
        translated_error > 0.05,
        "translation mode unexpectedly matches Pappus ({translated_volume} vs {pappus}); \
         the frame mode would be indistinguishable"
    );
}

/// A straight guide makes the RMF frame constant, so frame mode and
/// translation mode must agree (the local blend equals the world blend).
#[test]
fn frame_guided_loft_matches_translation_on_a_straight_guide() {
    use crate::solid_mass_properties;
    let bottom = square_at(Vec3::new(0.0, 0.0, 0.0), 2.0);
    let top = square_at(Vec3::new(0.0, 0.0, 6.0), 1.0);
    let guide = make_line(Vec3::new(0.0, 0.0, 0.0), Vec3::new(0.0, 0.0, 6.0)).unwrap();

    let framed =
        loft_profile_brep_guided_frame(&[bottom.clone(), top.clone()], &guide, None).unwrap();
    assert!(
        framed.validate().is_empty(),
        "frame-guided straight loft is invalid: {:?}",
        framed.validate()
    );
    let translated = loft_profile_brep_guided(&[bottom, top], &guide, None).unwrap();
    let framed_volume = solid_mass_properties(&framed).unwrap().volume;
    let translated_volume = solid_mass_properties(&translated).unwrap().volume;
    let error = (framed_volume - translated_volume).abs() / translated_volume;
    assert!(
        error < 1e-9,
        "frame mode {framed_volume} deviates from translation mode {translated_volume} on a straight guide (error {error})"
    );
}