hypercurve 0.2.0

Hyperreal-backed planar curves, contours, and regions for CAD topology
Documentation
use hypercurve::{
    BooleanBoundaryFragmentSet, BooleanFragmentAction, BooleanOp, BulgeVertex2, Classification,
    Contour2, CurvePolicy, DirectedBooleanFragment, FillRule, Real, Region2, RegionContourKey,
    RegionContourRole, RegionSide, Segment2, UncertaintyReason,
};

fn s(value: i32) -> Real {
    value.into()
}

fn p(x: i32, y: i32) -> hypercurve::Point2 {
    hypercurve::Point2::new(s(x), s(y))
}

fn vertex(x: i32, y: i32, bulge: i32) -> BulgeVertex2 {
    BulgeVertex2::new(p(x, y), s(bulge))
}

fn contour(vertices: &[BulgeVertex2]) -> Contour2 {
    Contour2::from_bulge_vertices(vertices).unwrap()
}

fn rectangle(xmin: i32, ymin: i32, xmax: i32, ymax: i32) -> Contour2 {
    contour(&[
        vertex(xmin, ymin, 0),
        vertex(xmax, ymin, 0),
        vertex(xmax, ymax, 0),
        vertex(xmin, ymax, 0),
    ])
}

fn policy() -> CurvePolicy {
    CurvePolicy::certified()
}

fn line_segment(x0: i32, y0: i32, x1: i32, y1: i32) -> Segment2 {
    Segment2::Line(hypercurve::LineSeg2::try_new(p(x0, y0), p(x1, y1)).unwrap())
}

fn overlapping_fragments() -> (Region2, Region2, hypercurve::RegionFragmentSet) {
    let first = Region2::from_material_contours(vec![rectangle(0, 0, 4, 4)]);
    let second = Region2::from_material_contours(vec![rectangle(2, -1, 6, 3)]);
    let intersections = first.intersect_region(&second, &policy()).unwrap();
    let Classification::Decided(fragments) = intersections
        .split_regions(&first.as_view(), &second.as_view(), &policy())
        .unwrap()
    else {
        panic!("expected decided fragments");
    };

    (first, second, fragments)
}

#[test]
fn boolean_fragment_selection_classifies_union_and_intersection() {
    let (first, second, fragments) = overlapping_fragments();

    let Classification::Decided(union) = fragments
        .classify_for_boolean(
            &first.as_view(),
            &second.as_view(),
            BooleanOp::Union,
            &policy(),
        )
        .unwrap()
    else {
        panic!("expected decided union selection");
    };
    let Classification::Decided(intersection) = fragments
        .classify_for_boolean(
            &first.as_view(),
            &second.as_view(),
            BooleanOp::Intersection,
            &policy(),
        )
        .unwrap()
    else {
        panic!("expected decided intersection selection");
    };

    assert!(union.count_action(BooleanFragmentAction::KeepSourceDirection) > 0);
    assert!(intersection.count_action(BooleanFragmentAction::KeepSourceDirection) > 0);
    assert_eq!(
        union.count_action(BooleanFragmentAction::BoundaryNeedsResolution),
        0
    );
    assert_eq!(
        intersection.count_action(BooleanFragmentAction::BoundaryNeedsResolution),
        0
    );
    assert_ne!(
        union.count_action(BooleanFragmentAction::KeepSourceDirection),
        intersection.count_action(BooleanFragmentAction::KeepSourceDirection)
    );
}

#[test]
fn boolean_fragment_selection_reverses_second_operand_for_difference() {
    let (first, second, fragments) = overlapping_fragments();

    let Classification::Decided(difference) = fragments
        .classify_for_boolean(
            &first.as_view(),
            &second.as_view(),
            BooleanOp::Difference,
            &policy(),
        )
        .unwrap()
    else {
        panic!("expected decided difference selection");
    };

    assert!(difference.count_action(BooleanFragmentAction::KeepSourceDirection) > 0);
    assert!(difference.count_action(BooleanFragmentAction::KeepReversed) > 0);
}

