BREP_kernel 0.2.0

A boundary representation (BREP) geometry kernel for building CAD applications.
Documentation
use super::*;
use crate::{chamfer_edge, fillet_edge, make_box_brep, make_cylinder_brep};

fn unit_cube() -> BrepSolid {
    make_box_brep(Vec3::new(0.0, 0.0, 0.0), 1.0, 1.0, 1.0).unwrap()
}

/// The edge whose midpoint is nearest `target`.
fn edge_id_near(solid: &BrepSolid, target: Vec3) -> u64 {
    solid
        .edges
        .iter()
        .min_by(|a, b| {
            let ma = a
                .curve
                .evaluate((a.t0 + a.t1) * 0.5)
                .unwrap()
                .sub(target)
                .length();
            let mb = b
                .curve
                .evaluate((b.t0 + b.t1) * 0.5)
                .unwrap()
                .sub(target)
                .length();
            ma.partial_cmp(&mb).unwrap()
        })
        .unwrap()
        .id
}

#[test]
fn deletes_chamfer_and_recovers_the_sharp_cube() {
    let cube = unit_cube();
    let volume = solid_signed_volume(&cube).unwrap().abs();
    // Sharp edge between the top (z=1) and right (x=1) faces.
    let edge = edge_id_near(&cube, Vec3::new(1.0, 0.5, 1.0));
    let chamfered = chamfer_edge(&cube, edge, 0.2, Some("chamfer")).unwrap();
    assert!(chamfered.validate().is_empty());
    // The chamfer face sits over the bevel centre.
    let chamfer_face = resolve_face_by_point(&chamfered, Vec3::new(0.9, 0.5, 0.9)).unwrap();
    let face_count_before = chamfered.shells[0].faces.len();

    let healed = delete_face_and_heal(&chamfered, chamfer_face).unwrap();
    assert!(
        healed.validate().is_empty(),
        "healed solid must validate: {:?}",
        healed.validate()
    );
    // The chamfer face is gone.
    assert_eq!(healed.shells[0].faces.len(), face_count_before - 1);
    assert!(!healed.shells[0].faces.iter().any(|f| f.id == chamfer_face));
    // Volume returns to the full cube; the sharp edge is recovered.
    let healed_volume = solid_signed_volume(&healed).unwrap().abs();
    assert!(
        (healed_volume - volume).abs() < 1e-6,
        "expected full-cube volume {volume}, got {healed_volume}"
    );
    // A sharp edge sits exactly on the recovered corner line x=1,z=1.
    assert!(healed.edges.iter().any(|edge| {
        let mid = edge.curve.evaluate((edge.t0 + edge.t1) * 0.5).unwrap();
        (mid.x - 1.0).abs() < 1e-6 && (mid.z - 1.0).abs() < 1e-6
    }));
}

#[test]
fn deletes_fillet_and_recovers_the_sharp_cube() {
    let cube = unit_cube();
    let volume = solid_signed_volume(&cube).unwrap().abs();
    let edge = edge_id_near(&cube, Vec3::new(1.0, 0.5, 1.0));
    let filleted = fillet_edge(&cube, edge, 0.2, Some("fillet")).unwrap();
    assert!(filleted.validate().is_empty());
    // The cylindrical fillet face's 45° point sits on the diagonal at the
    // rolling-ball centre (1-r, ·, 1-r) plus r/√2 along (1,0,1).
    let offset = 0.2 / 2.0_f64.sqrt();
    let probe = Vec3::new(0.8 + offset, 0.5, 0.8 + offset);
    let fillet_face = resolve_face_by_point(&filleted, probe).unwrap();
    let face_count_before = filleted.shells[0].faces.len();

    let healed = delete_face_and_heal(&filleted, fillet_face).unwrap();
    assert!(
        healed.validate().is_empty(),
        "healed solid must validate: {:?}",
        healed.validate()
    );
    assert_eq!(healed.shells[0].faces.len(), face_count_before - 1);
    let healed_volume = solid_signed_volume(&healed).unwrap().abs();
    assert!(
        (healed_volume - volume).abs() < 1e-6,
        "expected full-cube volume {volume}, got {healed_volume}"
    );
    assert!(healed.edges.iter().any(|edge| {
        let mid = edge.curve.evaluate((edge.t0 + edge.t1) * 0.5).unwrap();
        (mid.x - 1.0).abs() < 1e-6 && (mid.z - 1.0).abs() < 1e-6
    }));
}

