CAD: Project feature — convert solid edges into a parametric sketch

Onshape-style "Use / Convert entities": pick edges (or a whole face) of an
existing solid and get sketch geometry projected onto a target plane, then
extrude/revolve/edit it like any sketch. Parametric: apply_project re-derives
the feature's entities from the source body on every recompute, so editing the
source updates the projection.

Line edges -> Line entities (exact); circles/arcs whose plane is parallel to
the sketch plane -> Circle/Arc (exact); everything else (incl. non-parallel
circles that project to ellipses) -> sampled Line chain.

Append-only: new enum value Project + project_source_body/project_edges/
project_face fields at the end of save/load; recipe version stays 2. Recompute
loop made non-const solely so apply_project can write back f.entities.

Suite 67->71 cases, 1178->1229 assertions, RC=0. Fixture 25493->26835 B.

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-24 23:42:40 +02:00
co-authored by Claude Opus 4.8
parent 07b39d45cb
commit 161d006e92
5 changed files with 328 additions and 8 deletions
+116 -5
View File
@@ -25,6 +25,9 @@
#include <BRepPrimAPI_MakeCylinder.hxx>
#include <BRepCheck_Analyzer.hxx>
#include <BRepLib.hxx>
#include <BRepAdaptor_Curve.hxx>
#include <GeomAbs_CurveType.hxx>
#include <BRep_Tool.hxx>
#include <Geom_CylindricalSurface.hxx>
#include <Geom_ConicalSurface.hxx>
#include <Geom2d_TrimmedCurve.hxx>
@@ -783,6 +786,20 @@ int CadDocument::add_thicken(int target_body, int face, double thickness, bool f
return int(features.size()) - 1;
}
int CadDocument::add_project_edges(int source_body, const std::vector<int>& edge_ids, int face,
const SketchPlane& plane, const std::string& name)
{
CadFeature f;
f.type = CadFeatureType::Project;
f.name = name;
f.project_source_body = source_body;
f.project_edges = edge_ids;
f.project_face = face;
f.plane = plane;
features.push_back(f);
return int(features.size()) - 1;
}
int CadDocument::add_plane(int base, double offset, double angle_tilt, int axis,
const std::string& name)
{
@@ -1442,6 +1459,8 @@ void CadDocument::apply_feature(TopoDS_Shape& result, bool& have_body,
switch (f.type) {
case CadFeatureType::Sketch:
return; // sketches carry no solid; consumed by an extrude
case CadFeatureType::Project:
return; // edges-to-sketch: consumed downstream, no solid body
case CadFeatureType::Helix:
return; // helical curve; consumed by Sweep as a path (like Sketch)
case CadFeatureType::Boolean:
@@ -1475,7 +1494,8 @@ void CadDocument::apply_feature(TopoDS_Shape& result, bool& have_body,
// otherwise fall back to f's own inline sketch params (this makes a
// single self-contained candidate previewable).
const CadFeature& sk = (f.sketch_ref >= 0 && f.sketch_ref < int(features.size())
&& features[f.sketch_ref].type == CadFeatureType::Sketch)
&& (features[f.sketch_ref].type == CadFeatureType::Sketch
|| features[f.sketch_ref].type == CadFeatureType::Project))
? features[f.sketch_ref] : f;
// Imported rigid art (Text/SVG) extrudes via the faces-with-holes path
// (with its placement transform applied); otherwise build a single wire
@@ -1546,7 +1566,8 @@ void CadDocument::apply_feature(TopoDS_Shape& result, bool& have_body,
// Resolve the profile sketch like Extrude: referenced Sketch when valid,
// else this feature's own inline entities/profile (self-contained candidate).
const CadFeature& sk = (f.sketch_ref >= 0 && f.sketch_ref < int(features.size())
&& features[f.sketch_ref].type == CadFeatureType::Sketch)
&& (features[f.sketch_ref].type == CadFeatureType::Sketch
|| features[f.sketch_ref].type == CadFeatureType::Project))
? features[f.sketch_ref] : f;
TopoDS_Wire wire = build_sketch_wire(sk);
const double ang = f.flip ? -f.revolve_angle : f.revolve_angle;
@@ -1571,7 +1592,8 @@ void CadDocument::apply_feature(TopoDS_Shape& result, bool& have_body,
}
case CadFeatureType::Sweep: {
const CadFeature& sk = (f.sketch_ref >= 0 && f.sketch_ref < int(features.size())
&& features[f.sketch_ref].type == CadFeatureType::Sketch)
&& (features[f.sketch_ref].type == CadFeatureType::Sketch
|| features[f.sketch_ref].type == CadFeatureType::Project))
? features[f.sketch_ref] : f;
if (f.sweep_path_ref < 0 || f.sweep_path_ref >= int(features.size()))
throw std::runtime_error("sweep needs a valid path reference");
@@ -1612,7 +1634,8 @@ void CadDocument::apply_feature(TopoDS_Shape& result, bool& have_body,
std::vector<TopoDS_Wire> profiles;
for (int ref : f.loft_profile_refs) {
if (ref < 0 || ref >= int(features.size())
|| features[ref].type != CadFeatureType::Sketch)
|| (features[ref].type != CadFeatureType::Sketch
&& features[ref].type != CadFeatureType::Project))
continue;
profiles.push_back(build_sketch_wire(features[ref]));
}
@@ -2092,6 +2115,92 @@ void CadDocument::apply_thicken(std::vector<CadBody>& bodies, const CadFeature&
bodies.push_back({solid, f.name.empty() ? std::string("Thicken") : f.name});
}
void CadDocument::apply_project(const std::vector<CadBody>& bodies, CadFeature& f) const
{
f.entities.clear();
const int nb = int(bodies.size());
if (nb == 0) throw std::runtime_error("project: no source body");
const int src = (f.project_source_body >= 0 && f.project_source_body < nb)
? f.project_source_body : nb - 1;
if (src < 0 || bodies[src].shape.IsNull()) throw std::runtime_error("project: source body is empty");
const TopoDS_Shape& shape = bodies[src].shape;
std::vector<TopoDS_Edge> edges;
if (!f.project_edges.empty()) {
for (int id : f.project_edges) {
TopoDS_Edge e = GeometryEngine::edge_by_index(shape, id);
if (e.IsNull()) throw std::runtime_error("project: edge not found");
edges.push_back(e);
}
} else if (f.project_face >= 0) {
TopoDS_Face fc = GeometryEngine::face_by_index(shape, f.project_face);
if (fc.IsNull()) throw std::runtime_error("project: face not found");
edges = GeometryEngine::edges_of_face(fc);
} else {
throw std::runtime_error("project: no edges or face selected");
}
if (edges.empty()) throw std::runtime_error("project: no edges to project");
auto to2d = [&](const gp_Pnt& p) -> Vec2d {
Vec3d d(p.X() - f.plane.origin.x(), p.Y() - f.plane.origin.y(), p.Z() - f.plane.origin.z());
return Vec2d(d.dot(f.plane.x_axis), d.dot(f.plane.y_axis));
};
for (const TopoDS_Edge& e : edges) {
BRepAdaptor_Curve ac(e);
const GeomAbs_CurveType ct = ac.GetType();
if (ct == GeomAbs_Line) {
gp_Pnt a = ac.Value(ac.FirstParameter());
gp_Pnt b = ac.Value(ac.LastParameter());
SketchEntity se; se.type = SketchEntity::Type::Line;
se.p0 = to2d(a); se.p1 = to2d(b);
f.entities.push_back(se);
} else if (ct == GeomAbs_Circle) {
gp_Circ c = ac.Circle();
gp_Dir cn = c.Axis().Direction();
Vec3d cnv(cn.X(), cn.Y(), cn.Z());
const double par = std::abs(cnv.dot(f.plane.normal));
const bool full = BRep_Tool::IsClosed(e) ||
std::abs((ac.LastParameter() - ac.FirstParameter()) - 2.0 * M_PI) < 1e-6;
if (par > 0.999) {
Vec2d ctr = to2d(c.Location());
if (full) {
SketchEntity se; se.type = SketchEntity::Type::Circle;
se.center = ctr; se.radius = c.Radius();
f.entities.push_back(se);
} else {
gp_Pnt a = ac.Value(ac.FirstParameter());
gp_Pnt b = ac.Value(ac.LastParameter());
Vec2d a2 = to2d(a), b2 = to2d(b);
SketchEntity se; se.type = SketchEntity::Type::Arc;
se.center = ctr; se.radius = c.Radius();
se.p0 = a2; se.p1 = b2;
se.start_angle = std::atan2(a2.y() - ctr.y(), a2.x() - ctr.x());
se.end_angle = std::atan2(b2.y() - ctr.y(), b2.x() - ctr.x());
f.entities.push_back(se);
}
continue;
}
std::vector<Vec3d> pts = GeometryEngine::sample_edge_world(e);
for (size_t i = 1; i < pts.size(); ++i) {
SketchEntity se; se.type = SketchEntity::Type::Line;
se.p0 = to2d(gp_Pnt(pts[i-1].x(), pts[i-1].y(), pts[i-1].z()));
se.p1 = to2d(gp_Pnt(pts[i].x(), pts[i].y(), pts[i].z()));
f.entities.push_back(se);
}
} else {
std::vector<Vec3d> pts = GeometryEngine::sample_edge_world(e);
for (size_t i = 1; i < pts.size(); ++i) {
SketchEntity se; se.type = SketchEntity::Type::Line;
se.p0 = to2d(gp_Pnt(pts[i-1].x(), pts[i-1].y(), pts[i-1].z()));
se.p1 = to2d(gp_Pnt(pts[i].x(), pts[i].y(), pts[i].z()));
f.entities.push_back(se);
}
}
}
if (f.entities.empty()) throw std::runtime_error("project: produced no entities");
}
void CadDocument::route_feature(std::vector<CadBody>& bodies, const CadFeature& f) const
{
if (f.type == CadFeatureType::Plane) return; // datum plane: not part of the body pipeline
@@ -2103,6 +2212,7 @@ void CadDocument::route_feature(std::vector<CadBody>& bodies, const CadFeature&
if (f.type == CadFeatureType::Mirror) { apply_mirror(bodies, f); return; } // mirror body about plane
if (f.type == CadFeatureType::Transform) { apply_transform(bodies, f); return; } // move/rotate body
if (f.type == CadFeatureType::Thicken) { apply_thicken(bodies, f); return; } // face -> plate
if (f.type == CadFeatureType::Project) return; // sketch-like: consumed downstream, no body
// Resolve the target body: explicit target_body when valid, else the last body.
const int t = (f.target_body >= 0 && f.target_body < int(bodies.size()))
? f.target_body : int(bodies.size()) - 1;
@@ -2135,13 +2245,14 @@ bool CadDocument::recompute()
error.clear();
std::vector<CadBody> built;
try {
for (const CadFeature& f : features) {
for (CadFeature& f : features) {
if (!f.enabled) continue;
if (f.type == CadFeatureType::Sketch) continue; // consumed by an extrude
if (f.type == CadFeatureType::Helix) continue; // consumed by Sweep as a path
if (f.type == CadFeatureType::Plane) continue; // datum: no solid, derived on demand
if (f.type == CadFeatureType::Axis) continue; // datum axis
if (f.type == CadFeatureType::CoordSys) continue; // datum coordinate system
if (f.type == CadFeatureType::Project) { apply_project(built, f); continue; }
route_feature(built, f);
}
} catch (const Standard_Failure& e) {
+15 -3
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 };
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 };
enum class SketchShape { Rectangle, Circle };
enum class PlaneType { Offset, Angle, Midplane, Tangent, TwoEdges, Coincident };
enum class AxisType { TwoPoints, FaceNormal, CylinderCenterline, PlaneIntersection, AlongEdge };
@@ -258,6 +258,11 @@ struct CadFeature {
int cut_face_body{-1}; // body owning the face; -1 = the target body
int cut_face{-1}; // global face id to cut along; -1 = use `plane`
// Project feature: convert edges of an existing solid into sketch entities on `plane`.
int project_source_body{-1}; // body owning the edges; -1 = last body
std::vector<int> project_edges; // global edge ids to project; empty => use project_face
int project_face{-1}; // if project_edges empty, project every edge of this face
template<class Archive>
void save(Archive& ar) const {
std::string brep = (type == CadFeatureType::Import) ? brep_to_string(imported_solid) : std::string();
@@ -286,7 +291,8 @@ struct CadFeature {
helix_radius, helix_pitch, helix_height, helix_left_handed, helix_taper_deg,
xf_translate, xf_axis, xf_pivot, xf_angle_deg, xf_copy,
thicken_face, thicken_thickness, thicken_flip,
cut_face_body, cut_face);
cut_face_body, cut_face,
project_source_body, project_edges, project_face);
}
template<class Archive>
void load(Archive& ar) {
@@ -316,7 +322,8 @@ struct CadFeature {
helix_radius, helix_pitch, helix_height, helix_left_handed, helix_taper_deg,
xf_translate, xf_axis, xf_pivot, xf_angle_deg, xf_copy,
thicken_face, thicken_thickness, thicken_flip,
cut_face_body, cut_face);
cut_face_body, cut_face,
project_source_body, project_edges, project_face);
imported_solid = brep_from_string(brep);
}
};
@@ -367,6 +374,10 @@ public:
int add_sketch_entities(const std::vector<SketchEntity>& entities,
const SketchPlane& plane, const std::string& name,
const std::vector<SketchEntityConstraintDef>& constraints = {});
// Project edges of source_body onto plane, producing a sketch feature whose
// entities are (re)derived on every recompute.
int add_project_edges(int source_body, const std::vector<int>& edge_ids, int face,
const SketchPlane& plane, const std::string& name);
// Solve features[index]'s sketch constraints, writing solved coordinates back
// into its profile.points. No-op (returns true) if the feature has no
// constraints. Returns false if index is invalid / not a Sketch / solve fails.
@@ -537,6 +548,7 @@ private:
void apply_mirror(std::vector<CadBody>& bodies, const CadFeature& f) const;
void apply_transform(std::vector<CadBody>& bodies, const CadFeature& f) const;
void apply_thicken(std::vector<CadBody>& bodies, const CadFeature& f) const;
void apply_project(const std::vector<CadBody>& bodies, CadFeature& f) const;
// Undo/redo stacks of feature-list snapshots. checkpoint() pushes onto m_undo and
// clears m_redo; undo()/redo() shuffle the current state between them. Capped so a
+27
View File
@@ -76,6 +76,7 @@ const char* feature_type_name(CadFeatureType t)
case CadFeatureType::Helix: return "Helix";
case CadFeatureType::Transform: return "Transform";
case CadFeatureType::Thicken: return "Thicken";
case CadFeatureType::Project: return "Project";
}
return "Unknown";
}
@@ -248,6 +249,13 @@ json describe_tools()
json{{"name", "keep_upper"},{"type", "boolean"}, {"default", true}},
json{{"name", "keep_lower"},{"type", "boolean"}, {"default", true}},
})}},
json{{"name", "project"}, {"summary", "Project edges of a solid onto a sketch plane, producing a new sketch feature."},
{"params", json::array({
json{{"name", "source_body"}, {"type", "integer"}, {"default", -1}, {"description", "body owning the edges; -1 = last body"}},
json{{"name", "face"}, {"type", "integer"}, {"default", -1}, {"description", "global face id on the source body; when set, all its edges are projected"}},
json{{"name", "edges"}, {"type", "array"}, {"default", json::array()}, {"description", "global edge ids to project; empty => project the face"}},
json{{"name", "plane"}, {"type", "string"}, {"default", "XY"}, {"description", "target sketch plane (XY/XZ/YZ)"}},
})}},
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}},
@@ -932,6 +940,24 @@ json action_split(DesignPanel* panel, const json& params)
return json{{"ok", ok}, {"split_index", idx}, {"bodies", int(doc.bodies.size())}, {"error", doc.error}};
}
json action_project(DesignPanel* panel, const json& params)
{
CadDocument& doc = panel->mcp_doc();
if (doc.bodies.empty()) throw std::runtime_error("no source body to project from");
int source_body = params.value("source_body", -1);
int face = params.value("face", -1);
std::vector<int> edges;
if (params.contains("edges") && params["edges"].is_array())
for (const auto& v : params["edges"]) edges.push_back(v.get<int>());
SketchPlane pl = plane_from(params, doc);
doc.checkpoint();
int idx = doc.add_project_edges(source_body, edges, face, pl, "Project");
bool ok = doc.recompute();
if (!ok) doc.undo();
panel->mcp_after_change();
return json{{"ok", ok}, {"project_index", idx}, {"bodies", int(doc.bodies.size())}, {"error", doc.error}};
}
json action_axis(DesignPanel* panel, const json& params)
{
CadDocument& doc = panel->mcp_doc();
@@ -1033,6 +1059,7 @@ std::string handle_on_main(const std::string& method, const json& params, const
if (method == "transform") return rpc_result(id, action_transform(panel, params));
if (method == "thicken") return rpc_result(id, action_thicken(panel, params));
if (method == "split") return rpc_result(id, action_split(panel, params));
if (method == "project") return rpc_result(id, action_project(panel, params));
if (method == "axis") return rpc_result(id, action_axis(panel, params));
if (method == "coordsys") return rpc_result(id, action_coordsys(panel, params));
if (method == "helix") return rpc_result(id, action_helix(panel, params));
Binary file not shown.
+170
View File
@@ -3094,6 +3094,167 @@ TEST_CASE("thicken round-trip serialization", "[CadDocument]")
}
}
// --- Project feature tests ---
TEST_CASE("project a box top face to 4 lines, extrudable", "[CadDocument][project]")
{
using Catch::Matchers::WithinRel;
CadDocument doc;
int sk = doc.add_sketch(SketchShape::Rectangle, SketchPlane::XY(), 20, 20, 10, "Box");
doc.add_extrude(sk, 10.0, false, BooleanMode::New, "Ext");
REQUIRE(doc.recompute());
REQUIRE(doc.bodies.size() == 1);
int n_faces = GeometryEngine::face_count(doc.bodies[0].shape);
int top_face = -1;
for (int i = 0; i < n_faces; ++i) {
TopoDS_Face fc = GeometryEngine::face_by_index(doc.bodies[0].shape, i);
Vec3d n = GeometryEngine::face_normal_world(fc);
if (n.z() > 0.9) { top_face = i; break; }
}
REQUIRE(top_face >= 0);
int proj = doc.add_project_edges(0, {}, top_face, SketchPlane::XY(), "ProjTop");
REQUIRE(proj >= 0);
doc.add_extrude(proj, 5.0, false, BooleanMode::New, "FromProj");
REQUIRE(doc.recompute());
REQUIRE(doc.error.empty());
const auto& pf = doc.features[proj];
REQUIRE(pf.entities.size() == 4);
for (const auto& e : pf.entities)
REQUIRE(e.type == SketchEntity::Type::Line);
REQUIRE(doc.bodies.size() >= 2);
double v = double(SketchEngine::tessellate(doc.bodies.back().shape).volume());
REQUIRE_THAT(v, WithinRel(20.0 * 20.0 * 5.0, 1e-3));
}
TEST_CASE("project a cylinder top edge to 1 circle, extrudable", "[CadDocument][project]")
{
using Catch::Matchers::WithinRel;
CadDocument doc;
SketchEntity c;
c.type = SketchEntity::Type::Circle;
c.center = Vec2d(0, 0);
c.radius = 6.0;
int sk = doc.add_sketch_entities({c}, SketchPlane::XY(), "Circ");
doc.add_extrude(sk, 10.0, false, BooleanMode::New, "Cyl");
REQUIRE(doc.recompute());
REQUIRE(doc.bodies.size() == 1);
int n_edges = GeometryEngine::edge_count(doc.bodies[0].shape);
int top_edge = -1;
for (int i = 0; i < n_edges; ++i) {
TopoDS_Edge e = GeometryEngine::edge_by_index(doc.bodies[0].shape, i);
auto pts = GeometryEngine::sample_edge_world(e);
if (pts.empty()) continue;
Vec3d mid = Vec3d::Zero();
for (const auto& p : pts) mid += p;
mid /= double(pts.size());
if (mid.z() > 9.0) {
BRepAdaptor_Curve ac(e);
if (ac.GetType() == GeomAbs_Circle) { top_edge = i; break; }
}
}
REQUIRE(top_edge >= 0);
int proj = doc.add_project_edges(0, {top_edge}, -1, SketchPlane::XY(), "ProjCirc");
REQUIRE(proj >= 0);
doc.add_extrude(proj, 4.0, false, BooleanMode::New, "FromCirc");
REQUIRE(doc.recompute());
REQUIRE(doc.error.empty());
const auto& pf = doc.features[proj];
REQUIRE(pf.entities.size() == 1);
REQUIRE(pf.entities[0].type == SketchEntity::Type::Circle);
REQUIRE_THAT(pf.entities[0].radius, WithinRel(6.0, 1e-3));
REQUIRE(doc.bodies.size() >= 2);
double v = double(SketchEngine::tessellate(doc.bodies.back().shape).volume());
REQUIRE_THAT(v, WithinRel(M_PI * 36.0 * 4.0, 1e-2));
}
TEST_CASE("project bad face id returns error", "[CadDocument][project]")
{
CadDocument doc;
int sk = doc.add_sketch(SketchShape::Rectangle, SketchPlane::XY(), 20, 20, 10, "Box");
doc.add_extrude(sk, 10.0, false, BooleanMode::New, "Ext");
REQUIRE(doc.recompute());
doc.add_project_edges(0, {}, 9999, SketchPlane::XY(), "Bad");
bool ok = doc.recompute();
REQUIRE_FALSE(ok);
bool has_project = doc.error.find("project") != std::string::npos
|| doc.error.find("face") != std::string::npos;
REQUIRE(has_project);
}
TEST_CASE("project round-trip serialization", "[CadDocument][project]")
{
using Catch::Matchers::WithinAbs;
using Catch::Matchers::WithinRel;
CadDocument doc;
int sk = doc.add_sketch(SketchShape::Rectangle, SketchPlane::XY(), 20, 20, 10, "Box");
doc.add_extrude(sk, 10.0, false, BooleanMode::New, "Ext");
REQUIRE(doc.recompute());
REQUIRE(doc.bodies.size() == 1);
int n_faces = GeometryEngine::face_count(doc.bodies[0].shape);
int top_face = -1;
for (int i = 0; i < n_faces; ++i) {
TopoDS_Face fc = GeometryEngine::face_by_index(doc.bodies[0].shape, i);
Vec3d n = GeometryEngine::face_normal_world(fc);
if (n.z() > 0.9) { top_face = i; break; }
}
REQUIRE(top_face >= 0);
doc.add_project_edges(0, {}, top_face, SketchPlane::XY(), "ProjTop");
REQUIRE(doc.recompute());
REQUIRE(doc.error.empty());
int saved_src = doc.features.back().project_source_body;
int saved_face = doc.features.back().project_face;
auto saved_edges = doc.features.back().project_edges;
size_t saved_nb = doc.bodies.size();
std::vector<std::pair<Vec3d, Vec3d>> bboxes;
for (const auto& b : doc.bodies) {
Bnd_Box bb; BRepBndLib::Add(b.shape, bb);
Standard_Real x0, y0, z0, x1, y1, z1;
bb.Get(x0, y0, z0, x1, y1, z1);
bboxes.push_back({Vec3d(x0, y0, z0), Vec3d(x1, y1, z1)});
}
auto blob = doc.serialize_recipe();
REQUIRE_FALSE(blob.empty());
CadDocument doc2;
REQUIRE(doc2.deserialize_recipe(blob));
REQUIRE(doc2.bodies.size() == saved_nb);
REQUIRE(doc2.features.size() == doc.features.size());
const auto& f2 = doc2.features.back();
REQUIRE(f2.project_source_body == saved_src);
REQUIRE(f2.project_face == saved_face);
REQUIRE(f2.project_edges == saved_edges);
for (size_t i = 0; i < saved_nb; ++i) {
Bnd_Box bb; BRepBndLib::Add(doc2.bodies[i].shape, bb);
Standard_Real x0, y0, z0, x1, y1, z1;
bb.Get(x0, y0, z0, x1, y1, z1);
REQUIRE_THAT(double(x0), WithinAbs(bboxes[i].first.x(), 1e-6));
REQUIRE_THAT(double(y0), WithinAbs(bboxes[i].first.y(), 1e-6));
REQUIRE_THAT(double(z0), WithinAbs(bboxes[i].first.z(), 1e-6));
REQUIRE_THAT(double(x1), WithinAbs(bboxes[i].second.x(), 1e-6));
REQUIRE_THAT(double(y1), WithinAbs(bboxes[i].second.y(), 1e-6));
REQUIRE_THAT(double(z1), WithinAbs(bboxes[i].second.z(), 1e-6));
}
}
// --- Golden recipe fixture (v1 format tripwire) ---
static CadDocument make_golden_doc_v1()
@@ -3257,6 +3418,8 @@ static CadDocument make_golden_doc_v1()
doc.add_split_by_face(0, 0, 2, true, false, "GoldenSplit");
doc.add_project_edges(0, {}, 0, SketchPlane::XY(), "GoldenProject");
return doc;
}
@@ -3545,6 +3708,13 @@ TEST_CASE("golden recipe v1 still deserialises", "[CadDocument]")
REQUIRE(f.cut_keep_upper == true);
REQUIRE(f.cut_keep_lower == false);
}
// Project
if (f.type == CadFeatureType::Project && e.name == "GoldenProject") {
REQUIRE(f.project_source_body == 0);
REQUIRE(f.project_face == 0);
REQUIRE(f.project_edges == e.project_edges);
}
}
// --- Layer 2: geometry check (optional — only if the document recomputes) ---