#[test]
fn boolean_fragment_selection_emits_directed_boundary_fragments() {
    let (first, second, fragments) = overlapping_fragments();
    let Classification::Decided(union) = fragments
        .classify_for_boolean(
            &first.as_view(),
            &second.as_view(),
            BooleanOp::Union,
            &policy(),
        )
        .unwrap()
    else {
        panic!("expected decided union selection");
    };

    let emitted = union.emit_boundary_fragments(&fragments).unwrap();

    assert_eq!(
        emitted.directed_len(),
        union.count_action(BooleanFragmentAction::KeepSourceDirection)
            + union.count_action(BooleanFragmentAction::KeepReversed)
    );
    assert_eq!(emitted.unresolved_len(), 0);
    assert!(emitted.is_ready_for_traversal());

    let Classification::Decided(chains) = emitted.assemble_chains(&policy()) else {
        panic!("expected assembled boundary chains");
    };
    assert_eq!(chains.len(), 1);
    assert_eq!(chains.closed_count(), 1);
    assert_eq!(chains.chains()[0].len(), emitted.directed_len());

    let Classification::Decided(loops) = chains.closed_loops() else {
        panic!("expected a closed boolean loop");
    };
    assert_eq!(loops.len(), 1);

    let contours = loops.to_contours(FillRule::NonZero).unwrap();
    assert_eq!(contours.len(), 1);
    assert_eq!(contours[0].len(), emitted.directed_len());
}

#[test]
fn boolean_boundary_chain_assembly_keeps_disjoint_loops_separate() {
    let first = Region2::from_material_contours(vec![rectangle(0, 0, 2, 2)]);
    let second = Region2::from_material_contours(vec![rectangle(4, 4, 6, 6)]);
    let intersections = first.intersect_region(&second, &policy()).unwrap();
    let Classification::Decided(fragments) = intersections
        .split_regions(&first.as_view(), &second.as_view(), &policy())
        .unwrap()
    else {
        panic!("expected decided fragments");
    };
    let Classification::Decided(union) = fragments
        .classify_for_boolean(
            &first.as_view(),
            &second.as_view(),
            BooleanOp::Union,
            &policy(),
        )
        .unwrap()
    else {
        panic!("expected decided union selection");
    };
    let emitted = union.emit_boundary_fragments(&fragments).unwrap();

    let Classification::Decided(chains) = emitted.assemble_chains(&policy()) else {
        panic!("expected disjoint closed chains");
    };

    assert_eq!(chains.len(), 2);
    assert_eq!(chains.closed_count(), 2);
    assert!(chains.chains().iter().all(|chain| chain.len() == 4));

    let Classification::Decided(loops) = chains.into_closed_loops() else {
        panic!("expected disjoint closed loops");
    };
    let contours = loops.into_contours(FillRule::NonZero).unwrap();
    assert_eq!(contours.len(), 2);
    assert!(contours.iter().all(|contour| contour.len() == 4));
}

#[test]
fn boolean_fragment_selection_reverses_emitted_second_difference_fragments() {
    let (first, second, fragments) = overlapping_fragments();
    let Classification::Decided(difference) = fragments
        .classify_for_boolean(
            &first.as_view(),
            &second.as_view(),
            BooleanOp::Difference,
            &policy(),
        )
        .unwrap()
    else {
        panic!("expected decided difference selection");
    };

    let emitted = difference.emit_boundary_fragments(&fragments).unwrap();
    let second_key = RegionContourKey::new(RegionSide::Second, RegionContourRole::Material, 0);
    let reversed = difference
        .classifications()
        .iter()
        .find(|classification| {
            classification.key == second_key
                && classification.action == BooleanFragmentAction::KeepReversed
        })
        .expect("expected a reversed second-operand fragment");
    let source = fragments
        .fragments_for_contour(second_key)
        .unwrap()
        .fragments
        .fragments()
        .get(reversed.fragment_index)
        .unwrap();
    let directed = emitted
        .directed_fragments()
        .iter()
        .find(|fragment| {
            fragment.key == second_key && fragment.fragment_index == reversed.fragment_index
        })
        .expect("expected emitted reversed fragment");

    assert_eq!(directed.segment.start(), source.segment.end());
    assert_eq!(directed.segment.end(), source.segment.start());
}