#[test]
fn refuses_to_delete_a_face_whose_neighbours_cannot_reintersect() {
    // Deleting a plain cube face: its four neighbours are two pairs of
    // parallel planes, so nothing re-intersects cleanly.
    let cube = unit_cube();
    let top = resolve_face_by_point(&cube, Vec3::new(0.5, 0.5, 1.0)).unwrap();
    let result = delete_face_and_heal(&cube, top);
    assert!(result.is_err(), "expected a refusal, got a solid");
    let message = result.unwrap_err();
    assert!(
        message.contains("re-intersect") || message.contains("parallel"),
        "unexpected error: {message}"
    );
    // And the original solid is untouched / still valid.
    assert!(cube.validate().is_empty());
}

// --- move_faces --------------------------------------------------------

/// (a) Extrude-equivalent sanity: pushing the +x face of a 1×2×3 box
/// outward by 0.5 along x must grow the volume by exactly
/// 0.5 · (dy · dz) = 0.5 · 6 = 3.
#[test]
fn moving_a_box_face_outward_grows_volume_like_an_extrude() {
    let block = make_box_brep(Vec3::new(0.0, 0.0, 0.0), 1.0, 2.0, 3.0).unwrap();
    let volume = solid_signed_volume(&block).unwrap();
    let plus_x = resolve_face_by_point(&block, Vec3::new(1.0, 1.0, 1.5)).unwrap();

    let moved = move_faces(&block, &[plus_x], Vec3::new(0.5, 0.0, 0.0)).unwrap();
    assert!(
        moved.validate().is_empty(),
        "moved solid must validate: {:?}",
        moved.validate()
    );
    let moved_volume = solid_signed_volume(&moved).unwrap();
    assert!(
        (moved_volume - (volume + 0.5 * 2.0 * 3.0)).abs() < 1e-9,
        "expected {} + 3, got {moved_volume}",
        volume
    );
    // Pure geometry edit: no topology was created or destroyed.
    assert_eq!(moved.vertices.len(), block.vertices.len());
    assert_eq!(moved.edges.len(), block.edges.len());
    assert_eq!(moved.shells[0].faces.len(), block.shells[0].faces.len());
    // The input is untouched.
    assert!(block.validate().is_empty());
    assert!((solid_signed_volume(&block).unwrap() - volume).abs() < 1e-12);
}

/// (b) The same face moved INWARD shrinks the volume by exactly the same
/// prism: 0.25 · (dy · dz) = 1.5.
#[test]
fn moving_a_box_face_inward_shrinks_volume_exactly() {
    let block = make_box_brep(Vec3::new(0.0, 0.0, 0.0), 1.0, 2.0, 3.0).unwrap();
    let volume = solid_signed_volume(&block).unwrap();
    let plus_x = resolve_face_by_point(&block, Vec3::new(1.0, 1.0, 1.5)).unwrap();

    let moved = move_faces(&block, &[plus_x], Vec3::new(-0.25, 0.0, 0.0)).unwrap();
    assert!(moved.validate().is_empty());
    let moved_volume = solid_signed_volume(&moved).unwrap();
    assert!(
        (moved_volume - (volume - 0.25 * 2.0 * 3.0)).abs() < 1e-9,
        "expected {} - 1.5, got {moved_volume}",
        volume
    );
}

