From b13ca01cccdb8e913bce43a735ae6789a428818a Mon Sep 17 00:00:00 2001 From: Tommaso Bianchi Date: Sat, 25 Jul 2026 10:51:12 +0200 Subject: [PATCH] =?UTF-8?q?M8a:=20assembly=20mates=20=E2=80=94=20Fastened?= =?UTF-8?q?=20+=20Planar=20(recipe=20v3)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- src/libslic3r/CadDocument.cpp | 207 +++++++-- src/libslic3r/CadDocument.hpp | 26 +- src/slic3r/GUI/McpControl.cpp | 35 ++ tests/data/cad_recipe_v2.bin | Bin 30773 -> 0 bytes tests/data/cad_recipe_v3.bin | Bin 0 -> 34656 bytes tests/libslic3r/test_caddocument.cpp | 617 ++++++++++++++++++++++++++- 6 files changed, 838 insertions(+), 47 deletions(-) delete mode 100644 tests/data/cad_recipe_v2.bin create mode 100644 tests/data/cad_recipe_v3.bin diff --git a/src/libslic3r/CadDocument.cpp b/src/libslic3r/CadDocument.cpp index 4b543661ea..86a4578188 100644 --- a/src/libslic3r/CadDocument.cpp +++ b/src/libslic3r/CadDocument.cpp @@ -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::resolve_datum_axes() const return out; } -std::vector CadDocument::resolve_datum_coordsys() const +CadDocument::DatumCoordSys CadDocument::datum_frame(const std::vector& bodies, const CadFeature& f) { - std::vector 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::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::resolve_datum_coordsys() const +{ + std::vector 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& bodies, CadFeature& if (f.entities.empty()) throw std::runtime_error("project: produced no entities"); } +void CadDocument::apply_mate(std::vector& 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& 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& 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; } diff --git a/src/libslic3r/CadDocument.hpp b/src/libslic3r/CadDocument.hpp index f7854a2c1f..20aa70a616 100644 --- a/src/libslic3r/CadDocument.hpp +++ b/src/libslic3r/CadDocument.hpp @@ -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 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 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& bodies, const CadFeature& f) const; void apply_surface_offset(std::vector& bodies, const CadFeature& f) const; void apply_project(const std::vector& bodies, CadFeature& f) const; + static DatumCoordSys datum_frame(const std::vector& bodies, const CadFeature& f); + void apply_mate(std::vector& 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 diff --git a/src/slic3r/GUI/McpControl.cpp b/src/slic3r/GUI/McpControl.cpp index ac7c517866..f71719e70e 100644 --- a/src/slic3r/GUI/McpControl.cpp +++ b/src/slic3r/GUI/McpControl.cpp @@ -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(); + const int cs_b = params["cs_b"].get(); + 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")); diff --git a/tests/data/cad_recipe_v2.bin b/tests/data/cad_recipe_v2.bin deleted file mode 100644 index 58f5b5fcc89470f8edefc6af4ce6a6da3b6f54e0..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 30773 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 literal 0 HcmV?d00001 diff --git a/tests/libslic3r/test_caddocument.cpp b/tests/libslic3r/test_caddocument.cpp index 80e945afc0..e8850cbcd3 100644 --- a/tests/libslic3r/test_caddocument.cpp +++ b/tests/libslic3r/test_caddocument.cpp @@ -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(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(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)); +} \ No newline at end of file