#[test]
fn boolean_fragment_selection_defers_shared_boundary_fragments() {
    let first = Region2::from_material_contours(vec![rectangle(0, 0, 4, 4)]);
    let second = Region2::from_material_contours(vec![rectangle(2, -2, 6, 0)]);
    let intersections = first.intersect_region(&second, &policy()).unwrap();
    let Classification::Decided(fragments) = intersections
        .split_regions(&first.as_view(), &second.as_view(), &policy())
        .unwrap()
    else {
        panic!("expected decided fragments");
    };

    let Classification::Decided(selection) = fragments
        .classify_for_boolean(
            &first.as_view(),
            &second.as_view(),
            BooleanOp::Union,
            &policy(),
        )
        .unwrap()
    else {
        panic!("expected decided selection");
    };

    assert!(selection.count_action(BooleanFragmentAction::BoundaryNeedsResolution) > 0);
    let emitted = selection.emit_boundary_fragments(&fragments).unwrap();
    assert!(!emitted.is_ready_for_traversal());
    assert_eq!(
        emitted.unresolved_len(),
        selection.count_action(BooleanFragmentAction::BoundaryNeedsResolution)
    );
    assert_eq!(
        emitted.assemble_chains(&policy()),
        Classification::Uncertain(UncertaintyReason::Boundary)
    );
}

#[test]
fn segment_representative_point_samples_arc_geometry() {
    let circle = contour(&[vertex(0, 0, 1), vertex(2, 0, 1)]);
    let first_midpoint = circle.segments()[0]
        .representative_point(&policy())
        .unwrap();

    assert_eq!(first_midpoint, Classification::Decided(p(1, -1)));
}

#[test]
fn reversing_segments_swaps_endpoints_and_arc_orientation() {
    let line = Segment2::Line(hypercurve::LineSeg2::try_new(p(0, 0), p(2, 0)).unwrap());
    let Segment2::Line(reversed_line) = line.reversed() else {
        panic!("expected reversed line");
    };
    assert_eq!(reversed_line.start(), &p(2, 0));
    assert_eq!(reversed_line.end(), &p(0, 0));

    let arc = Segment2::Arc(hypercurve::CircularArc2::from_bulge(p(0, 0), p(2, 0), s(1)).unwrap());
    let Segment2::Arc(reversed_arc) = arc.reversed() else {
        panic!("expected reversed arc");
    };
    assert_eq!(reversed_arc.start(), &p(2, 0));
    assert_eq!(reversed_arc.end(), &p(0, 0));
    assert!(reversed_arc.is_clockwise());
    assert_eq!(reversed_arc.bulge(), Some(&s(-1)));
}

#[test]
fn boundary_chain_assembly_rejects_branch_points() {
    let key = RegionContourKey::new(RegionSide::First, RegionContourRole::Material, 0);
    let fragments = BooleanBoundaryFragmentSet::new(
        vec![
            DirectedBooleanFragment {
                key,
                fragment_index: 0,
                segment: line_segment(0, 0, 1, 0),
            },
            DirectedBooleanFragment {
                key,
                fragment_index: 1,
                segment: line_segment(1, 0, 2, 0),
            },
            DirectedBooleanFragment {
                key,
                fragment_index: 2,
                segment: line_segment(1, 0, 1, 1),
            },
        ],
        Vec::new(),
    );

    assert_eq!(
        fragments.assemble_chains(&policy()),
        Classification::Uncertain(UncertaintyReason::Unsupported)
    );
}

#[test]
fn boundary_loop_extraction_rejects_open_chains() {
    let key = RegionContourKey::new(RegionSide::First, RegionContourRole::Material, 0);
    let fragments = BooleanBoundaryFragmentSet::new(
        vec![
            DirectedBooleanFragment {
                key,
                fragment_index: 0,
                segment: line_segment(0, 0, 1, 0),
            },
            DirectedBooleanFragment {
                key,
                fragment_index: 1,
                segment: line_segment(1, 0, 2, 0),
            },
        ],
        Vec::new(),
    );

    let Classification::Decided(chains) = fragments.assemble_chains(&policy()) else {
        panic!("expected open chain assembly to succeed");
    };
    assert_eq!(chains.len(), 1);
    assert_eq!(chains.closed_count(), 0);
    assert_eq!(
        chains.closed_loops(),
        Classification::Uncertain(UncertaintyReason::Unsupported)
    );
}