/// (c) A two-face group: the +x and +y faces of the unit cube moved
/// together by (0.3, 0.4, 0).
///
/// Hand computation of the expected volume: translating a PLANE only
/// displaces it by the normal component of the translation, so the +x
/// carrier (normal +x) lands on x = 1.3 and the +y carrier (normal +y)
/// lands on y = 1.4. Re-intersecting with the four fixed planes x = 0,
/// y = 0, z = 0, z = 1 leaves the axis-aligned prism
/// [0, 1.3] × [0, 1.4] × [0, 1], i.e.
///     V = 1.3 · 1.4 · 1.0 = 1.82.
/// The cube edge shared by the two moved faces is interior to the group
/// and rides rigidly onto the line x = 1.3, y = 1.4 — exactly where the
/// two translated carriers re-intersect, so the group stays attached.
#[test]
fn moving_a_two_face_group_diagonally_yields_the_analytic_prism() {
    let cube = unit_cube();
    let plus_x = resolve_face_by_point(&cube, Vec3::new(1.0, 0.5, 0.5)).unwrap();
    let plus_y = resolve_face_by_point(&cube, Vec3::new(0.5, 1.0, 0.5)).unwrap();

    let moved = move_faces(&cube, &[plus_x, plus_y], Vec3::new(0.3, 0.4, 0.0)).unwrap();
    assert!(
        moved.validate().is_empty(),
        "moved solid must validate: {:?}",
        moved.validate()
    );
    let moved_volume = solid_signed_volume(&moved).unwrap();
    assert!(
        (moved_volume - 1.82).abs() < 1e-9,
        "expected the analytic prism volume 1.82, got {moved_volume}"
    );
    // The group's interior edge was carried rigidly: its top corner is
    // the full translation of (1, 1, 1).
    assert!(moved
        .vertices
        .iter()
        .any(|vertex| { vertex.point.sub(Vec3::new(1.3, 1.4, 1.0)).length() < 1e-9 }));
    assert_eq!(moved.vertices.len(), 8);
    assert_eq!(moved.edges.len(), 12);
    assert_eq!(moved.shells[0].faces.len(), 6);
}

/// (d) Moving a cylinder's cap along its axis: the moved face is planar
/// but its neighbour is the cylindrical wall. This v1 slice only
/// re-intersects against PLANAR fixed neighbours, so it must refuse
/// honestly (extending the wall's bounded NURBS carrier along its rulings
/// is the deferred follow-up), and the input must stay untouched.
#[test]
fn moving_a_cylinder_cap_is_refused_honestly_in_the_planar_neighbour_slice() {
    let cylinder =
        make_cylinder_brep(Vec3::default(), Vec3::new(0.0, 0.0, 1.0), 1.0, 2.0).unwrap();
    let cap = resolve_face_by_point(&cylinder, Vec3::new(0.0, 0.0, 2.0)).unwrap();

    let result = move_faces(&cylinder, &[cap], Vec3::new(0.0, 0.0, 0.5));
    let message = result.unwrap_err();
    assert!(
        message.starts_with("move_faces:"),
        "refusal must carry the operation name: {message}"
    );
    assert!(
        message.contains("not planar"),
        "refusal must name the curved-neighbour limitation: {message}"
    );
    assert!(cylinder.validate().is_empty());
}

/// (e) Unknown ids, an empty group, and translations that collapse or
/// invert the box past its opposite face are refused without panicking
/// and without corrupting the input.
#[test]
fn unknown_ids_and_collapsing_translations_are_refused_without_panic() {
    let cube = unit_cube();
    let step = Vec3::new(0.1, 0.0, 0.0);

    let unknown = move_faces(&cube, &[424_242], step).unwrap_err();
    assert!(unknown.contains("no face with id"), "got: {unknown}");

    let empty = move_faces(&cube, &[], step).unwrap_err();
    assert!(empty.contains("no faces selected"), "got: {empty}");

    let plus_x = resolve_face_by_point(&cube, Vec3::new(1.0, 0.5, 0.5)).unwrap();
    // Landing exactly on the opposite face collapses the side edges.
    let collapse = move_faces(&cube, &[plus_x], Vec3::new(-1.0, 0.0, 0.0)).unwrap_err();
    assert!(collapse.contains("collapses"), "got: {collapse}");
    // Passing beyond the opposite face reverses the side edges.
    let invert = move_faces(&cube, &[plus_x], Vec3::new(-1.5, 0.0, 0.0)).unwrap_err();
    assert!(invert.contains("inverts"), "got: {invert}");

    // The refusal path never mutates the input.
    assert!(cube.validate().is_empty());
    assert!((solid_signed_volume(&cube).unwrap() - 1.0).abs() < 1e-12);
}