STEP import: a void reads as a cavity whichever way its shell flag points (#3730) (#3872)

A BREP_WITH_VOIDS void's faces face into the cavity, so it encloses volume of
the opposite sign to the outer shell. Its ORIENTED_CLOSED_SHELL flag alone
doesn't tell: SOLIDWORKS writes .F. over a shell already facing into the
cavity, SolidCraft .T., and flipping by the flag added the cavity's volume. A
void enclosing volume of the outer shell's sign is now turned inside out.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
bherbruck
2026-10-20 23:54:54 +00:00
committed by GitHub
parent fc3dedfd5c
commit 94bd8719aa
3 changed files with 52 additions and 0 deletions
+11
View File
@@ -541,6 +541,17 @@ thread_local! {
pub(crate) static REPHASED: std::cell::Cell<usize> = const { std::cell::Cell::new(0) };
}
/// The volume one closed shell encloses, signed by the way its faces face (positive when they
/// face out of it), from a mesh as coarse as the orientation mesh of a solid of its size.
/// `None` when it cannot be meshed.
pub(crate) fn shell_volume(shell: &mt::Shell) -> Option<f64> {
let bounds: Aabb3 = shell.vertex_iter().map(|v| from_p3(v.point())).collect();
let span = if bounds.is_empty() { 1.0 } else { bounds.diagonal() };
let deflection = Deflection { display: true, ..Deflection::chord(orientation_tolerance(span)) };
let v = guard("shell volume", || Ok(shell.robust_triangulation_within(deflection).to_polygon().volume())).ok()?;
v.is_finite().then_some(v)
}
/// Deflection the orientation mesh uses: coarse enough for a large solid, fine enough that a
/// small one still tessellates. `unmeshed_faces` counts at the same value, so the count the
/// orientation mesh produced is reused instead of meshing the body again.
+17
View File
@@ -641,5 +641,22 @@ pub(crate) fn read_item(cx: &Ctx, id: u64) -> R<BrepOut> {
if checked.is_err() {
closed = false;
}
// A void's faces face into the cavity, out of the material: it encloses volume of the
// opposite sign to the outer shell. The ORIENTED_CLOSED_SHELL flag alone doesn't say so:
// some exporters write .F. over a shell already facing into the cavity (#3730), others .T.
// A void enclosing volume of the outer shell's sign is turned inside out.
if e.name() == "BREP_WITH_VOIDS"
&& checked.is_ok()
&& let Some((outer, voids)) = shells.split_first_mut()
&& let Some(outside) = crate::body::shell_volume(outer)
{
for void in voids {
if crate::body::shell_volume(void).is_some_and(|v| v * outside > 0.0) {
for face in void.face_iter_mut() {
face.invert();
}
}
}
}
Ok(BrepOut { solid: mt::Solid::new_unchecked(shells), failed, closed, file_faces, face_origin })
}
+24
View File
@@ -111,6 +111,30 @@ fn seam_only_sphere_void_is_turned_inside_out() {
assert!(rel(m.area, area) < 1e-3, "area {} vs {area}", m.area);
}
/// A block with a closed cavity, written as BREP_WITH_VOIDS. SolidCraft writes the void's
/// shell facing into the cavity with ORIENTED_CLOSED_SHELL `.T.`; some exporters write the
/// same faces with `.F.` (#3730), which turned the void inside out and added its volume. Either
/// way the cavity is taken out.
#[test]
fn a_void_reads_as_a_cavity_whichever_way_its_shell_flag_points() {
let block = box_solid(Vec3::ZERO, Vec3::new(40.0, 30.0, 12.5)).unwrap();
let cavity = box_solid(Vec3::new(10.0, 12.0, 4.0), Vec3::new(30.0, 18.0, 9.0)).unwrap();
let Ok(Some(hollow)) = boolean(&block, &cavity, BoolOp::Cut) else { panic!("cut") };
let want = 40.0 * 30.0 * 12.5 - 20.0 * 6.0 * 5.0;
assert!(rel(measure(&hollow).unwrap().volume, want) < 1e-9);
let text = step_export(&[&hollow], "void").unwrap();
let flag = |t: &str| t.lines().filter(|l| l.contains("ORIENTED_CLOSED_SHELL")).map(str::to_owned).collect::<Vec<_>>();
let written = flag(&text);
assert!(written.len() == 1 && written[0].ends_with(".T.);"), "{written:?}");
let flipped = text.replace(&written[0], &written[0].replace(".T.);", ".F.);"));
for t in [text, flipped] {
let imp = step_import(&t).unwrap();
assert!(imp.warnings.is_empty(), "{:?}", imp.warnings);
let m = measure(&imp.bodies[0].body).unwrap();
assert!(rel(m.volume, want) < 1e-6, "{:?}: volume {} vs {want}", flag(&t), m.volume);
}
}
#[test]
fn non_antipodal_seam_sphere_does_not_panic() {
let text = seam_sphere_step(Vec3::ZERO, 2.0, Vec3::Z, Vec3::X, 0.0, false, 2.5);