From b807be3c4ae999fe4f81ac91bd3a2f524fe41932 Mon Sep 17 00:00:00 2001 From: Tommaso Bianchi Date: Thu, 23 Jul 2026 23:18:42 +0200 Subject: [PATCH] CAD mass properties: volume / area / centre of mass / inertia Port of snaporca c2821af783. New GeometryEngine::mass_properties over BRepGProp (separate VolumeProperties/SurfaceProperties), bounds-checked CadDocument::body_mass_properties, and a mass_properties MCP method. Query-only: no CadFeature, no serialization, no version change. Analytic tests (WithinRel/WithinAbs): cube 8000/2400/COM(0,0,10)/inertia 533333, cylinder 500pi/300pi, hollow = solid-500pi, invalid index -> valid=false. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01BVzKmX6Y1aEteit1HTXG4Q --- src/libslic3r/CadDocument.cpp | 6 ++ src/libslic3r/CadDocument.hpp | 2 + src/libslic3r/GeometryEngine.cpp | 27 +++++++ src/libslic3r/GeometryEngine.hpp | 9 +++ src/slic3r/GUI/McpControl.cpp | 19 +++++ tests/libslic3r/test_caddocument.cpp | 106 +++++++++++++++++++++++++++ 6 files changed, 169 insertions(+) diff --git a/src/libslic3r/CadDocument.cpp b/src/libslic3r/CadDocument.cpp index 226db5d6c2..5e8e560910 100644 --- a/src/libslic3r/CadDocument.cpp +++ b/src/libslic3r/CadDocument.cpp @@ -1852,4 +1852,10 @@ bool CadDocument::export_step(const std::string& path, return true; } +GeometryEngine::MassProps CadDocument::body_mass_properties(int body_index) const +{ + if (body_index < 0 || body_index >= int(bodies.size())) return {}; + return GeometryEngine::mass_properties(bodies[body_index].shape); +} + } // namespace Slic3r diff --git a/src/libslic3r/CadDocument.hpp b/src/libslic3r/CadDocument.hpp index 7d7bc9c968..73596ceb58 100644 --- a/src/libslic3r/CadDocument.hpp +++ b/src/libslic3r/CadDocument.hpp @@ -371,6 +371,8 @@ public: const std::vector& body_xforms, std::string& err) const; + GeometryEngine::MassProps body_mass_properties(int body_index) const; + // Undo/redo of the feature recipe (Onshape-style Ctrl+Z). The caller marks a // user-action boundary by calling checkpoint() BEFORE the mutation(s) for that // action (add/delete/move/replace, or a direct features edit). undo()/redo() then diff --git a/src/libslic3r/GeometryEngine.cpp b/src/libslic3r/GeometryEngine.cpp index 311c24dfdb..4b60c3f1e9 100644 --- a/src/libslic3r/GeometryEngine.cpp +++ b/src/libslic3r/GeometryEngine.cpp @@ -465,6 +465,33 @@ GeometryEngine::surface_deviation(const TopoDS_Shape& candidate, return d; } +GeometryEngine::MassProps GeometryEngine::mass_properties(const TopoDS_Shape& shape) +{ + MassProps p; + if (shape.IsNull()) return p; + try { + GProp_GProps vprops; + BRepGProp::VolumeProperties(shape, vprops); + double mass = vprops.Mass(); + if (std::abs(mass) < 1e-30) return p; + p.volume = std::abs(mass); + p.center_of_mass = Vec3d(vprops.CentreOfMass().X(), vprops.CentreOfMass().Y(), vprops.CentreOfMass().Z()); + gp_Mat mat = vprops.MatrixOfInertia(); + p.inertia = {{ + mat(1,1), mat(1,2), mat(1,3), + mat(2,1), mat(2,2), mat(2,3), + mat(3,1), mat(3,2), mat(3,3), + }}; + GProp_GProps sprops; + BRepGProp::SurfaceProperties(shape, sprops); + p.surface_area = sprops.Mass(); + p.valid = true; + } catch (const Standard_Failure&) { + // leave valid = false + } + return p; +} + std::string GeometryEngine::primitive_name(PrimitiveType type) { switch (type) { diff --git a/src/libslic3r/GeometryEngine.hpp b/src/libslic3r/GeometryEngine.hpp index 53a083f95d..deae837919 100644 --- a/src/libslic3r/GeometryEngine.hpp +++ b/src/libslic3r/GeometryEngine.hpp @@ -94,6 +94,15 @@ public: double merge_angle_deg, MeshBrepStats& stats); + struct MassProps { + double volume{0.0}; + double surface_area{0.0}; + Vec3d center_of_mass{Vec3d::Zero()}; + std::array inertia{}; + bool valid{false}; + }; + static MassProps mass_properties(const TopoDS_Shape& shape); + 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/McpControl.cpp b/src/slic3r/GUI/McpControl.cpp index 4a646db78c..048ed034f8 100644 --- a/src/slic3r/GUI/McpControl.cpp +++ b/src/slic3r/GUI/McpControl.cpp @@ -187,6 +187,10 @@ json describe_tools() json{{"name", "a"}, {"type", "object"}}, json{{"name", "b"}, {"type", "object"}}, })}}, + json{{"name", "mass_properties"}, {"summary", "Volume / surface area / centre of mass / inertia tensor of a body."}, + {"params", json::array({ + json{{"name", "body"}, {"type", "integer"}, {"default", 0}}, + })}}, json{{"name", "slice_body"}, {"summary", "Cross-section of a body by a base plane at an offset (sections-as-evidence); returns ordered world contours, each flagged closed/open."}, {"params", json::array({ json{{"name", "body"}, {"type", "integer"}, {"default", 0}}, @@ -367,6 +371,20 @@ json measure(DesignPanel* panel, const json& params) return r; } +json mass_properties(DesignPanel* panel, const json& params) +{ + const TopoDS_Shape& shape = body_shape(panel, params); + auto mp = GeometryEngine::mass_properties(shape); + if (!mp.valid) throw std::runtime_error("mass properties could not be computed (null/empty shape)"); + return json{ + {"volume", mp.volume}, + {"surface_area", mp.surface_area}, + {"center_of_mass", json::array({mp.center_of_mass.x(), mp.center_of_mass.y(), mp.center_of_mass.z()})}, + {"inertia", mp.inertia}, + {"valid", mp.valid}, + }; +} + // Chain raw section segments (each a sampled-edge polyline) into ordered contours by joining // endpoints within tol. Grows the tail; when the tail is stuck, reverses the contour and grows // the other end. A contour is closed when its two ends meet. OCCT section vertices are exact, @@ -785,6 +803,7 @@ std::string handle_on_main(const std::string& method, const json& params, const if (method == "describe_scene") return rpc_result(id, describe_scene(panel)); if (method == "query_topology") return rpc_result(id, query_topology(panel, params)); if (method == "measure") return rpc_result(id, measure(panel, params)); + if (method == "mass_properties") return rpc_result(id, mass_properties(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)); diff --git a/tests/libslic3r/test_caddocument.cpp b/tests/libslic3r/test_caddocument.cpp index ab0deb404c..4e7f6a2c70 100644 --- a/tests/libslic3r/test_caddocument.cpp +++ b/tests/libslic3r/test_caddocument.cpp @@ -1379,6 +1379,112 @@ TEST_CASE("cut splits a body with a plane", "[cut]") } } +TEST_CASE("mass properties: analytic cube", "[CadDocument]") +{ + using Catch::Matchers::WithinRel; + using Catch::Matchers::WithinAbs; + + CadDocument doc; + int sk = doc.add_sketch(SketchShape::Rectangle, SketchPlane::XY(), 20, 20, 10, "Sketch"); + doc.add_extrude(sk, 20.0, false, BooleanMode::New, "Extrude"); + REQUIRE(doc.recompute()); + REQUIRE(doc.error.empty()); + + auto mp = doc.body_mass_properties(0); + REQUIRE(mp.valid); + // Cube 20x20x20 -> V = 8000 mm^3 + REQUIRE_THAT(mp.volume, WithinRel(8000.0, 1e-6)); + // Surface area = 6 * 20^2 = 2400 mm^2 + REQUIRE_THAT(mp.surface_area, WithinRel(2400.0, 1e-6)); + // COM at geometric centre: the box centred on origin is from z=0 to z=20, + // so centre at (0, 0, 10) + REQUIRE_THAT(mp.center_of_mass.x(), WithinAbs(0.0, 1e-6)); + REQUIRE_THAT(mp.center_of_mass.y(), WithinAbs(0.0, 1e-6)); + REQUIRE_THAT(mp.center_of_mass.z(), WithinAbs(10.0, 1e-6)); + // Inertia sanity: symmetric cube -> diagonal terms equal, off-diagonal ~0 + double Ixx = mp.inertia[0], Iyy = mp.inertia[4], Izz = mp.inertia[8]; + REQUIRE_THAT(Ixx, WithinRel(8000.0 * (20*20 + 20*20) / 12.0, 1e-4)); // I = m/12*(b^2+h^2) about COM + REQUIRE_THAT(Iyy, WithinRel(Ixx, 1e-4)); + REQUIRE_THAT(Izz, WithinRel(Ixx, 1e-4)); + REQUIRE_THAT(mp.inertia[1], WithinAbs(0.0, 1e-6)); + REQUIRE_THAT(mp.inertia[2], WithinAbs(0.0, 1e-6)); + REQUIRE_THAT(mp.inertia[5], WithinAbs(0.0, 1e-6)); +} + +TEST_CASE("mass properties: analytic cylinder", "[CadDocument]") +{ + using Catch::Matchers::WithinRel; + + CadDocument doc; + // Circle r=10 on XY, extrude height=5 -> cylinder + SketchEntity c; + c.type = SketchEntity::Type::Circle; + c.center = Vec2d(0, 0); + c.radius = 10.0; + int sk = doc.add_sketch_entities({c}, SketchPlane::XY(), "Circle"); + doc.add_extrude(sk, 5.0, false, BooleanMode::New, "Extrude"); + REQUIRE(doc.recompute()); + REQUIRE(doc.error.empty()); + + auto mp = doc.body_mass_properties(0); + REQUIRE(mp.valid); + double expected_vol = M_PI * 100.0 * 5.0; // pi * r^2 * h + REQUIRE_THAT(mp.volume, WithinRel(expected_vol, 1e-4)); + // Surface: 2*pi*r^2 + 2*pi*r*h = 2*pi*100 + 2*pi*10*5 = 200*pi + 100*pi = 300*pi + double expected_area = 2 * M_PI * 100.0 + 2 * M_PI * 10.0 * 5.0; + REQUIRE_THAT(mp.surface_area, WithinRel(expected_area, 1e-4)); +} + +TEST_CASE("mass properties: hollow body", "[CadDocument]") +{ + using Catch::Matchers::WithinRel; + + // Solid cube + CadDocument doc; + int sk = doc.add_sketch(SketchShape::Rectangle, SketchPlane::XY(), 20, 20, 10, "Sketch"); + doc.add_extrude(sk, 20.0, false, BooleanMode::New, "Extrude"); + REQUIRE(doc.recompute()); + auto mp_solid = doc.body_mass_properties(0); + REQUIRE(mp_solid.valid); + double solid_vol = mp_solid.volume; + + // Hollow cube with a through hole + CadDocument doc2; + int sk2 = doc2.add_sketch(SketchShape::Rectangle, SketchPlane::XY(), 20, 20, 10, "Sketch"); + doc2.add_extrude(sk2, 20.0, false, BooleanMode::New, "Extrude"); + doc2.add_hole(10.0, 20.0, true, 0.0, 0.0, SketchPlane::XY(), "Hole"); + REQUIRE(doc2.recompute()); + auto mp_hollow = doc2.body_mass_properties(0); + REQUIRE(mp_hollow.valid); + + // Hollow volume < solid volume + REQUIRE(mp_hollow.volume < solid_vol); + // Hollow = solid - cylinder: V_cyl = pi*5^2*20 = 500*pi + double expected_hollow = solid_vol - M_PI * 25.0 * 20.0; + REQUIRE_THAT(mp_hollow.volume, WithinRel(expected_hollow, 0.01)); +} + +TEST_CASE("mass properties: invalid body index", "[CadDocument]") +{ + CadDocument doc; + int sk = doc.add_sketch(SketchShape::Rectangle, SketchPlane::XY(), 20, 20, 10, "Sketch"); + doc.add_extrude(sk, 20.0, false, BooleanMode::New, "Extrude"); + REQUIRE(doc.recompute()); + + // Out of range -> valid=false + auto mp = doc.body_mass_properties(99); + REQUIRE_FALSE(mp.valid); + REQUIRE(mp.volume == 0.0); + // Negative index + auto mp2 = doc.body_mass_properties(-1); + REQUIRE_FALSE(mp2.valid); + + // Empty document (no recompute) -> all bodies empty, index 0 out of range + CadDocument empty; + auto mp3 = empty.body_mass_properties(0); + REQUIRE_FALSE(mp3.valid); +} + TEST_CASE("serialize_recipe roundtrip with two bodies", "[CadDocument]") { using Catch::Matchers::WithinRel;