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
+162
View File
@@ -30,6 +30,16 @@
#include <Standard_Failure.hxx>
#include <BRepExtrema_DistShapeShape.hxx>
#include <BRepBuilderAPI_MakeVertex.hxx>
#include <BRepBuilderAPI_MakeEdge.hxx>
#include <BRepBuilderAPI_MakeWire.hxx>
#include <BRepBuilderAPI_MakeFace.hxx>
#include <BRepBuilderAPI_MakeSolid.hxx>
#include <BRep_Builder.hxx>
#include <TopoDS_Shell.hxx>
#include <TopoDS_Vertex.hxx>
#include <ShapeUpgrade_UnifySameDomain.hxx>
#include <array>
#include <map>
#include <cmath>
namespace Slic3r {
@@ -60,6 +70,158 @@ std::vector<TopoDS_Shape> 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<std::array<long long, 3>, int> cell_to_new;
std::vector<int> old_to_new(its.vertices.size(), -1);
std::vector<Vec3d> verts;
verts.reserve(its.vertices.size());
for (size_t i = 0; i < its.vertices.size(); ++i) {
const Vec3d p = its.vertices[i].cast<double>();
const std::array<long long, 3> 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<Vec3i32> 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<TopoDS_Vertex> vertex_cache(verts.size());
std::vector<bool> 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<std::pair<int, int>, TopoDS_Edge> edge_cache;
std::map<std::pair<int, int>, int> edge_usage;
auto get_edge = [&](int i, int j) -> TopoDS_Edge {
const std::pair<int, int> 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)
+35
View File
@@ -59,6 +59,41 @@ public:
// already linked via Format/STEP.cpp — no new dependency. err is set on failure (empty result).
static std::vector<TopoDS_Shape> 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,
+128
View File
@@ -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 <boost/algorithm/string/case_conv.hpp>
#include <boost/filesystem/path.hpp>
#include <Standard_Failure.hxx>
#include <wx/sizer.h>
#include <wx/button.h>
@@ -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<std::vector<std::vector<Vec2d>>>& regions,
const wxString& base_name)
+1
View File
@@ -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<std::vector<std::vector<Vec2d>>>& regions,
const wxString& base_name);
+63
View File
@@ -29,6 +29,9 @@
#include "libslic3r/SketchEngine.hpp"
#include "libslic3r/GeometryEngine.hpp"
#include "libslic3r/TriangleMesh.hpp"
#include "libslic3r/Format/OBJ.hpp"
#include <boost/algorithm/string/case_conv.hpp>
#include <boost/filesystem/path.hpp>
#include "libslic3r/BoundingBox.hpp"
#include <gp_Pln.hxx>
@@ -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<std::string>();
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));