Skip to content

Boolean union keeps internal coplanar interface faces #6

@darwin

Description

@darwin

Summary

When two solids touch on a coplanar face, boolean_union keeps the shared interface as two opposite-facing faces instead of removing it.

This creates an internal partition inside the union result:

  • topology is non-manifold (junction edges have more than 2 incident faces)
  • surface_area is overcounted
  • volume can still appear correct

Minimal reproduction

[union [cube 10 10 25] [translate 0 0 25 [cube 10 10 35]]]

Two cuboids are stacked and share the entire plane z = 25.

Expected

Result should be identical to a single 10 x 10 x 60 cuboid:

  • no internal faces
  • manifold boundary
  • surface_area = 2600

Actual

  • result contains 12 faces
  • two extra faces lie on the internal interface plane (z = 25), with opposite normals
  • surface_area = 2800 (+200 = 2 x (10 x 10))

Why this is a bug

Keeping both sides of the mating coplanar interface means interior geometry is emitted as boundary geometry.

That is invalid for a closed solid boundary representation:

  • internal partitions must not appear in the outer shell
  • each boundary edge should be shared by exactly 2 boundary faces

Impact

  • non-manifold output from a union operation
  • incorrect surface_area
  • can break downstream CAD/mesh workflows that require valid closed solids

Larger repro (partial overlap)

[union [cube 50 30 5] [translate 0 0 5 [cube 5 30 40]]]

Expected clean L-shape boundary.

Actual result includes two spurious internal faces at z = 5 in the column footprint (x = 0..5, y = 0..30), producing non-manifold junction edges.

Reference test

Related test on GitHub:

Inline test code (self-contained in this report):

#[test]
fn test_union_coplanar_no_internal_faces() {
    let bottom = make_cube(10.0, 10.0, 25.0);
    let mut top = make_cube(10.0, 10.0, 35.0);
    translate_brep(&mut top, 0.0, 0.0, 25.0);

    let result = boolean_op(&bottom, &top, BooleanOp::Union, 32);

    // Volume should be correct: 10 * 10 * 60 = 6000
    let mesh = result.to_mesh(32);
    let volume = compute_mesh_volume(&mesh);
    assert!(
        (volume - 6000.0).abs() < 10.0,
        "Union volume should be 6000, got {:.1}",
        volume
    );

    // The outer shell must have exactly 6 faces (no internal partition)
    let brep = result
        .as_brep()
        .expect("coplanar box union should produce BRep, not mesh fallback");
    let solid = &brep.topology.solids[brep.solid_id];
    let shell = &brep.topology.shells[solid.outer_shell];
    assert_eq!(
        shell.faces.len(),
        6,
        "Expected 6 outer faces for a 10x10x60 cuboid, got {} \
         (internal coplanar faces not removed)",
        shell.faces.len()
    );
}

Notes

  • Coplanar side-face splitting by itself is cosmetic.
  • The critical defect is retention of the internal coplanar interface faces.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions