diff --git a/src/libslic3r/GeometryEngine.cpp b/src/libslic3r/GeometryEngine.cpp index ce9d083051..dede7cec50 100644 --- a/src/libslic3r/GeometryEngine.cpp +++ b/src/libslic3r/GeometryEngine.cpp @@ -30,6 +30,16 @@ #include #include #include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include #include namespace Slic3r { @@ -60,6 +70,158 @@ std::vector GeometryEngine::read_step_solids(const std::string& pa return out; } +// ---- Mesh -> B-rep (faceted, shared topology by construction) ---- +// +// Port of mesh2step's brep_build.py. Two properties are load-bearing and easy to lose: +// +// 1. The edge cache is keyed on the UNORDERED vertex-index pair, and a triangle that walks +// the edge backwards (i > j) gets edge.Reversed(). Consistently-wound meshes (STL/OBJ/3MF +// all are) walk every shared edge in opposite directions from its two adjacent triangles, +// so this reversal is exactly what leaves the faces coherently outward-oriented. +// 2. Degeneracy is split in two, deliberately. A triangle is dropped as sub-resolution noise +// only if its longest edge is below `tolerance` (an absolute floor), while sliver rejection +// is scale-INDEPENDENT (area < 1e-9 * longest_edge^2). Folding the two together under one +// `area < tolerance^2` test rejects legitimate thin CAD slivers whenever tolerance is coarse +// relative to them, turning a watertight input into a falsely-open shell — a real regression +// mesh2step hit on a 62k-triangle mechanical part. +TopoDS_Shape GeometryEngine::mesh_to_brep(const indexed_triangle_set& its, + double tolerance, + double merge_angle_deg, + MeshBrepStats& stats) +{ + stats = MeshBrepStats{}; + stats.input_tris = int(its.indices.size()); + if (tolerance <= 0.0) + throw std::runtime_error("mesh_to_brep: tolerance must be > 0"); + if (its.indices.empty()) + throw std::runtime_error("mesh_to_brep: mesh has no triangles"); + + // 1. Tolerance-quantized vertex dedup. A merged vertex keeps the exact coordinates of the + // first input occurrence — vertices are grouped by a cell, never snapped onto its grid. + std::map, int> cell_to_new; + std::vector old_to_new(its.vertices.size(), -1); + std::vector verts; + verts.reserve(its.vertices.size()); + for (size_t i = 0; i < its.vertices.size(); ++i) { + const Vec3d p = its.vertices[i].cast(); + const std::array cell{ (long long) std::llround(p.x() / tolerance), + (long long) std::llround(p.y() / tolerance), + (long long) std::llround(p.z() / tolerance) }; + auto ins = cell_to_new.emplace(cell, int(verts.size())); + if (ins.second) + verts.push_back(p); + old_to_new[i] = ins.first->second; + } + + // 2. Reject degenerate triangles (see the two-part rule in the comment above). + std::vector tris; + tris.reserve(its.indices.size()); + for (const Vec3i32& t : its.indices) { + const int a = old_to_new[t(0)], b = old_to_new[t(1)], c = old_to_new[t(2)]; + if (a == b || b == c || a == c) { ++stats.degenerate_collapsed; continue; } + const Vec3d& pa = verts[a]; const Vec3d& pb = verts[b]; const Vec3d& pc = verts[c]; + const double e0 = (pb - pa).norm(), e1 = (pc - pb).norm(), e2 = (pa - pc).norm(); + const double longest = std::max(e0, std::max(e1, e2)); + if (longest < tolerance) { ++stats.degenerate_collapsed; continue; } + const double area = 0.5 * (pb - pa).cross(pc - pa).norm(); + if (area < 1e-9 * longest * longest) { ++stats.degenerate_sliver; continue; } + tris.emplace_back(a, b, c); + } + stats.kept_tris = int(tris.size()); + if (tris.empty()) + throw std::runtime_error("mesh_to_brep: every triangle was rejected as degenerate " + "(try a smaller tolerance)"); + + // 3. One face per triangle, sharing vertices and edges through the caches. + std::vector vertex_cache(verts.size()); + std::vector vertex_made(verts.size(), false); + auto get_vertex = [&](int i) -> const TopoDS_Vertex& { + if (!vertex_made[i]) { + const Vec3d& p = verts[i]; + vertex_cache[i] = BRepBuilderAPI_MakeVertex(gp_Pnt(p.x(), p.y(), p.z())).Vertex(); + vertex_made[i] = true; + } + return vertex_cache[i]; + }; + + std::map, TopoDS_Edge> edge_cache; + std::map, int> edge_usage; + auto get_edge = [&](int i, int j) -> TopoDS_Edge { + const std::pair key = (i < j) ? std::make_pair(i, j) : std::make_pair(j, i); + ++edge_usage[key]; + auto it = edge_cache.find(key); + if (it == edge_cache.end()) + it = edge_cache.emplace(key, + BRepBuilderAPI_MakeEdge(get_vertex(key.first), get_vertex(key.second)).Edge()).first; + return (i > j) ? TopoDS::Edge(it->second.Reversed()) : it->second; + }; + + BRep_Builder builder; + TopoDS_Shell shell; + builder.MakeShell(shell); + + for (const Vec3i32& t : tris) { + try { + BRepBuilderAPI_MakeWire mk_wire(get_edge(t(0), t(1)), get_edge(t(1), t(2)), get_edge(t(2), t(0))); + if (!mk_wire.IsDone()) { ++stats.faces_failed; continue; } + BRepBuilderAPI_MakeFace mk_face(mk_wire.Wire()); + if (!mk_face.IsDone()) { ++stats.faces_failed; continue; } + builder.Add(shell, mk_face.Face()); + ++stats.faces_built; + } catch (const Standard_Failure&) { + ++stats.faces_failed; + } + } + + // 4. Watertightness falls straight out of the usage counts the cache already gathered. + for (const auto& kv : edge_usage) { + if (kv.second == 1) ++stats.boundary_edges; + else if (kv.second >= 3) ++stats.nonmanifold_edges; + } + stats.unique_edges = int(edge_usage.size()); + stats.watertight = stats.boundary_edges == 0 && stats.nonmanifold_edges == 0 && stats.unique_edges > 0; + + TopoDS_Shape shape = shell; + if (stats.watertight && stats.faces_built > 0) { + BRepBuilderAPI_MakeSolid mk_solid(shell); + if (mk_solid.IsDone()) { + TopoDS_Solid solid = mk_solid.Solid(); + GProp_GProps props; + BRepGProp::VolumeProperties(solid, props); + double vol = props.Mass(); + if (vol < 0.0) { // inward-wound input + solid = TopoDS::Solid(solid.Reversed()); + vol = -vol; + } + if (vol > 0.0) { + shape = solid; + stats.is_solid = true; + stats.volume = vol; + } + } + } + + // 5. Optional coplanar merge. Faceted output is one planar face per triangle — exact, but + // you cannot meaningfully fillet or extrude a face that IS a single triangle. Merging + // coplanar neighbours is what turns the import into something the face/edge tools can + // actually operate on (a 12-triangle cube collapses to its 6 real faces). + if (merge_angle_deg > 0.0) { + try { + ShapeUpgrade_UnifySameDomain unifier(shape, true, true, true); + unifier.SetAngularTolerance(merge_angle_deg * M_PI / 180.0); + unifier.SetLinearTolerance(tolerance); + unifier.Build(); + const TopoDS_Shape merged = unifier.Shape(); + if (!merged.IsNull()) + shape = merged; + } catch (const Standard_Failure&) { + // Merging is an optimisation, not a correctness step: keep the exact faceted shape. + } + } + stats.faces_final = face_count(shape); + return shape; +} + // ---- Primitive creation ---- TopoDS_Solid GeometryEngine::make_primitive(const PrimitiveParams& params) diff --git a/src/libslic3r/GeometryEngine.hpp b/src/libslic3r/GeometryEngine.hpp index 305658c4b5..f93feb7ec3 100644 --- a/src/libslic3r/GeometryEngine.hpp +++ b/src/libslic3r/GeometryEngine.hpp @@ -59,6 +59,41 @@ public: // already linked via Format/STEP.cpp — no new dependency. err is set on failure (empty result). static std::vector read_step_solids(const std::string& path, std::string& err); + // Triangle mesh -> B-rep solid. Native 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 there is no BRepBuilderAPI_Sewing pass to reconstruct topology afterwards — which is + // both faster and what makes watertightness fall out of the edge-usage counts for free. + // Runs in-process on the OCCT kernel libslic3r already links: no STEP file is written or + // re-read (a faceted STEP of a 62k-triangle mesh is ~149 MB and takes OCCT's reader >300 s + // to parse back, so routing the Design tab through a file would hang the GUI). + struct MeshBrepStats { + int input_tris{0}; + int kept_tris{0}; + int degenerate_collapsed{0}; // <3 distinct vertices after tolerance quantization + int degenerate_sliver{0}; // 3 distinct vertices but near-collinear + int faces_built{0}; + int faces_failed{0}; + int unique_edges{0}; + int boundary_edges{0}; // used by exactly 1 triangle -> open shell + int nonmanifold_edges{0}; // used by >=3 triangles + bool watertight{false}; // every edge used exactly twice + bool is_solid{false}; // watertight AND MakeSolid gave a positive volume + double volume{0.0}; + int faces_final{0}; // after the optional coplanar merge + }; + // tolerance: spatial quantization cell used ONLY for vertex dedup and as the + // sub-resolution floor below which a triangle is noise. Never a sew tolerance. + // merge_angle_deg > 0: run ShapeUpgrade_UnifySameDomain to merge coplanar neighbours into + // single faces (a 12-triangle cube -> 6 pickable faces). This is what makes the imported + // body editable with the face/edge tools; <= 0 keeps the exact one-face-per-triangle form. + // Never wraps a non-watertight shell as a fake solid: an open mesh comes back as a shell, + // with the reason (boundary / non-manifold edge counts) reported in stats. + static TopoDS_Shape mesh_to_brep(const indexed_triangle_set& its, + double tolerance, + double merge_angle_deg, + MeshBrepStats& stats); + struct Deviation { double max_mm{0}; double mean_mm{0}; double rms_mm{0}; int sample_count{0}; }; static Deviation surface_deviation(const TopoDS_Shape& candidate, const TopoDS_Shape& reference, diff --git a/src/slic3r/GUI/DesignPanel.cpp b/src/slic3r/GUI/DesignPanel.cpp index 7b6e17561d..b874aa80b6 100644 --- a/src/slic3r/GUI/DesignPanel.cpp +++ b/src/slic3r/GUI/DesignPanel.cpp @@ -2,6 +2,12 @@ #include "DesignCanvas.hpp" #include "DesignSketchTool.hpp" #include "libslic3r/GeometryEngine.hpp" // face_by_index for face-extrude gizmo anchor +#include "libslic3r/TriangleMesh.hpp" // mesh import: STL/OBJ -> indexed_triangle_set +#include "libslic3r/Format/OBJ.hpp" + +#include +#include +#include #include #include @@ -52,6 +58,17 @@ namespace Slic3r { namespace GUI { +// Mesh -> B-rep import defaults (see GeometryEngine::mesh_to_brep). +// Tolerance is a vertex-dedup cell, not a sew tolerance: 10 um is well under any printable +// feature yet coarse enough to weld the float noise a mesh exporter leaves on shared vertices. +static constexpr double MESH_IMPORT_TOLERANCE = 0.01; // mm +// Merge coplanar neighbours so the body has real, pickable faces instead of one face per +// triangle. 5 deg tolerates the small normal jitter of an exported/scanned flat face while +// still keeping genuinely curved regions faceted. +static constexpr double MESH_IMPORT_MERGE_ANGLE_DEG = 5.0; +// Above this, warn before converting: the build is one OCCT face per triangle. +static constexpr size_t MESH_IMPORT_TRIANGLE_WARN = 50000; + // Format a value with the international ('.') decimal separator regardless of the // app's LC_NUMERIC locale (wx sets it to the user locale at startup). snprintf may // emit a comma, so normalise it. @@ -628,6 +645,12 @@ DesignPanel::DesignPanel(wxWindow* parent) b_step->Bind(wxEVT_BUTTON, [this](wxCommandEvent&) { on_import_step(); }); m_keys_feature[SHIFT('I')] = [this] { on_import_step(); }; fadd(b_step); + // Import mesh — same destination as STEP (an editable B-rep body), but the geometry has + // to be reconstructed from triangles first (GeometryEngine::mesh_to_brep). + auto* b_mesh = icon_btn("design_step", _L("Import mesh (STL/OBJ) as an editable B-rep solid")); + b_mesh->Bind(wxEVT_BUTTON, [this](wxCommandEvent&) { on_import_mesh(); }); + m_keys_feature[SHIFT('M')] = [this] { on_import_mesh(); }; + fadd(b_mesh); add_sep(m_tb_feature); auto* b_constrain = icon_btn("design_constrain", _L("Constrain selected sketch")); b_constrain->Bind(wxEVT_BUTTON, [this](wxCommandEvent&) { @@ -2392,6 +2415,111 @@ void DesignPanel::on_import_step() m_status->Refresh(); } +// Import a triangle mesh as a real B-rep body: the triangles are rebuilt into OCCT faces with +// shared topology, then coplanar neighbours are merged so the result has pickable CAD faces +// rather than one face per triangle. Lands in the same CadFeatureType::Import as a STEP, so +// every downstream feature tool (fillet / cut / shell / face-extrude) works on it unchanged. +void DesignPanel::on_import_mesh() +{ + wxFileDialog dlg(this, _L("Import mesh"), wxEmptyString, wxEmptyString, + "Mesh files (*.stl;*.obj)|*.stl;*.obj|All files|*.*", + wxFD_OPEN | wxFD_FILE_MUST_EXIST); + if (dlg.ShowModal() != wxID_OK) + return; + const std::string path(dlg.GetPath().ToUTF8().data()); + + auto fail = [this](const wxString& msg) { + m_status->SetForegroundColour(wxColour(235, 110, 110)); + m_status->SetLabel(msg); + m_status->Refresh(); + }; + + // Load the triangles with the slicer's own readers — no new mesh dependency. + TriangleMesh mesh; + const std::string ext = boost::algorithm::to_lower_copy( + boost::filesystem::path(path).extension().string()); + if (ext == ".stl") { + if (!mesh.ReadSTLFile(path.c_str())) { fail(_L("Could not read the STL file")); return; } + } else if (ext == ".obj") { + ObjInfo obj_info; + std::string obj_err; + if (!load_obj(path.c_str(), &mesh, obj_info, obj_err)) { + fail(_L("Could not read the OBJ file: ") + wxString::FromUTF8(obj_err)); + return; + } + } else { + fail(_L("Unsupported mesh format (STL and OBJ are supported)")); + return; + } + if (mesh.its.indices.empty()) { fail(_L("The mesh contains no triangles")); return; } + + // One planar face per triangle before merging, so the cost is driven by the triangle count. + // A dense organic scan has few coplanar neighbours to merge away and stays heavy afterwards; + // warn rather than silently freezing the CAD kernel on every subsequent recompute. + if (mesh.its.indices.size() > MESH_IMPORT_TRIANGLE_WARN) { + const wxString q = wxString::Format( + _L("This mesh has %d triangles. Every triangle becomes a B-rep face before coplanar " + "merging, so importing it may take a long time and leave a body that is slow to " + "edit. Decimating the mesh first is usually better.\n\nImport anyway?"), + int(mesh.its.indices.size())); + if (wxMessageBox(q, _L("Large mesh"), wxYES_NO | wxICON_WARNING, this) != wxYES) + return; + } + + wxBusyCursor busy; + GeometryEngine::MeshBrepStats stats; + TopoDS_Shape shape; + try { + shape = GeometryEngine::mesh_to_brep(mesh.its, MESH_IMPORT_TOLERANCE, + MESH_IMPORT_MERGE_ANGLE_DEG, stats); + } catch (const std::exception& e) { + fail(_L("Mesh conversion failed: ") + wxString::FromUTF8(e.what())); + return; + } catch (const Standard_Failure& e) { // OCCT throws outside std::exception + fail(_L("Mesh conversion failed: ") + wxString::FromUTF8( + e.GetMessageString() ? e.GetMessageString() : "OCCT error")); + return; + } + if (shape.IsNull()) { fail(_L("Mesh conversion produced no geometry")); return; } + + m_doc.checkpoint(); // undo boundary: importing a mesh as a B-rep body + m_feature_counter++; + CadFeature f; + f.type = CadFeatureType::Import; + f.name = std::string("Mesh") + std::to_string(m_feature_counter); + f.imported_solid = shape; + f.mode = BooleanMode::New; // its own coexisting body, like a STEP solid + m_doc.features.push_back(f); + + if (!m_doc.recompute()) { + fail(_L("Mesh import failed: ") + wxString::FromUTF8(m_doc.error)); + return; + } + set_ui_mode(UiMode::Feature); + refresh_tree(); + set_tree_selection(int(m_doc.features.size()) - 1); + set_status_ok(); + + // Report what the mesh actually was, never dress an open shell up as a solid: if it is not + // watertight, say so and say why (boundary vs non-manifold edges) — that is a defect in the + // source mesh the user needs to know about before they start cutting features into it. + if (stats.is_solid) { + m_status->SetForegroundColour(wxNullColour); + m_status->SetLabel(wxString::Format( + _L("Imported solid — %d triangles → %d faces, volume %.2f mm³. Pick a face or edge, " + "then Fillet / Cut / Shell to modify"), + stats.kept_tris, stats.faces_final, stats.volume)); + } else { + m_status->SetForegroundColour(wxColour(220, 160, 60)); // warning, not an error + m_status->SetLabel(wxString::Format( + _L("Imported as an open shell (not watertight): %d boundary edge(s), %d non-manifold " + "edge(s) — %d triangles → %d faces. The source mesh has holes or duplicated " + "geometry; boolean features may fail on it"), + stats.boundary_edges, stats.nonmanifold_edges, stats.kept_tris, stats.faces_final)); + } + m_status->Refresh(); +} + void DesignPanel::add_imported_sketch( const std::vector>>& regions, const wxString& base_name) diff --git a/src/slic3r/GUI/DesignPanel.hpp b/src/slic3r/GUI/DesignPanel.hpp index 63b74cae1d..967a37357d 100644 --- a/src/slic3r/GUI/DesignPanel.hpp +++ b/src/slic3r/GUI/DesignPanel.hpp @@ -90,6 +90,7 @@ private: void on_add_text(); void on_import_svg(); void on_import_step(); // STEP -> editable B-rep body (keeps the OCCT solid, not a mesh) + void on_import_mesh(); // STL/OBJ -> B-rep body via GeometryEngine::mesh_to_brep bool place_on_face(); // Prepare's Place on Face (F): lay the selected body face on the bed void add_imported_sketch(const std::vector>>& regions, const wxString& base_name); diff --git a/src/slic3r/GUI/McpControl.cpp b/src/slic3r/GUI/McpControl.cpp index 4081133911..f93dc7541d 100644 --- a/src/slic3r/GUI/McpControl.cpp +++ b/src/slic3r/GUI/McpControl.cpp @@ -29,6 +29,9 @@ #include "libslic3r/SketchEngine.hpp" #include "libslic3r/GeometryEngine.hpp" #include "libslic3r/TriangleMesh.hpp" +#include "libslic3r/Format/OBJ.hpp" +#include +#include #include "libslic3r/BoundingBox.hpp" #include @@ -194,6 +197,12 @@ json describe_tools() {"params", json::array({ json{{"name", "path"}, {"type", "string"}}, })}}, + json{{"name", "import_mesh"}, {"summary", "Convert a triangle mesh (STL/OBJ) into an editable B-rep body. Reports whether the result is a real solid or an open shell, and why."}, + {"params", json::array({ + json{{"name", "path"}, {"type", "string"}}, + json{{"name", "tolerance"}, {"type", "number"}, {"default", 0.01}}, + json{{"name", "merge_angle_deg"}, {"type", "number"}, {"default", 5.0}}, + })}}, json{{"name", "validate_against"}, {"summary", "Volume + bbox/centroid + surface deviation (max/mean/rms mm) of a body vs a reference {step:path|body:id} (the RE acceptance metric)."}, {"params", json::array({ json{{"name", "body"}, {"type", "integer"}, {"default", 0}}, @@ -449,6 +458,59 @@ json import_step(DesignPanel* panel, const json& params) {"bodies", int(doc.bodies.size())}, {"error", doc.error}}; } +// --- Import a triangle mesh as a B-rep body (GeometryEngine::mesh_to_brep) --- +// Same destination as import_step: a CadFeatureType::Import body every feature tool can edit. +// The full conversion stats come back so a caller can tell an honest solid from an open shell +// instead of discovering it later when a boolean silently fails. +json import_mesh(DesignPanel* panel, const json& params) +{ + if (!params.contains("path")) throw std::runtime_error("import_mesh needs 'path'"); + const std::string path = params["path"].get(); + const double tolerance = params.value("tolerance", 0.01); + const double merge_angle_deg = params.value("merge_angle_deg", 5.0); + + TriangleMesh mesh; + const std::string ext = boost::algorithm::to_lower_copy( + boost::filesystem::path(path).extension().string()); + if (ext == ".stl") { + if (!mesh.ReadSTLFile(path.c_str())) throw std::runtime_error("could not read STL: " + path); + } else if (ext == ".obj") { + ObjInfo obj_info; std::string obj_err; + if (!load_obj(path.c_str(), &mesh, obj_info, obj_err)) + throw std::runtime_error("could not read OBJ: " + obj_err); + } else { + throw std::runtime_error("unsupported mesh format (want .stl or .obj): " + ext); + } + + GeometryEngine::MeshBrepStats st; + const TopoDS_Shape shape = GeometryEngine::mesh_to_brep(mesh.its, tolerance, merge_angle_deg, st); + if (shape.IsNull()) throw std::runtime_error("mesh conversion produced no geometry"); + + CadDocument& doc = panel->mcp_doc(); + doc.checkpoint(); + const int first = int(doc.features.size()); + CadFeature f; + f.type = CadFeatureType::Import; + f.name = "Mesh" + std::to_string(first + 1); + f.imported_solid = shape; + f.mode = BooleanMode::New; + doc.features.push_back(f); + + const bool ok = doc.recompute(); + if (!ok) doc.undo(); + panel->mcp_after_change(); + return json{{"ok", ok}, {"first_feature", first}, {"bodies", int(doc.bodies.size())}, + {"input_triangles", st.input_tris}, {"kept_triangles", st.kept_tris}, + {"degenerate_collapsed", st.degenerate_collapsed}, + {"degenerate_sliver", st.degenerate_sliver}, + {"faces_built", st.faces_built}, {"faces_failed", st.faces_failed}, + {"faces_final", st.faces_final}, {"unique_edges", st.unique_edges}, + {"boundary_edges", st.boundary_edges}, + {"nonmanifold_edges", st.nonmanifold_edges}, + {"watertight", st.watertight}, {"is_solid", st.is_solid}, + {"volume", st.volume}, {"error", doc.error}}; +} + // --- Validate: volume + bbox deviation of a body vs a reference (the "scarto %") -- // ponytail: volume delta + bbox/centroid offset (the RE skill's actual acceptance metric). // Surface-deviation heat-map is the upgrade path (per-vertex BRepExtrema), add when needed. @@ -719,6 +781,7 @@ std::string handle_on_main(const std::string& method, const json& params, const if (method == "measure") return rpc_result(id, measure(panel, params)); if (method == "slice_body") return rpc_result(id, slice_body(panel, params)); if (method == "import_step") return rpc_result(id, import_step(panel, params)); + if (method == "import_mesh") return rpc_result(id, import_mesh(panel, params)); if (method == "validate_against") return rpc_result(id, validate_against(panel, params)); if (method == "extrude") return rpc_result(id, action_extrude(panel, params)); if (method == "revolve") return rpc_result(id, action_revolve(panel, params)); diff --git a/tests/libslic3r/test_caddocument.cpp b/tests/libslic3r/test_caddocument.cpp index 832c3c9be8..a6e8942f20 100644 --- a/tests/libslic3r/test_caddocument.cpp +++ b/tests/libslic3r/test_caddocument.cpp @@ -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); +}