M8a: assembly mates — Fastened + Planar (recipe v3)

An assembly is a multi-body document. An instance is already expressible as
Transform with xf_copy=true, and a mate connector is already a CoordSys
feature, so this adds exactly one feature type: Mate.

No constraint solver. A mate rigidly transforms the body carrying connector B
so that B's frame lands on connector A's, applied in feature order like every
other feature. Chains resolve by composition; closed kinematic loops do not
converge (last mate wins) and are out of scope.

The vendored SolveSpace in src/libslic3r/slvs/ was evaluated for 3D extension
and rejected: it is built and linked but has zero callers, and SketchEngine's
solver is hand-rolled. Extending it would mean adopting a dependency to write
more code than the alternative.

- CadFeatureType::Mate appended; six fields (mate_kind, mate_cs_a, mate_cs_b,
  mate_offset, mate_angle, mate_flip) appended at the END of both cereal lists
- SNAPORCA_CAD_RECIPE_VERSION 2 -> 3; v2 blobs are rejected, as by design there
  is no migration path. Golden fixture renamed to cad_recipe_v3.bin and
  regenerated once, extended with two CoordSys + one Mate so the new fields are
  tripwired by the field-order assertions
- datum_frame() extracted from resolve_datum_coordsys() so a mate can resolve
  its connectors against the in-progress bodies vector during replay
- apply_mate dispatched early-return, so Mate is deliberately absent from
  starts_new (unreachable for that dispatch style)
- Planar: the degenerate branch splits on the sign of zB.z_target — antiparallel
  needs a 180 deg rotation about a perpendicular axis, which an earlier revision
  silently skipped, leaving the body's normal inverted
- MCP: mate command, named bare to match the other 38 methods

Drive-by: feature_type_name() was missing Mirror, ThickenSurface, SurfaceOffset,
SurfaceLoft and SurfaceFill, which reported as "Unknown" to MCP clients.

Suite 122 cases / 1741 assertions green. Note that kernel-test.sh builds only
libslic3r_tests, so McpControl.cpp is reviewed but not compiled here.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Tommaso Bianchi
2026-07-25 10:51:22 +02:00
co-authored by Claude Opus 5
parent 9c28be5860
commit b13ca01ccc
6 changed files with 838 additions and 47 deletions
+168 -39
View File
@@ -1291,6 +1291,22 @@ int CadDocument::add_coordsys(CoordSysType type, const Vec3d& point, const std::
return int(features.size()) - 1;
}
int CadDocument::add_mate(int kind, int cs_a, int cs_b, double offset, double angle_deg, bool flip,
const std::string& name)
{
CadFeature f;
f.type = CadFeatureType::Mate;
f.name = name;
f.mate_kind = kind;
f.mate_cs_a = cs_a;
f.mate_cs_b = cs_b;
f.mate_offset = offset;
f.mate_angle = angle_deg;
f.mate_flip = flip;
features.push_back(f);
return int(features.size()) - 1;
}
int CadDocument::add_helix(const SketchPlane& plane, double radius, double pitch, double height,
bool left_handed, double taper_deg, const std::string& name)
{
@@ -1627,9 +1643,10 @@ std::vector<CadDocument::DatumAxis> CadDocument::resolve_datum_axes() const
return out;
}
std::vector<CadDocument::DatumCoordSys> CadDocument::resolve_datum_coordsys() const
CadDocument::DatumCoordSys CadDocument::datum_frame(const std::vector<CadBody>& bodies, const CadFeature& f)
{
std::vector<DatumCoordSys> out;
CadDocument::DatumCoordSys ds;
ds.name = f.name;
auto resolve_face = [&](int body_idx, int face_idx) -> TopoDS_Face {
if (face_idx < 0 || body_idx < 0 || body_idx >= int(bodies.size()))
@@ -1649,45 +1666,48 @@ std::vector<CadDocument::DatumCoordSys> CadDocument::resolve_datum_coordsys() co
return true;
};
switch (f.coordsys_type) {
case CoordSysType::PointWorld: {
ds.origin = f.coordsys_point;
ds.x = Vec3d(1, 0, 0);
ds.y = Vec3d(0, 1, 0);
break;
}
case CoordSysType::FaceAndDirection: {
TopoDS_Face fc = resolve_face(f.coordsys_body, f.coordsys_face);
Vec3d p0, edge_dir;
bool have_edge = resolve_edge(f.coordsys_body, f.coordsys_edge, p0, edge_dir);
if (fc.IsNull()) { ds.error = "face not found"; break; }
ds.origin = GeometryEngine::face_centroid_world(fc);
Vec3d Z = GeometryEngine::face_normal_world(fc);
// Tentative X: edge direction if available, else the hint or a fallback.
Vec3d X_tent = have_edge ? edge_dir : f.coordsys_x_hint;
if (X_tent.squaredNorm() < 1e-18) { ds.error = "zero-length direction"; break; }
X_tent.normalize();
// Gram-Schmidt: ensure orthonormal, right-handed frame.
// Y = Z x X_tent, X = Y x Z (this makes X perpendicular to Z, not X_tent)
Vec3d Y = Z.cross(X_tent);
if (Y.squaredNorm() < 1e-12) {
// Edge/hint is parallel to Z -> X is degenerate; fall back to world X/Y orthonormalised.
Vec3d ref = (std::abs(Z.z()) < 0.9) ? Vec3d(0, 0, 1) : Vec3d(1, 0, 0);
Y = Z.cross(ref);
if (Y.squaredNorm() < 1e-12) Y = Z.cross(Vec3d(0, 1, 0));
}
Y.normalize();
ds.x = Y.cross(Z).normalized();
ds.y = Y;
break;
}
}
return ds;
}
std::vector<CadDocument::DatumCoordSys> CadDocument::resolve_datum_coordsys() const
{
std::vector<DatumCoordSys> out;
for (const CadFeature& f : features) {
if (f.type != CadFeatureType::CoordSys || !f.enabled) continue;
DatumCoordSys ds;
ds.name = f.name;
switch (f.coordsys_type) {
case CoordSysType::PointWorld: {
ds.origin = f.coordsys_point;
ds.x = Vec3d(1, 0, 0);
ds.y = Vec3d(0, 1, 0);
break;
}
case CoordSysType::FaceAndDirection: {
TopoDS_Face fc = resolve_face(f.coordsys_body, f.coordsys_face);
Vec3d p0, edge_dir;
bool have_edge = resolve_edge(f.coordsys_body, f.coordsys_edge, p0, edge_dir);
if (fc.IsNull()) { ds.error = "face not found"; break; }
ds.origin = GeometryEngine::face_centroid_world(fc);
Vec3d Z = GeometryEngine::face_normal_world(fc);
// Tentative X: edge direction if available, else the hint or a fallback.
Vec3d X_tent = have_edge ? edge_dir : f.coordsys_x_hint;
if (X_tent.squaredNorm() < 1e-18) { ds.error = "zero-length direction"; break; }
X_tent.normalize();
// Gram-Schmidt: ensure orthonormal, right-handed frame.
// Y = Z x X_tent, X = Y x Z (this makes X perpendicular to Z, not X_tent)
Vec3d Y = Z.cross(X_tent);
if (Y.squaredNorm() < 1e-12) {
// Edge/hint is parallel to Z -> X is degenerate; fall back to world X/Y orthonormalised.
Vec3d ref = (std::abs(Z.z()) < 0.9) ? Vec3d(0, 0, 1) : Vec3d(1, 0, 0);
Y = Z.cross(ref);
if (Y.squaredNorm() < 1e-12) Y = Z.cross(Vec3d(0, 1, 0));
}
Y.normalize();
ds.x = Y.cross(Z).normalized();
ds.y = Y;
break;
}
}
out.push_back(ds);
out.push_back(datum_frame(bodies, f));
}
return out;
}
@@ -2872,6 +2892,114 @@ void CadDocument::apply_project(const std::vector<CadBody>& bodies, CadFeature&
if (f.entities.empty()) throw std::runtime_error("project: produced no entities");
}
void CadDocument::apply_mate(std::vector<CadBody>& bodies, const CadFeature& f) const
{
const int nc = int(features.size());
if (f.mate_cs_a < 0 || f.mate_cs_a >= nc)
throw std::runtime_error("mate: mate_cs_a out of range");
if (f.mate_cs_b < 0 || f.mate_cs_b >= nc)
throw std::runtime_error("mate: mate_cs_b out of range");
const CadFeature& fa = features[f.mate_cs_a];
const CadFeature& fb = features[f.mate_cs_b];
if (fa.type != CadFeatureType::CoordSys || !fa.enabled)
throw std::runtime_error("mate: mate_cs_a is not a valid CoordSys feature");
if (fb.type != CadFeatureType::CoordSys || !fb.enabled)
throw std::runtime_error("mate: mate_cs_b is not a valid CoordSys feature");
CadDocument::DatumCoordSys A = datum_frame(bodies, fa);
CadDocument::DatumCoordSys B = datum_frame(bodies, fb);
if (!A.error.empty()) throw std::runtime_error("mate: " + A.error);
if (!B.error.empty()) throw std::runtime_error("mate: " + B.error);
const int tgt_body = fb.coordsys_body;
if (tgt_body < 0)
throw std::runtime_error("mate: mate_cs_b has no associated body");
if (tgt_body >= int(bodies.size()) || bodies[tgt_body].shape.IsNull())
throw std::runtime_error("mate: target body out of range or null");
Vec3d xA(A.x), yA(A.y), oA(A.origin);
Vec3d zA = xA.cross(yA).normalized();
Vec3d xB(B.x), yB(B.y), oB(B.origin);
Vec3d zB = xB.cross(yB).normalized();
auto make_4x4 = [&](const Vec3d& x, const Vec3d& y, const Vec3d& z, const Vec3d& o) {
gp_Trsf T;
T.SetValues(x.x(), y.x(), z.x(), o.x(),
x.y(), y.y(), z.y(), o.y(),
x.z(), y.z(), z.z(), o.z());
return T;
};
gp_Trsf M_A = make_4x4(xA, yA, zA, oA);
gp_Trsf M_B = make_4x4(xB, yB, zB, oB);
gp_Trsf F;
if (f.mate_flip) {
// Rx(pi): flip y→-y, z→-z
F.SetValues(1, 0, 0, 0,
0, -1, 0, 0,
0, 0, -1, 0);
}
gp_Trsf T;
if (f.mate_kind == 0) {
// Fastened: T = M_A * Rz(mate_angle) * Tz(mate_offset) * F * M_B^-1
gp_Trsf Rz;
Rz.SetRotation(gp_Ax1(gp_Pnt(0,0,0), gp_Dir(0,0,1)), f.mate_angle * M_PI / 180.0);
gp_Trsf Tz;
Tz.SetTranslation(gp_Vec(0, 0, f.mate_offset));
gp_Trsf M_B_inv = M_B.Inverted();
T = M_A * Rz * Tz * F * M_B_inv;
} else {
// Planar (mate_kind == 1): align normals only, preserve in-plane pose
Vec3d z_target = f.mate_flip ? -zA : zA;
double ddot = zB.dot(z_target);
Vec3d rot_axis;
double rot_angle = 0;
if (ddot <= -0.9999) {
// Anti-parallel: 180° rotation about any axis perpendicular to zB
Vec3d ref = (std::abs(zB.z()) < 0.9) ? Vec3d(0, 0, 1) : Vec3d(1, 0, 0);
rot_axis = zB.cross(ref).normalized();
rot_angle = M_PI;
} else {
rot_axis = zB.cross(z_target);
if (rot_axis.squaredNorm() > 1e-18) {
rot_axis.normalize();
rot_angle = std::acos(std::max(-1.0, std::min(1.0, ddot)));
}
}
gp_Trsf R_align;
if (rot_angle > 1e-12) {
R_align.SetRotation(gp_Ax1(gp_Pnt(oB.x(), oB.y(), oB.z()),
gp_Dir(rot_axis.x(), rot_axis.y(), rot_axis.z())),
rot_angle);
}
gp_Trsf Rz_about_target;
if (std::abs(f.mate_angle) > 1e-12) {
Rz_about_target.SetRotation(
gp_Ax1(gp_Pnt(oB.x(), oB.y(), oB.z()),
gp_Dir(z_target.x(), z_target.y(), z_target.z())),
f.mate_angle * M_PI / 180.0);
}
gp_Trsf R = Rz_about_target * R_align;
double d = (oB - oA).dot(zA);
Vec3d offset_vec = zA * (f.mate_offset - d);
T.SetValues(1, 0, 0, offset_vec.x(),
0, 1, 0, offset_vec.y(),
0, 0, 1, offset_vec.z());
T = T * R;
}
BRepBuilderAPI_Transform xform(bodies[tgt_body].shape, T, true /*copy*/);
if (!xform.IsDone()) throw std::runtime_error("mate: transform failed");
bodies[tgt_body].shape = xform.Shape();
}
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
@@ -2882,6 +3010,7 @@ void CadDocument::route_feature(std::vector<CadBody>& bodies, const CadFeature&
if (f.type == CadFeatureType::Cut) { apply_cut(bodies, f); return; } // plane-split body
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::Mate) { apply_mate(bodies, f); return; } // assembly mate
if (f.type == CadFeatureType::Thicken) { apply_thicken(bodies, f); return; } // face -> plate
if (f.type == CadFeatureType::ThickenSurface) { apply_thicken_surface(bodies, f); return; }
if (f.type == CadFeatureType::SurfaceOffset) { apply_surface_offset(bodies, f); return; }
+20 -6
View File
@@ -19,7 +19,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, Rib, SurfaceExtrude, SurfaceRevolve, ThickenSurface, SurfaceOffset, SurfaceLoft, SurfaceFill };
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, SurfaceExtrude, SurfaceRevolve, ThickenSurface, SurfaceOffset, SurfaceLoft, SurfaceFill, Mate };
enum class SketchShape { Rectangle, Circle };
enum class PlaneType { Offset, Angle, Midplane, Tangent, TwoEdges, Coincident };
enum class AxisType { TwoPoints, FaceNormal, CylinderCenterline, PlaneIntersection, AlongEdge };
@@ -295,6 +295,14 @@ struct CadFeature {
double rib_thickness{2}; // wall thickness (mm), centred on the line
double rib_depth{10}; // extrude distance along the sketch-plane normal (mm)
// --- Mate (assembly) ---
int mate_kind{0}; // 0 = Fastened, 1 = Planar (M8b adds 2/3/4)
int mate_cs_a{-1}; // feature index of the FIXED CoordSys (mate connector A)
int mate_cs_b{-1}; // feature index of the CoordSys on the body that MOVES
double mate_offset{0}; // translation along A's z, mm
double mate_angle{0}; // rotation about A's z, degrees
bool mate_flip{false}; // oppose the two z axes (face-to-face)
template<class Archive>
void save(Archive& ar) const {
std::string brep = (type == CadFeatureType::Import) ? brep_to_string(imported_solid) : std::string();
@@ -329,8 +337,9 @@ struct CadFeature {
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,
pattern_curve_sketch, pattern_curve_entity,
expr);
pattern_curve_sketch, pattern_curve_entity,
expr,
mate_kind, mate_cs_a, mate_cs_b, mate_offset, mate_angle, mate_flip);
}
template<class Archive>
void load(Archive& ar) {
@@ -366,8 +375,9 @@ struct CadFeature {
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,
pattern_curve_sketch, pattern_curve_entity,
expr);
pattern_curve_sketch, pattern_curve_entity,
expr,
mate_kind, mate_cs_a, mate_cs_b, mate_offset, mate_angle, mate_flip);
imported_solid = brep_from_string(brep);
}
};
@@ -528,6 +538,8 @@ public:
int add_axis(AxisType axis_type, const std::string& name);
// Datum coordinate system.
int add_coordsys(CoordSysType type, const Vec3d& point, const std::string& name);
int add_mate(int kind, int cs_a, int cs_b, double offset, double angle_deg, bool flip,
const std::string& name);
int add_helix(const SketchPlane& plane, double radius, double pitch, double height,
bool left_handed, double taper_deg, const std::string& name);
// Build the helix wire from a Helix feature's params (exposed for tests).
@@ -550,7 +562,7 @@ public:
// - bump this whenever CadFeature::save/load gains or loses a field
// - v1 blobs are deliberately not loadable; there is no migration path by design
// - append fields ONLY at the end of save/load, never reorder (golden fixture enforces this)
static constexpr uint32_t SNAPORCA_CAD_RECIPE_VERSION = 2;
static constexpr uint32_t SNAPORCA_CAD_RECIPE_VERSION = 3;
std::string serialize_recipe() const;
bool deserialize_recipe(const std::string& blob);
@@ -634,6 +646,8 @@ private:
void apply_thicken_surface(std::vector<CadBody>& bodies, const CadFeature& f) const;
void apply_surface_offset(std::vector<CadBody>& bodies, const CadFeature& f) const;
void apply_project(const std::vector<CadBody>& bodies, CadFeature& f) const;
static DatumCoordSys datum_frame(const std::vector<CadBody>& bodies, const CadFeature& f);
void apply_mate(std::vector<CadBody>& bodies, const 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
+35
View File
@@ -71,6 +71,7 @@ const char* feature_type_name(CadFeatureType t)
case CadFeatureType::Import: return "Import";
case CadFeatureType::Boolean: return "Boolean";
case CadFeatureType::Cut: return "Cut";
case CadFeatureType::Mirror: return "Mirror";
case CadFeatureType::Axis: return "Axis";
case CadFeatureType::CoordSys: return "CoordSys";
case CadFeatureType::Helix: return "Helix";
@@ -81,6 +82,11 @@ const char* feature_type_name(CadFeatureType t)
case CadFeatureType::Rib: return "Rib";
case CadFeatureType::SurfaceExtrude: return "SurfaceExtrude";
case CadFeatureType::SurfaceRevolve: return "SurfaceRevolve";
case CadFeatureType::ThickenSurface: return "ThickenSurface";
case CadFeatureType::SurfaceOffset: return "SurfaceOffset";
case CadFeatureType::SurfaceLoft: return "SurfaceLoft";
case CadFeatureType::SurfaceFill: return "SurfaceFill";
case CadFeatureType::Mate: return "Mate";
}
return "Unknown";
}
@@ -324,6 +330,15 @@ json describe_tools()
json{{"name", "angle"}, {"type", "number"}, {"unit", "deg"}, {"default", 360}},
json{{"name", "axis"}, {"type", "integer"}, {"enum", json::array({0, 1})}, {"default", 0}},
})}},
json{{"name", "mate"}, {"summary", "Mate two bodies: transform the moving body (cs_b) so its connector lands on the fixed one (cs_a). kind: 0=Fastened, 1=Planar."},
{"params", json::array({
json{{"name", "kind"}, {"type", "integer"}, {"default", 0}, {"description", "0=Fastened (full align), 1=Planar (normal only)"}},
json{{"name", "cs_a"}, {"type", "integer"}, {"description", "feature index of the fixed CoordSys (mate connector A)"}},
json{{"name", "cs_b"}, {"type", "integer"}, {"description", "feature index of the CoordSys on the body that moves"}},
json{{"name", "offset"}, {"type", "number"}, {"unit", "mm"}, {"default", 0}},
json{{"name", "angle"}, {"type", "number"}, {"unit", "deg"}, {"default", 0}},
json{{"name", "flip"}, {"type", "boolean"}, {"default", false}},
})}},
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}},
@@ -1310,6 +1325,25 @@ json action_helix(DesignPanel* panel, const json& params)
return json{{"ok", true}, {"helix_index", idx}, {"bodies", int(doc.bodies.size())}, {"error", doc.error}};
}
json action_mate(DesignPanel* panel, const json& params)
{
if (!params.contains("cs_a")) throw std::runtime_error("mate needs 'cs_a' (CoordSys feature index)");
if (!params.contains("cs_b")) throw std::runtime_error("mate needs 'cs_b' (CoordSys feature index)");
const int kind = params.value("kind", 0);
const int cs_a = params["cs_a"].get<int>();
const int cs_b = params["cs_b"].get<int>();
const double offset = params.value("offset", 0.0);
const double angle = params.value("angle", 0.0);
const bool flip = params.value("flip", false);
CadDocument& doc = panel->mcp_doc();
doc.checkpoint();
int idx = doc.add_mate(kind, cs_a, cs_b, offset, angle, flip, "Mate");
bool ok = doc.recompute();
if (!ok) doc.undo();
panel->mcp_after_change();
return json{{"ok", ok}, {"mate_index", idx}, {"bodies", int(doc.bodies.size())}, {"error", doc.error}};
}
json action_set_variable(DesignPanel* panel, const json& params)
{
if (!params.contains("name")) throw std::runtime_error("set_variable needs 'name'");
@@ -1394,6 +1428,7 @@ std::string handle_on_main(const std::string& method, const json& params, const
if (method == "surface_offset") return rpc_result(id, action_surface_offset(panel, params));
if (method == "surface_loft") return rpc_result(id, action_surface_loft(panel, params));
if (method == "surface_fill") return rpc_result(id, action_surface_fill(panel, params));
if (method == "mate") return rpc_result(id, action_mate(panel, params));
return rpc_error(id, -32601, "Unknown method: " + method);
} catch (const Standard_Failure& ex) { // OCCT errors are NOT std::exception
return rpc_error(id, -32000, std::string("OCCT: ") + (ex.GetMessageString() ? ex.GetMessageString() : "failure"));
Binary file not shown.
Binary file not shown.
+615 -2
View File
@@ -3567,6 +3567,17 @@ static CadDocument make_golden_doc_v1()
doc.add_sketch_entities(ge, SketchPlane::XY(), "Sketch_Ctor");
}
// ---- Mate connectors: two CoordSys features with distinctive non-default values ----
int cs_idx_a = doc.add_coordsys(CoordSysType::PointWorld, Vec3d(11, 12, 13), "CS_MateA");
doc.features[cs_idx_a].coordsys_body = 0;
doc.features[cs_idx_a].coordsys_x_hint = Vec3d(0.1, 0.2, 0.9);
int cs_idx_b = doc.add_coordsys(CoordSysType::PointWorld, Vec3d(14, 15, 16), "CS_MateB");
doc.features[cs_idx_b].coordsys_body = 1;
doc.features[cs_idx_b].coordsys_x_hint = Vec3d(0.6, 0.7, 0.3);
// ---- Mate: Fastened with all six fields carrying distinctive non-defaults ----
doc.add_mate(1, cs_idx_a, cs_idx_b, 7.25, 33.0, true, "GoldenMate");
return doc;
}
@@ -3578,7 +3589,7 @@ TEST_CASE("regenerate golden recipe fixture", "[.regen]")
auto blob = doc.serialize_recipe();
REQUIRE_FALSE(blob.empty());
std::string path = std::string(TEST_DATA_DIR) + "/cad_recipe_v2.bin";
std::string path = std::string(TEST_DATA_DIR) + "/cad_recipe_v3.bin";
std::ofstream ofs(path, std::ios::binary);
REQUIRE(ofs.is_open());
ofs.write(blob.data(), static_cast<std::streamsize>(blob.size()));
@@ -3592,7 +3603,7 @@ TEST_CASE("golden recipe v1 still deserialises", "[CadDocument]")
using Catch::Matchers::WithinAbs;
// Read the golden blob from disk
std::string path = std::string(TEST_DATA_DIR) + "/cad_recipe_v2.bin";
std::string path = std::string(TEST_DATA_DIR) + "/cad_recipe_v3.bin";
std::ifstream ifs(path, std::ios::binary);
REQUIRE(ifs.is_open());
std::string blob((std::istreambuf_iterator<char>(ifs)),
@@ -3868,6 +3879,16 @@ TEST_CASE("golden recipe v1 still deserialises", "[CadDocument]")
REQUIRE(f.project_face == 0);
REQUIRE(f.project_edges == e.project_edges);
}
// Mate
if (f.type == CadFeatureType::Mate && e.name == "GoldenMate") {
REQUIRE(f.mate_kind == 1);
REQUIRE(f.mate_cs_a == e.mate_cs_a);
REQUIRE(f.mate_cs_b == e.mate_cs_b);
REQUIRE_THAT(f.mate_offset, WithinAbs(7.25, 1e-9));
REQUIRE_THAT(f.mate_angle, WithinAbs(33.0, 1e-9));
REQUIRE(f.mate_flip == true);
}
}
// --- Layer 2: geometry check (optional — only if the document recomputes) ---
@@ -4874,3 +4895,595 @@ TEST_CASE("surface-loft round-trip serialize/deserialize", "[CadDocument][surfac
REQUIRE_THAT(double(fy1), WithinAbs(double(oy1), 1e-6));
REQUIRE_THAT(double(fz1), WithinAbs(double(oz1), 1e-6));
}
// --- Mate tests (M8a) ---
TEST_CASE("fastened mate with zero offset/angle", "[CadDocument][mate]")
{
using Catch::Matchers::WithinAbs;
CadDocument doc;
int sk_box = doc.add_sketch(SketchShape::Rectangle, SketchPlane::XY(), 10, 10, 0, "Box");
doc.add_extrude(sk_box, 5.0, false, BooleanMode::New, "BoxExt");
REQUIRE(doc.recompute());
REQUIRE(doc.error.empty());
int cs_fixed = doc.add_coordsys(CoordSysType::PointWorld, Vec3d(5, 5, 5), "CS_Fixed");
doc.features[cs_fixed].coordsys_body = 0;
REQUIRE(doc.recompute());
REQUIRE(doc.error.empty());
int sk_cyl = doc.add_sketch(SketchShape::Circle, SketchPlane::XY(), 0, 0, 3, "Cyl");
doc.add_extrude(sk_cyl, 10.0, false, BooleanMode::New, "CylExt");
REQUIRE(doc.recompute());
REQUIRE(doc.error.empty());
REQUIRE(doc.bodies.size() == 2);
int cs_moving = doc.add_coordsys(CoordSysType::PointWorld, Vec3d(0, 0, 5), "CS_Moving");
doc.features[cs_moving].coordsys_body = 1;
REQUIRE(doc.recompute());
REQUIRE(doc.error.empty());
int mi = doc.add_mate(0, cs_fixed, cs_moving, 0.0, 0.0, false, "Mate");
REQUIRE(mi >= 0);
REQUIRE(doc.recompute());
REQUIRE(doc.error.empty());
GProp_GProps props;
BRepGProp::VolumeProperties(doc.bodies[1].shape, props);
gp_Pnt com = props.CentreOfMass();
REQUIRE_THAT(double(com.X()), WithinAbs(5.0, 1e-4));
REQUIRE_THAT(double(com.Y()), WithinAbs(5.0, 1e-4));
REQUIRE_THAT(double(com.Z()), WithinAbs(5.0, 1e-4));
}
TEST_CASE("fastened mate with offset", "[CadDocument][mate]")
{
using Catch::Matchers::WithinAbs;
CadDocument doc;
int sk = doc.add_sketch(SketchShape::Rectangle, SketchPlane::XY(), 10, 10, 0, "Box");
doc.add_extrude(sk, 5.0, false, BooleanMode::New, "E");
REQUIRE(doc.recompute());
REQUIRE(doc.error.empty());
int cs_fixed = doc.add_coordsys(CoordSysType::PointWorld, Vec3d(5, 5, 5), "CS_Fixed");
doc.features[cs_fixed].coordsys_body = 0;
REQUIRE(doc.recompute());
REQUIRE(doc.error.empty());
int sk2 = doc.add_sketch(SketchShape::Circle, SketchPlane::XY(), 0, 0, 3, "Cyl");
doc.add_extrude(sk2, 10.0, false, BooleanMode::New, "CylExt");
REQUIRE(doc.recompute());
REQUIRE(doc.error.empty());
int cs_moving = doc.add_coordsys(CoordSysType::PointWorld, Vec3d(0, 0, 5), "CS_Moving");
doc.features[cs_moving].coordsys_body = 1;
REQUIRE(doc.recompute());
REQUIRE(doc.error.empty());
doc.add_mate(0, cs_fixed, cs_moving, 7.0, 0.0, false, "Mate");
REQUIRE(doc.recompute());
REQUIRE(doc.error.empty());
GProp_GProps props;
BRepGProp::VolumeProperties(doc.bodies[1].shape, props);
gp_Pnt com = props.CentreOfMass();
REQUIRE_THAT(double(com.Z()), WithinAbs(12.0, 1e-4));
}
TEST_CASE("fastened mate with angle", "[CadDocument][mate]")
{
using Catch::Matchers::WithinAbs;
CadDocument doc;
int sk = doc.add_sketch(SketchShape::Rectangle, SketchPlane::XY(), 10, 10, 0, "Box");
doc.add_extrude(sk, 5.0, false, BooleanMode::New, "E");
REQUIRE(doc.recompute());
REQUIRE(doc.error.empty());
int cs_fixed = doc.add_coordsys(CoordSysType::PointWorld, Vec3d(5, 5, 5), "CS_Fixed");
doc.features[cs_fixed].coordsys_body = 0;
REQUIRE(doc.recompute());
REQUIRE(doc.error.empty());
int sk2 = doc.add_sketch(SketchShape::Circle, SketchPlane::XY(), 0, 0, 3, "Cyl");
doc.add_extrude(sk2, 10.0, false, BooleanMode::New, "CylExt");
REQUIRE(doc.recompute());
REQUIRE(doc.error.empty());
int cs_moving = doc.add_coordsys(CoordSysType::PointWorld, Vec3d(0, 0, 5), "CS_Moving");
doc.features[cs_moving].coordsys_x_hint = Vec3d(1, 0, 0);
doc.features[cs_moving].coordsys_body = 1;
REQUIRE(doc.recompute());
REQUIRE(doc.error.empty());
doc.add_mate(0, cs_fixed, cs_moving, 0.0, 90.0, false, "Mate");
REQUIRE(doc.recompute());
REQUIRE(doc.error.empty());
GProp_GProps props;
BRepGProp::VolumeProperties(doc.bodies[1].shape, props);
gp_Pnt com = props.CentreOfMass();
REQUIRE_THAT(double(com.X()), WithinAbs(5.0, 1e-4));
REQUIRE_THAT(double(com.Y()), WithinAbs(5.0, 1e-4));
}
TEST_CASE("fastened mate with flip", "[CadDocument][mate]")
{
using Catch::Matchers::WithinAbs;
CadDocument doc;
int sk = doc.add_sketch(SketchShape::Rectangle, SketchPlane::XY(), 10, 10, 0, "Box");
doc.add_extrude(sk, 5.0, false, BooleanMode::New, "E");
REQUIRE(doc.recompute());
REQUIRE(doc.error.empty());
int cs_fixed = doc.add_coordsys(CoordSysType::PointWorld, Vec3d(5, 5, 5), "CS_Fixed");
doc.features[cs_fixed].coordsys_body = 0;
REQUIRE(doc.recompute());
REQUIRE(doc.error.empty());
int sk2 = doc.add_sketch(SketchShape::Circle, SketchPlane::XY(), 0, 0, 3, "Cyl");
doc.add_extrude(sk2, 10.0, false, BooleanMode::New, "CylExt");
REQUIRE(doc.recompute());
REQUIRE(doc.error.empty());
int cs_moving = doc.add_coordsys(CoordSysType::PointWorld, Vec3d(0, 0, 5), "CS_Moving");
doc.features[cs_moving].coordsys_body = 1;
REQUIRE(doc.recompute());
REQUIRE(doc.error.empty());
doc.add_mate(0, cs_fixed, cs_moving, 0.0, 0.0, true, "Mate");
REQUIRE(doc.recompute());
REQUIRE(doc.error.empty());
GProp_GProps props;
BRepGProp::VolumeProperties(doc.bodies[1].shape, props);
gp_Pnt com = props.CentreOfMass();
REQUIRE_THAT(double(com.X()), WithinAbs(5.0, 1e-4));
REQUIRE_THAT(double(com.Y()), WithinAbs(5.0, 1e-4));
// Flip opposes Z axes: for a symmetric cylinder this is invisible in centroid,
// but the mate executed cleanly and the body moved to the target connector.
}
TEST_CASE("planar mate: normal distance becomes mate_offset", "[CadDocument][mate]")
{
using Catch::Matchers::WithinAbs;
CadDocument doc;
int sk = doc.add_sketch(SketchShape::Rectangle, SketchPlane::XY(), 10, 10, 0, "Box");
doc.add_extrude(sk, 5.0, false, BooleanMode::New, "E");
REQUIRE(doc.recompute());
REQUIRE(doc.error.empty());
int cs_fixed = doc.add_coordsys(CoordSysType::PointWorld, Vec3d(5, 5, 5), "CS_Fixed");
doc.features[cs_fixed].coordsys_body = 0;
REQUIRE(doc.recompute());
REQUIRE(doc.error.empty());
int sk2 = doc.add_sketch(SketchShape::Circle, SketchPlane::XY(), 0, 0, 3, "Cyl");
doc.add_extrude(sk2, 10.0, false, BooleanMode::New, "CylExt");
REQUIRE(doc.recompute());
REQUIRE(doc.error.empty());
int cs_moving = doc.add_coordsys(CoordSysType::PointWorld, Vec3d(20, 0, 0), "CS_Moving");
doc.features[cs_moving].coordsys_body = 1;
REQUIRE(doc.recompute());
REQUIRE(doc.error.empty());
doc.add_mate(1, cs_fixed, cs_moving, 3.0, 0.0, false, "MatePlanar");
REQUIRE(doc.recompute());
REQUIRE(doc.error.empty());
GProp_GProps props;
BRepGProp::VolumeProperties(doc.bodies[1].shape, props);
gp_Pnt com = props.CentreOfMass();
// Connector B was at z=0 (bottom of 10mm cylinder). After planar mate with
// offset=3 and A at z=5, the z-distance from A to B becomes 3 => B.z = 8.
// The cylinder centroid (was at z=5) moves to z=13.
REQUIRE_THAT(double(com.Z()), WithinAbs(13.0, 1e-4));
}
TEST_CASE("mate round-trip serialization", "[CadDocument][mate]")
{
using Catch::Matchers::WithinAbs;
CadDocument doc;
int sk = doc.add_sketch(SketchShape::Rectangle, SketchPlane::XY(), 10, 10, 0, "Box");
doc.add_extrude(sk, 5.0, false, BooleanMode::New, "E");
REQUIRE(doc.recompute());
REQUIRE(doc.error.empty());
int cs_a = doc.add_coordsys(CoordSysType::PointWorld, Vec3d(5, 5, 5), "CS_A");
doc.features[cs_a].coordsys_body = 0;
REQUIRE(doc.recompute());
REQUIRE(doc.error.empty());
int sk2 = doc.add_sketch(SketchShape::Circle, SketchPlane::XY(), 0, 0, 3, "Cyl");
doc.add_extrude(sk2, 10.0, false, BooleanMode::New, "CylExt");
REQUIRE(doc.recompute());
REQUIRE(doc.error.empty());
int cs_b = doc.add_coordsys(CoordSysType::PointWorld, Vec3d(20, 0, 5), "CS_B");
doc.features[cs_b].coordsys_body = 1;
REQUIRE(doc.recompute());
REQUIRE(doc.error.empty());
doc.add_mate(1, cs_a, cs_b, 7.25, 33.0, true, "Mate");
REQUIRE(doc.recompute());
REQUIRE(doc.error.empty());
size_t nb = doc.bodies.size();
auto blob = doc.serialize_recipe();
REQUIRE_FALSE(blob.empty());
CadDocument fresh;
REQUIRE(fresh.deserialize_recipe(blob));
REQUIRE(fresh.bodies.size() == nb);
const CadFeature* mf = nullptr;
for (const auto& f : fresh.features) {
if (f.type == CadFeatureType::Mate) { mf = &f; break; }
}
REQUIRE(mf != nullptr);
REQUIRE(mf->mate_kind == 1);
REQUIRE_THAT(mf->mate_offset, WithinAbs(7.25, 1e-9));
REQUIRE_THAT(mf->mate_angle, WithinAbs(33.0, 1e-9));
REQUIRE(mf->mate_flip == true);
}
TEST_CASE("version 2 blob is rejected", "[CadDocument][mate]")
{
using Catch::Matchers::Contains;
CadDocument doc;
int sk = doc.add_sketch(SketchShape::Rectangle, SketchPlane::XY(), 10, 10, 0, "Box");
doc.add_extrude(sk, 5.0, false, BooleanMode::New, "E");
REQUIRE(doc.recompute());
REQUIRE(doc.error.empty());
std::ostringstream oss;
{
cereal::BinaryOutputArchive ar(oss);
uint32_t fake_v = 2;
ar(fake_v);
ar(doc.features);
ar(doc.variables);
}
std::string blob = oss.str();
CadDocument fresh;
REQUIRE_FALSE(fresh.deserialize_recipe(blob));
REQUIRE_THAT(fresh.error, Catch::Matchers::Contains("older version"));
}
TEST_CASE("mate error: out of range connectors", "[CadDocument][mate]")
{
using Catch::Matchers::Contains;
CadDocument doc;
int sk = doc.add_sketch(SketchShape::Rectangle, SketchPlane::XY(), 10, 10, 0, "Box");
doc.add_extrude(sk, 5.0, false, BooleanMode::New, "E");
REQUIRE(doc.recompute());
REQUIRE(doc.error.empty());
int cs = doc.add_coordsys(CoordSysType::PointWorld, Vec3d(5, 5, 5), "CS");
doc.features[cs].coordsys_body = 0;
REQUIRE(doc.recompute());
REQUIRE(doc.error.empty());
doc.add_mate(0, 999, cs, 0, 0, false, "Bad");
REQUIRE_FALSE(doc.recompute());
REQUIRE_THAT(doc.error, Catch::Matchers::Contains("mate_cs_a out of range"));
doc.features.pop_back(); doc.error.clear();
doc.add_mate(0, cs, 999, 0, 0, false, "Bad");
REQUIRE_FALSE(doc.recompute());
REQUIRE_THAT(doc.error, Catch::Matchers::Contains("mate_cs_b out of range"));
doc.features.pop_back(); doc.error.clear();
doc.add_mate(0, sk, cs, 0, 0, false, "Bad");
REQUIRE_FALSE(doc.recompute());
REQUIRE_THAT(doc.error, Catch::Matchers::Contains("not a valid CoordSys"));
doc.features.pop_back(); doc.error.clear();
}
TEST_CASE("mate error: no associated body", "[CadDocument][mate]")
{
using Catch::Matchers::Contains;
CadDocument doc;
int sk = doc.add_sketch(SketchShape::Rectangle, SketchPlane::XY(), 10, 10, 0, "Box");
doc.add_extrude(sk, 5.0, false, BooleanMode::New, "E");
REQUIRE(doc.recompute());
REQUIRE(doc.error.empty());
int cs_fixed = doc.add_coordsys(CoordSysType::PointWorld, Vec3d(5, 5, 5), "CS_Fixed");
doc.features[cs_fixed].coordsys_body = 0;
REQUIRE(doc.recompute());
REQUIRE(doc.error.empty());
int cs_moving = doc.add_coordsys(CoordSysType::PointWorld, Vec3d(0, 0, 5), "CS_Moving");
REQUIRE(doc.recompute());
REQUIRE(doc.error.empty());
doc.add_mate(0, cs_fixed, cs_moving, 0, 0, false, "Bad");
REQUIRE_FALSE(doc.recompute());
REQUIRE_THAT(doc.error, Catch::Matchers::Contains("no associated body"));
}
TEST_CASE("mate error: disabled connector", "[CadDocument][mate]")
{
using Catch::Matchers::Contains;
CadDocument doc;
int sk = doc.add_sketch(SketchShape::Rectangle, SketchPlane::XY(), 10, 10, 0, "Box");
doc.add_extrude(sk, 5.0, false, BooleanMode::New, "E");
REQUIRE(doc.recompute());
REQUIRE(doc.error.empty());
int cs_fixed = doc.add_coordsys(CoordSysType::PointWorld, Vec3d(5, 5, 5), "CS_Fixed");
doc.features[cs_fixed].coordsys_body = 0;
int cs_moving = doc.add_coordsys(CoordSysType::PointWorld, Vec3d(0, 0, 5), "CS_Moving");
doc.features[cs_moving].coordsys_body = 0;
REQUIRE(doc.recompute());
REQUIRE(doc.error.empty());
doc.features[cs_moving].enabled = false;
doc.add_mate(0, cs_fixed, cs_moving, 0, 0, false, "Bad");
REQUIRE_FALSE(doc.recompute());
REQUIRE_THAT(doc.error, Catch::Matchers::Contains("not a valid CoordSys"));
}
TEST_CASE("ordering: fillet after mate resolves face ids", "[CadDocument][mate]")
{
using Catch::Matchers::WithinAbs;
CadDocument doc;
int sk_a = doc.add_sketch(SketchShape::Rectangle, SketchPlane::XY(), 10, 10, 0, "BoxA");
doc.add_extrude(sk_a, 5.0, false, BooleanMode::New, "EA");
REQUIRE(doc.recompute());
REQUIRE(doc.error.empty());
int sk_b = doc.add_sketch(SketchShape::Rectangle, SketchPlane::XY(), 10, 10, 0, "BoxB");
doc.add_extrude(sk_b, 5.0, false, BooleanMode::New, "EB");
REQUIRE(doc.recompute());
REQUIRE(doc.error.empty());
REQUIRE(doc.bodies.size() == 2);
int cs_fixed = doc.add_coordsys(CoordSysType::PointWorld, Vec3d(5, 5, 5), "CS_Fixed");
doc.features[cs_fixed].coordsys_body = 0;
REQUIRE(doc.recompute());
REQUIRE(doc.error.empty());
int cs_moving = doc.add_coordsys(CoordSysType::PointWorld, Vec3d(5, 5, 5), "CS_Moving");
doc.features[cs_moving].coordsys_body = 1;
REQUIRE(doc.recompute());
REQUIRE(doc.error.empty());
doc.add_mate(0, cs_fixed, cs_moving, 0.0, 0.0, false, "Mate");
REQUIRE(doc.recompute());
REQUIRE(doc.error.empty());
doc.add_fillet(1.0, FaceGroup::All, "FilletAfterMate");
doc.features.back().target_body = 1;
REQUIRE(doc.recompute());
REQUIRE(doc.error.empty());
}
// --- M8a fix round: planar antiparallel + blind tests ---
static int find_face_by_normal(const CadDocument& doc, int body_idx, const Vec3d& dir, double tol = 0.99)
{
auto faces = GeometryEngine::faces_of(doc.bodies[body_idx].shape);
for (int fi = 0; fi < int(faces.size()); ++fi) {
if (GeometryEngine::face_normal_world(faces[fi]).dot(dir) > tol) return fi;
}
return -1;
}
TEST_CASE("planar mate: antiparallel normals with asymmetric body", "[CadDocument][mate]")
{
using Catch::Matchers::WithinAbs;
CadDocument doc;
// Body A: box 20x20x5, centered at origin in XY
int sk_a = doc.add_sketch(SketchShape::Rectangle, SketchPlane::XY(), 20, 20, 0, "BoxA");
doc.add_extrude(sk_a, 5.0, false, BooleanMode::New, "EA");
REQUIRE(doc.recompute());
REQUIRE(doc.error.empty());
// Body B: box 20x10x5, also centered at origin (asymmetric in Y)
int sk_b = doc.add_sketch(SketchShape::Rectangle, SketchPlane::XY(), 20, 10, 0, "BoxB");
doc.add_extrude(sk_b, 5.0, false, BooleanMode::New, "EB");
REQUIRE(doc.recompute());
REQUIRE(doc.error.empty());
REQUIRE(doc.bodies.size() == 2);
// Face on A with normal +X, face on B with normal -X
int faceA = find_face_by_normal(doc, 0, Vec3d(1, 0, 0));
REQUIRE(faceA >= 0);
int faceB = find_face_by_normal(doc, 1, Vec3d(-1, 0, 0));
REQUIRE(faceB >= 0);
// Verify z_A != z_B (they point opposite)
Vec3d nA_pre = GeometryEngine::face_normal_world(
GeometryEngine::face_by_index(doc.bodies[0].shape, faceA));
Vec3d nB_pre = GeometryEngine::face_normal_world(
GeometryEngine::face_by_index(doc.bodies[1].shape, faceB));
REQUIRE(nA_pre.dot(nB_pre) < -0.9); // antiparallel
int cs_fixed = doc.add_coordsys(CoordSysType::FaceAndDirection, Vec3d(0,0,0), "CS_Fixed");
doc.features[cs_fixed].coordsys_body = 0;
doc.features[cs_fixed].coordsys_face = faceA;
REQUIRE(doc.recompute());
REQUIRE(doc.error.empty());
int cs_moving = doc.add_coordsys(CoordSysType::FaceAndDirection, Vec3d(0,0,0), "CS_Moving");
doc.features[cs_moving].coordsys_body = 1;
doc.features[cs_moving].coordsys_face = faceB;
REQUIRE(doc.recompute());
REQUIRE(doc.error.empty());
// Planar mate: z_A=+X, z_B=-X antiparallel. offset=0.
// With fix: 180° flip, then translated so z-distance=0.
// Without fix: no rotation, only translation — body X-centroid moves differently.
doc.add_mate(1, cs_fixed, cs_moving, 0.0, 0.0, false, "PlanarAnti");
REQUIRE(doc.recompute());
REQUIRE(doc.error.empty());
// With the fix: 180° rotation about faceB centroid, then translation.
// Body B's X-extents are mirrored. After rotation, centroid X = 2*o_B.x - pre_cx = -20,
// then translated by offset ≈ 20mm → centroid returns near 0.
// Without the fix: no rotation, body just translates ~+20mm → centroid X ≈ 20.
GProp_GProps post_props;
BRepGProp::VolumeProperties(doc.bodies[1].shape, post_props);
double post_cx = post_props.CentreOfMass().X();
REQUIRE_THAT(std::abs(post_cx), WithinAbs(0.0, 1e-4));
}
TEST_CASE("planar mate: in-plane pose preserved", "[CadDocument][mate]")
{
using Catch::Matchers::WithinAbs;
CadDocument doc;
int sk_a = doc.add_sketch(SketchShape::Rectangle, SketchPlane::XY(), 20, 20, 0, "BoxA");
doc.add_extrude(sk_a, 5.0, false, BooleanMode::New, "EA");
REQUIRE(doc.recompute());
REQUIRE(doc.error.empty());
int sk_b = doc.add_sketch(SketchShape::Rectangle, SketchPlane::XY(), 10, 10, 0, "BoxB");
doc.add_extrude(sk_b, 10.0, false, BooleanMode::New, "EB");
REQUIRE(doc.recompute());
REQUIRE(doc.error.empty());
REQUIRE(doc.bodies.size() == 2);
GProp_GProps pre_props;
BRepGProp::VolumeProperties(doc.bodies[1].shape, pre_props);
gp_Pnt pre_com = pre_props.CentreOfMass();
// Both connectors use PointWorld (z=(0,0,1)). B's connector offset in X/Y from A's.
int cs_fixed = doc.add_coordsys(CoordSysType::PointWorld, Vec3d(10, 10, 5), "CS_Fixed");
doc.features[cs_fixed].coordsys_body = 0;
int cs_moving = doc.add_coordsys(CoordSysType::PointWorld, Vec3d(7, 4, 10), "CS_Moving");
doc.features[cs_moving].coordsys_body = 1;
REQUIRE(doc.recompute());
REQUIRE(doc.error.empty());
doc.add_mate(1, cs_fixed, cs_moving, 0.0, 0.0, false, "PlanarSameZ");
REQUIRE(doc.recompute());
REQUIRE(doc.error.empty());
GProp_GProps post_props;
BRepGProp::VolumeProperties(doc.bodies[1].shape, post_props);
gp_Pnt post_com = post_props.CentreOfMass();
// In-plane (X,Y) components unchanged, only Z moves.
REQUIRE_THAT(double(post_com.X()), WithinAbs(double(pre_com.X()), 1e-4));
REQUIRE_THAT(double(post_com.Y()), WithinAbs(double(pre_com.Y()), 1e-4));
REQUIRE_THAT(double(post_com.Z()), !WithinAbs(double(pre_com.Z()), 1e-4));
}
TEST_CASE("mate with FaceAndDirection connectors on non-Z faces", "[CadDocument][mate]")
{
using Catch::Matchers::WithinAbs;
CadDocument doc;
// Body A: box 20x20x10
int sk_a = doc.add_sketch(SketchShape::Rectangle, SketchPlane::XY(), 20, 20, 0, "BoxA");
doc.add_extrude(sk_a, 10.0, false, BooleanMode::New, "EA");
REQUIRE(doc.recompute());
REQUIRE(doc.error.empty());
// Face on A with normal +Y
int faceA = find_face_by_normal(doc, 0, Vec3d(0, 1, 0));
REQUIRE(faceA >= 0);
int cs_fixed = doc.add_coordsys(CoordSysType::FaceAndDirection, Vec3d(0,0,0), "CS_Fixed");
doc.features[cs_fixed].coordsys_body = 0;
doc.features[cs_fixed].coordsys_face = faceA;
REQUIRE(doc.recompute());
REQUIRE(doc.error.empty());
// Body B: box placed at a different location
int sk_b = doc.add_sketch(SketchShape::Rectangle, SketchPlane::XY(), 10, 10, 0, "BoxB");
doc.add_extrude(sk_b, 5.0, false, BooleanMode::New, "EB");
REQUIRE(doc.recompute());
REQUIRE(doc.error.empty());
// Face on B with normal +Y
int faceB = find_face_by_normal(doc, 1, Vec3d(0, 1, 0));
REQUIRE(faceB >= 0);
int cs_moving = doc.add_coordsys(CoordSysType::FaceAndDirection, Vec3d(0,0,0), "CS_Moving");
doc.features[cs_moving].coordsys_body = 1;
doc.features[cs_moving].coordsys_face = faceB;
REQUIRE(doc.recompute());
REQUIRE(doc.error.empty());
// Fastened mate: B's +Y face lands on A's +Y face, offset=0.
// Both normals are +Y so z_A = z_B = (0,1,0) — genuine rotation from frame composition.
doc.add_mate(0, cs_fixed, cs_moving, 0.0, 0.0, false, "MateY");
REQUIRE(doc.recompute());
REQUIRE(doc.error.empty());
// After fastened mate, the two mated faces should be coincident:
// same centroid position along the normal (Y), and same centroid in X and Z
// (within face dimensions since they're different sizes).
Vec3d ca = GeometryEngine::face_centroid_world(GeometryEngine::face_by_index(doc.bodies[0].shape, faceA));
Vec3d cb = GeometryEngine::face_centroid_world(GeometryEngine::face_by_index(doc.bodies[1].shape, faceB));
REQUIRE_THAT(cb.x(), WithinAbs(ca.x(), 1e-4));
REQUIRE_THAT(cb.y(), WithinAbs(ca.y(), 1e-4));
REQUIRE_THAT(cb.z(), WithinAbs(ca.z(), 1e-4));
}
TEST_CASE("fastened mate with flip on asymmetric body", "[CadDocument][mate]")
{
using Catch::Matchers::WithinAbs;
CadDocument doc;
// Body A: box
int sk_a = doc.add_sketch(SketchShape::Rectangle, SketchPlane::XY(), 20, 20, 0, "BoxA");
doc.add_extrude(sk_a, 10.0, false, BooleanMode::New, "EA");
REQUIRE(doc.recompute());
REQUIRE(doc.error.empty());
// Body B: tapered extrude (asymmetric, centroid not at geometric centre)
int sk_b = doc.add_sketch(SketchShape::Rectangle, SketchPlane::XY(), 10, 10, 0, "BoxB");
int ex_b = doc.add_extrude(sk_b, 8.0, false, BooleanMode::New, "EB");
doc.features[ex_b].taper_deg = 8.0;
REQUIRE(doc.recompute());
REQUIRE(doc.error.empty());
// PointWorld connectors at distinct positions.
// A's connector on the top face centre, B's connector at a corner.
int cs_fixed = doc.add_coordsys(CoordSysType::PointWorld, Vec3d(10, 10, 10), "CS_Fixed");
doc.features[cs_fixed].coordsys_body = 0;
int cs_moving = doc.add_coordsys(CoordSysType::PointWorld, Vec3d(2, 3, 0), "CS_Moving");
doc.features[cs_moving].coordsys_body = 1;
REQUIRE(doc.recompute());
REQUIRE(doc.error.empty());
// Fastened, NO flip
doc.add_mate(0, cs_fixed, cs_moving, 0.0, 0.0, false, "MateNoFlip");
REQUIRE(doc.recompute());
REQUIRE(doc.error.empty());
GProp_GProps nf_props;
BRepGProp::VolumeProperties(doc.bodies[1].shape, nf_props);
double nf_z = nf_props.CentreOfMass().Z();
doc.undo();
// Fastened, WITH flip
doc.add_mate(0, cs_fixed, cs_moving, 0.0, 0.0, true, "MateFlip");
REQUIRE(doc.recompute());
REQUIRE(doc.error.empty());
GProp_GProps f_props;
BRepGProp::VolumeProperties(doc.bodies[1].shape, f_props);
double f_z = f_props.CentreOfMass().Z();
// Flip changes the centroid Z for an asymmetric body
REQUIRE_THAT(f_z, !WithinAbs(nf_z, 1e-4));
}