M5b: rib — thin stiffening wall grown from an open sketch line

New CadFeatureType::Rib (appended). A straight open Line entity in a sketch
is offset ±thickness/2 along its in-plane perpendicular into a thin rectangle,
extruded rib_depth along the sketch-plane normal, and fused to the target body.
Line-only for now (ponytail; polyline/arc ribs are a later extension) — a
non-line entity fails cleanly at recompute. Four serialized fields
(rib_sketch_ref/rib_entity/rib_thickness/rib_depth) appended to both symmetric
cereal lists (version stays 2, golden fixture regenerated 29525->30269). MCP:
rib. 3 new [CadDocument][rib] tests; suite 86/1376.

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-25 04:03:52 +02:00
co-authored by Claude Opus 4.8
parent 0bbf22ceae
commit a606dfe00a
5 changed files with 200 additions and 5 deletions
+47
View File
@@ -748,6 +748,21 @@ int CadDocument::add_shell(double thickness, int face, int target_body, const st
return int(features.size()) - 1;
}
int CadDocument::add_rib(int sketch_ref, int entity, double thickness, double depth,
int target_body, const std::string& name)
{
CadFeature f;
f.type = CadFeatureType::Rib;
f.name = name;
f.rib_sketch_ref = sketch_ref;
f.rib_entity = entity;
f.rib_thickness = thickness;
f.rib_depth = depth;
f.target_body = target_body;
features.push_back(f);
return int(features.size()) - 1;
}
int CadDocument::add_delete_face(int target_body, const std::vector<int>& faces,
const std::string& name)
{
@@ -1784,6 +1799,38 @@ void CadDocument::apply_feature(TopoDS_Shape& result, bool& have_body,
}
break;
}
case CadFeatureType::Rib: {
if (!have_body) throw std::runtime_error("rib needs a body");
if (f.rib_sketch_ref < 0 || f.rib_sketch_ref >= (int)features.size())
throw std::runtime_error("rib: bad sketch ref");
const CadFeature& sk = features[f.rib_sketch_ref];
if (sk.type != CadFeatureType::Sketch)
throw std::runtime_error("rib: ref is not a sketch");
if (f.rib_entity < 0 || f.rib_entity >= (int)sk.entities.size())
throw std::runtime_error("rib: bad entity");
const SketchEntity& ln = sk.entities[f.rib_entity];
if (ln.type != SketchEntity::Type::Line)
throw std::runtime_error("rib: entity must be a line"); // ponytail: line-only for now
// Thin rectangle centred on the line, in the sketch plane: offset both endpoints by
// +/-thickness/2 along the in-plane perpendicular of the line direction.
const SketchPlane& pl = sk.plane;
Vec2d a = ln.p0, b = ln.p1;
Vec2d dir = (b - a); double L = dir.norm();
if (L < 1e-9) throw std::runtime_error("rib: degenerate line");
dir /= L;
Vec2d perp(-dir.y(), dir.x());
double h = f.rib_thickness * 0.5;
Vec2d q0 = a + perp*h, q1 = b + perp*h, q2 = b - perp*h, q3 = a - perp*h;
auto w3 = [&](const Vec2d& p){ Vec3d w = pl.to_world(p); return gp_Pnt(w.x(),w.y(),w.z()); };
BRepBuilderAPI_MakePolygon poly(w3(q0), w3(q1), w3(q2), w3(q3), Standard_True);
if (!poly.IsDone()) throw std::runtime_error("rib: profile failed");
TopoDS_Shape wall = SketchEngine::make_extrude(poly.Wire(), pl, f.rib_depth, false, 0.0);
if (wall.IsNull()) throw std::runtime_error("rib: extrude failed");
BRepAlgoAPI_Fuse fuse(result, wall);
if (!fuse.IsDone()) throw std::runtime_error("rib: fuse failed");
result = fuse.Shape();
break;
}
case CadFeatureType::Fillet:
if (!have_body) throw std::runtime_error("fillet needs a body");
if (f.dressup_edge >= 0)
+17 -5
View File
@@ -17,7 +17,7 @@
namespace Slic3r {
enum class CadFeatureType { Sketch, Extrude, Fillet, Chamfer, Hole, Thread, Shell, Revolve, Sweep, Pattern, Plane, Loft, Draft, Import, Boolean, Cut, Mirror, Axis, CoordSys, Helix, Transform, Thicken, Project, DeleteFace };
enum class CadFeatureType { Sketch, Extrude, Fillet, Chamfer, Hole, Thread, Shell, Revolve, Sweep, Pattern, Plane, Loft, Draft, Import, Boolean, Cut, Mirror, Axis, CoordSys, Helix, Transform, Thicken, Project, DeleteFace, Rib };
enum class SketchShape { Rectangle, Circle };
enum class PlaneType { Offset, Angle, Midplane, Tangent, TwoEdges, Coincident };
enum class AxisType { TwoPoints, FaceNormal, CylinderCenterline, PlaneIntersection, AlongEdge };
@@ -276,6 +276,12 @@ struct CadFeature {
// healed via BRepAlgoAPI_Defeaturing.
std::vector<int> delete_faces;
// Rib: a thin wall grown from an open sketch line, fused to the body.
int rib_sketch_ref{-1}; // feature index of the Sketch holding the profile
int rib_entity{-1}; // index of the open Line entity within that sketch
double rib_thickness{2}; // wall thickness (mm), centred on the line
double rib_depth{10}; // extrude distance along the sketch-plane normal (mm)
template<class Archive>
void save(Archive& ar) const {
std::string brep = (type == CadFeatureType::Import) ? brep_to_string(imported_solid) : std::string();
@@ -308,7 +314,8 @@ struct CadFeature {
project_source_body, project_edges, project_face,
delete_faces,
hole_style, hole_cbore_diameter, hole_cbore_depth,
hole_csink_diameter, hole_csink_angle, hole_standard);
hole_csink_diameter, hole_csink_angle, hole_standard,
rib_sketch_ref, rib_entity, rib_thickness, rib_depth);
}
template<class Archive>
void load(Archive& ar) {
@@ -340,9 +347,10 @@ struct CadFeature {
thicken_face, thicken_thickness, thicken_flip,
cut_face_body, cut_face,
project_source_body, project_edges, project_face,
delete_faces,
hole_style, hole_cbore_diameter, hole_cbore_depth,
hole_csink_diameter, hole_csink_angle, hole_standard);
delete_faces,
hole_style, hole_cbore_diameter, hole_cbore_depth,
hole_csink_diameter, hole_csink_angle, hole_standard,
rib_sketch_ref, rib_entity, rib_thickness, rib_depth);
imported_solid = brep_from_string(brep);
}
};
@@ -449,6 +457,10 @@ public:
int add_loft(const std::vector<int>& profile_refs, bool ruled, BooleanMode mode,
const std::string& name);
int add_shell(double thickness, int face, int target_body, const std::string& name);
// Grow a thin rib wall (thickness, depth) from the open Line entity `entity` inside sketch
// feature `sketch_ref`, fused to `target_body`. Returns the new feature index.
int add_rib(int sketch_ref, int entity, double thickness, double depth,
int target_body, const std::string& name);
int add_draft(double angle, int face, int target_body, const std::string& name);
// Boolean between two existing bodies. op reuses BooleanMode (Add=union, Cut=subtract,
// Intersect=common; New invalid). target survives, tool is consumed unless keep_tool.
+30
View File
@@ -77,6 +77,8 @@ const char* feature_type_name(CadFeatureType t)
case CadFeatureType::Transform: return "Transform";
case CadFeatureType::Thicken: return "Thicken";
case CadFeatureType::Project: return "Project";
case CadFeatureType::DeleteFace: return "DeleteFace";
case CadFeatureType::Rib: return "Rib";
}
return "Unknown";
}
@@ -294,6 +296,14 @@ json describe_tools()
json{{"name", "ent_b"}, {"type", "integer"}, {"description", "second entity index within the sketch"}},
json{{"name", "end_b"}, {"type", "integer"}, {"enum", json::array({0, 1})}, {"default", 0}, {"description", "0 = start/p0 side, 1 = end/p1 side"}},
})}},
json{{"name", "rib"}, {"summary", "Grow a thin rib wall (stiffener) from an open Line sketch entity, fused to a body."},
{"params", json::array({
json{{"name", "sketch"}, {"type", "integer"}, {"description", "sketch feature index holding the open line"}},
json{{"name", "entity"}, {"type", "integer"}, {"description", "entity index of the open Line within the sketch"}},
json{{"name", "thickness"}, {"type", "number"}, {"unit", "mm"}, {"default", 2}, {"min", 0.01}},
json{{"name", "depth"}, {"type", "number"}, {"unit", "mm"}, {"default", 10}, {"min", 0.01}},
json{{"name", "body"}, {"type", "integer"}, {"default", -1}, {"description", "target body; omit for the last body"}},
})}},
json{{"name", "query_topology"}, {"summary", "Measured faces (centroid/normal/cylinder) and edges (length/circle) of a body."},
{"params", json::array({
json{{"name", "body"}, {"type", "integer"}, {"default", 0}},
@@ -944,6 +954,25 @@ json action_shell(DesignPanel* panel, const json& params)
return json{{"ok", ok}, {"shell_index", s}, {"bodies", int(doc.bodies.size())}, {"error", doc.error}};
}
json action_rib(DesignPanel* panel, const json& params)
{
if (!params.contains("sketch")) throw std::runtime_error("rib needs 'sketch' (feature index)");
if (!params.contains("entity")) throw std::runtime_error("rib needs 'entity' (entity index)");
CadDocument& doc = panel->mcp_doc();
if (doc.bodies.empty()) throw std::runtime_error("no body to rib");
int sketch = params["sketch"].get<int>();
int entity = params["entity"].get<int>();
double thickness = params.value("thickness", 2.0);
double depth = params.value("depth", 10.0);
int bi = target_body_arg(params, doc);
doc.checkpoint();
int idx = doc.add_rib(sketch, entity, thickness, depth, bi, "Rib");
bool ok = doc.recompute();
if (!ok) doc.undo();
panel->mcp_after_change();
return json{{"ok", ok}, {"rib_index", idx}, {"bodies", int(doc.bodies.size())}, {"error", doc.error}};
}
json action_draft(DesignPanel* panel, const json& params)
{
if (!params.contains("face")) throw std::runtime_error("draft needs 'face' (id from query_topology)");
@@ -1182,6 +1211,7 @@ std::string handle_on_main(const std::string& method, const json& params, const
if (method == "boolean") return rpc_result(id, action_boolean(panel, params));
if (method == "pattern") return rpc_result(id, action_pattern(panel, params));
if (method == "shell") return rpc_result(id, action_shell(panel, params));
if (method == "rib") return rpc_result(id, action_rib(panel, params));
if (method == "draft") return rpc_result(id, action_draft(panel, params));
if (method == "mirror") return rpc_result(id, action_mirror(panel, params));
if (method == "transform") return rpc_result(id, action_transform(panel, params));
Binary file not shown.
+106
View File
@@ -3809,6 +3809,112 @@ TEST_CASE("golden recipe v1 still deserialises", "[CadDocument]")
}
}
// --- Rib tests (M5b) ---
TEST_CASE("rib adds material to a box", "[CadDocument][rib]")
{
using Catch::Matchers::WithinRel;
using Catch::Matchers::WithinAbs;
CadDocument doc;
// Build a box: 40x40x10 extruded on XY -> z=[0,10]
int sk_box = doc.add_sketch(SketchShape::Rectangle, SketchPlane::XY(),
40, 40, 10, "Box");
doc.add_extrude(sk_box, 10.0, false, BooleanMode::New, "Extrude");
REQUIRE(doc.recompute());
REQUIRE(doc.error.empty());
double Vbox = double(doc.display_mesh.volume());
REQUIRE(Vbox > 0.0);
// Sketch a single open Line across the box footprint, on the same XY plane.
// Line from (5,20) to (35,20), centred in Y but off-centre in X.
std::vector<SketchEntity> ents = {
{SketchEntity::Type::Line, Vec2d(5, 20), Vec2d(35, 20)},
};
int sk_rib = doc.add_sketch_entities(ents, SketchPlane::XY(), "RibLine");
REQUIRE(sk_rib >= 0);
int fi = doc.add_rib(sk_rib, 0, 3.0, 12.0, 0, "Rib");
REQUIRE(fi >= 0);
REQUIRE(doc.features[fi].type == CadFeatureType::Rib);
bool ok = doc.recompute();
REQUIRE(ok);
REQUIRE(doc.error.empty());
// The rib fused extra material -> volume must be strictly larger.
double Vrib = double(doc.display_mesh.volume());
REQUIRE(Vrib > Vbox);
// A rib with a bad sketch ref must fail cleanly, not crash.
CadDocument bad;
bad.add_sketch(SketchShape::Rectangle, SketchPlane::XY(), 20, 20, 10, "Box");
bad.add_extrude(0, 10.0, false, BooleanMode::New, "E");
REQUIRE(bad.recompute());
bad.add_rib(999, 0, 3.0, 10.0, 0, "BadRib");
REQUIRE_FALSE(bad.recompute());
}
TEST_CASE("rib non-line entity rejected safely", "[CadDocument][rib]")
{
using Catch::Matchers::Contains;
CadDocument doc;
int sk = doc.add_sketch(SketchShape::Rectangle, SketchPlane::XY(), 20, 20, 10, "Box");
doc.add_extrude(sk, 10.0, false, BooleanMode::New, "E");
REQUIRE(doc.recompute());
// Sketch with a Circle entity (not a Line)
std::vector<SketchEntity> ents = {
{SketchEntity::Type::Circle, Vec2d(0, 0), Vec2d(0, 0), Vec2d(0, 0), 5.0},
};
int sk2 = doc.add_sketch_entities(ents, SketchPlane::XY(), "CircleSketch");
doc.add_rib(sk2, 0, 2.0, 10.0, 0, "BadRib");
REQUIRE_FALSE(doc.recompute());
REQUIRE_FALSE(doc.error.empty());
REQUIRE_THAT(doc.error, Catch::Matchers::Contains("rib"));
}
TEST_CASE("rib round-trip serialization", "[CadDocument][rib]")
{
using Catch::Matchers::WithinAbs;
CadDocument doc;
int sk = doc.add_sketch(SketchShape::Rectangle, SketchPlane::XY(), 40, 40, 10, "Box");
doc.add_extrude(sk, 10.0, false, BooleanMode::New, "E");
REQUIRE(doc.recompute());
std::vector<SketchEntity> ents = {
{SketchEntity::Type::Line, Vec2d(5, 20), Vec2d(35, 20)},
};
int sk2 = doc.add_sketch_entities(ents, SketchPlane::XY(), "RibLine");
doc.add_rib(sk2, 0, 3.0, 12.0, 0, "Rib");
REQUIRE(doc.recompute());
REQUIRE(doc.error.empty());
size_t nb = doc.bodies.size();
double Vdoc = double(doc.display_mesh.volume());
auto blob = doc.serialize_recipe();
REQUIRE_FALSE(blob.empty());
CadDocument fresh;
REQUIRE(fresh.deserialize_recipe(blob));
REQUIRE(fresh.bodies.size() == nb);
double Vfresh = double(fresh.display_mesh.volume());
REQUIRE_THAT(Vfresh, WithinAbs(Vdoc, 1e-6));
// Find the rib feature and check its fields survived.
const CadFeature* rf = nullptr;
for (const auto& f : fresh.features) {
if (f.type == CadFeatureType::Rib) { rf = &f; break; }
}
REQUIRE(rf != nullptr);
REQUIRE_THAT(rf->rib_thickness, WithinAbs(3.0, 1e-9));
REQUIRE_THAT(rf->rib_depth, WithinAbs(12.0, 1e-9));
}
// --- Bridge tests (M3c) ---
TEST_CASE("bridge two collinear lines", "[CadDocument][bridge]")