Import a triangle mesh as an editable B-rep body (mesh2step port)

Opening an STL/OBJ in the Design pane now rebuilds it into a real OCCT B-rep
solid that the face/edge feature tools can operate on, instead of a print mesh.

GeometryEngine::mesh_to_brep is a native C++ port of mesh2step
(github.com/tommasobbianchi/mesh2step): vertices and edges are shared across
triangles at construction time (vertex cache by deduped index, edge cache by
unordered index pair), so no BRepBuilderAPI_Sewing pass is needed to rebuild the
topology afterwards, and watertightness falls out of the edge-usage counts for
free. An open mesh is returned as a shell and reported as such — never dressed up
as a fake solid.

It runs in-process on the OCCT kernel libslic3r already links, so no STEP file is
written or re-read. That is not an optimisation but the whole point: a faceted
STEP of a 62k-triangle mesh is ~149 MB and OCCT's STEPControl_Reader takes >300 s
to parse it back, so routing this through a file would hang the GUI.

Coplanar neighbours are merged (ShapeUpgrade_UnifySameDomain, 5° default) so the
body arrives with pickable CAD faces rather than one face per triangle — on the
20,656-triangle test part that is 20,614 faces down to 4,784. Without it the
import is technically a solid but nothing you can meaningfully fillet or extrude.

- Design pane: "Import mesh" button + Shift+M; warns above 50k triangles.
- MCP: import_mesh {path, tolerance, merge_angle_deg}, returning the full
  conversion stats so a caller can tell an honest solid from an open shell.
- Catch2: cube round-trip (exact volume, 12 faceted faces, 6 after merge), open
  mesh stays a shell, and the scale-independent sliver rule that a naive
  area < tolerance^2 test would get wrong.

Verified end-to-end on the real 20,656-triangle ir3v2 hotend STL: reproduces
mesh2step's Python run exactly (20,614 kept, 42 degenerate, 0 boundary edges,
2 non-manifold edges, not watertight) and the resulting body's bbox matches the
one FreeCAD reports for the same part.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BVzKmX6Y1aEteit1HTXG4Q
This commit is contained in:
Tommaso Bianchi
2026-07-11 07:02:33 +02:00
co-authored by Claude Opus 4.8
parent c618965a4c
commit 343a0439f1
6 changed files with 472 additions and 0 deletions
+83
View File
@@ -1625,3 +1625,86 @@ TEST_CASE("surface_deviation: identical solids ~0, shifted solid ~shift", "[Devi
REQUIRE_THAT(d1.max_mm, Catch::Matchers::WithinAbs(2.0, 0.05));
REQUIRE(d1.mean_mm > 0.0);
}
TEST_CASE("mesh_to_brep: watertight cube -> solid, coplanar merge gives 6 faces", "[design][mesh2brep]")
{
const indexed_triangle_set cube = its_make_cube(10.0, 20.0, 30.0);
REQUIRE(cube.indices.size() == 12);
SECTION("faceted: one B-rep face per triangle, exact volume") {
GeometryEngine::MeshBrepStats st;
const TopoDS_Shape shape = GeometryEngine::mesh_to_brep(cube, 0.01, /*no merge*/0.0, st);
REQUIRE_FALSE(shape.IsNull());
CHECK(st.kept_tris == 12);
CHECK(st.faces_built == 12);
CHECK(st.faces_final == 12);
// Shared topology by construction: a cube has 8 vertices and 18 edges once the two
// triangles of each face share their diagonal. Every edge used exactly twice.
CHECK(st.unique_edges == 18);
CHECK(st.boundary_edges == 0);
CHECK(st.nonmanifold_edges == 0);
CHECK(st.watertight);
REQUIRE(st.is_solid);
REQUIRE_THAT(st.volume, Catch::Matchers::WithinRel(10.0 * 20.0 * 30.0, 1e-9));
}
SECTION("merge coplanar: 12 triangles collapse to the cube's 6 real faces") {
GeometryEngine::MeshBrepStats st;
const TopoDS_Shape shape = GeometryEngine::mesh_to_brep(cube, 0.01, 5.0, st);
REQUIRE_FALSE(shape.IsNull());
REQUIRE(st.is_solid);
// This is the whole point of merging: the imported body must expose pickable CAD faces,
// not one face per triangle, or the fillet/extrude tools have nothing meaningful to grab.
REQUIRE(st.faces_final == 6);
REQUIRE_THAT(st.volume, Catch::Matchers::WithinRel(10.0 * 20.0 * 30.0, 1e-9));
}
}
TEST_CASE("mesh_to_brep: an open mesh is reported as a shell, never a fake solid", "[design][mesh2brep]")
{
indexed_triangle_set open_cube = its_make_cube(10.0, 10.0, 10.0);
open_cube.indices.pop_back(); // punch a hole: drop one triangle
open_cube.indices.pop_back(); // (and its coplanar partner -> a whole face missing)
GeometryEngine::MeshBrepStats st;
const TopoDS_Shape shape = GeometryEngine::mesh_to_brep(open_cube, 0.01, 5.0, st);
REQUIRE_FALSE(shape.IsNull());
CHECK(st.kept_tris == 10);
CHECK(st.boundary_edges > 0); // the hole's rim
CHECK_FALSE(st.watertight);
REQUIRE_FALSE(st.is_solid); // must NOT be dressed up as a solid
CHECK(st.volume == 0.0);
}
TEST_CASE("mesh_to_brep: degenerate triangles are rejected on a scale-independent test", "[design][mesh2brep]")
{
// A thin but perfectly legitimate CAD sliver. Every edge (1.0, ~0.5, ~0.5) is far above the
// 0.01 dedup tolerance, so no vertex collapses — but its area (5e-5) is BELOW tolerance^2
// (1e-4). A rule of "reject when area < tolerance^2" would therefore throw it away, which is
// precisely the bug that turned a watertight 62k-triangle input into a falsely-open shell.
// The scale-independent test (area < 1e-9 * longest_edge^2 = 1e-9) keeps it, as it must.
indexed_triangle_set sliver;
sliver.vertices = { {0.f, 0.f, 0.f}, {1.f, 0.f, 0.f}, {0.5f, 0.0001f, 0.f} };
sliver.indices = { {0, 1, 2} };
GeometryEngine::MeshBrepStats st;
GeometryEngine::mesh_to_brep(sliver, 0.01, 0.0, st);
CHECK(st.degenerate_sliver == 0);
CHECK(st.degenerate_collapsed == 0);
CHECK(st.kept_tris == 1);
// A truly collinear triangle has no area at any scale -> rejected as a sliver.
indexed_triangle_set collinear;
collinear.vertices = { {0.f, 0.f, 0.f}, {10.f, 0.f, 0.f}, {20.f, 0.f, 0.f} };
collinear.indices = { {0, 1, 2} };
GeometryEngine::MeshBrepStats st2;
CHECK_THROWS(GeometryEngine::mesh_to_brep(collinear, 0.01, 0.0, st2)); // nothing left to build
CHECK(st2.degenerate_sliver == 1);
// A triangle entirely inside one tolerance cell is sub-resolution noise -> collapsed.
indexed_triangle_set tiny;
tiny.vertices = { {0.f, 0.f, 0.f}, {0.001f, 0.f, 0.f}, {0.f, 0.001f, 0.f} };
tiny.indices = { {0, 1, 2} };
GeometryEngine::MeshBrepStats st3;
CHECK_THROWS(GeometryEngine::mesh_to_brep(tiny, 0.1, 0.0, st3));
CHECK(st3.degenerate_collapsed == 1);
}