diff --git a/src/libslic3r/CadDocument.cpp b/src/libslic3r/CadDocument.cpp index 214dd3797b..2d7cfebf1e 100644 --- a/src/libslic3r/CadDocument.cpp +++ b/src/libslic3r/CadDocument.cpp @@ -699,6 +699,27 @@ int CadDocument::add_plane(int base, double offset, double angle_tilt, int axis, return int(features.size()) - 1; } +int CadDocument::add_axis(AxisType axis_type_, const std::string& name) +{ + CadFeature f; + f.type = CadFeatureType::Axis; + f.name = name; + f.axis_type = axis_type_; + features.push_back(f); + return int(features.size()) - 1; +} + +int CadDocument::add_coordsys(CoordSysType type, const Vec3d& point, const std::string& name) +{ + CadFeature f; + f.type = CadFeatureType::CoordSys; + f.name = name; + f.coordsys_type = type; + f.coordsys_point = point; + features.push_back(f); + return int(features.size()) - 1; +} + // Derive a SketchPlane: shift `base` along its normal by `offset`, then tilt // `angle_deg` about the base's X (axis 0) or Y (axis 1) axis (Rodrigues rotation). static SketchPlane offset_angle_plane(const SketchPlane& base, double offset, @@ -895,6 +916,190 @@ std::vector> CadDocument::resolve_datum_plan return out; } +// Resolved datum axes in feature order. Origin + unit direction computed from +// construction params; axis_err is non-empty when construction fails (no crash). +std::vector CadDocument::resolve_datum_axes() const +{ + std::vector out; + // For PlaneIntersection we need the already-resolved datum planes. + std::vector> datum_planes = resolve_datum_planes(); + + auto resolve_face = [&](int body_idx, int face_idx) -> TopoDS_Face { + if (face_idx < 0 || body_idx < 0 || body_idx >= int(bodies.size())) + return TopoDS_Face(); + return GeometryEngine::face_by_index(bodies[body_idx].shape, face_idx); + }; + auto resolve_edge = [&](int body_idx, int edge_idx, + Vec3d& p0, Vec3d& dir) -> bool { + if (edge_idx < 0 || body_idx < 0 || body_idx >= int(bodies.size())) + return false; + TopoDS_Edge e = GeometryEngine::edge_by_index(bodies[body_idx].shape, edge_idx); + if (e.IsNull()) return false; + auto pts = GeometryEngine::sample_edge_world(e); + if (pts.size() < 2) return false; + p0 = pts.front(); + dir = (pts.back() - pts.front()).normalized(); + return true; + }; + + for (const CadFeature& f : features) { + if (f.type != CadFeatureType::Axis || !f.enabled) continue; + + DatumAxis da; + da.name = f.name; + switch (f.axis_type) { + case AxisType::TwoPoints: { + Vec3d dir = f.axis_p2 - f.axis_p1; + if (dir.squaredNorm() < 1e-18) { da.error = "two points are coincident"; break; } + da.origin = f.axis_p1; + da.direction = dir.normalized(); + break; + } + case AxisType::FaceNormal: { + TopoDS_Face fc = resolve_face(f.axis_body, f.axis_face); + if (fc.IsNull()) { da.error = "face not found"; break; } + da.origin = GeometryEngine::face_centroid_world(fc); + da.direction = GeometryEngine::face_normal_world(fc); + break; + } + case AxisType::CylinderCenterline: { + TopoDS_Face fc = resolve_face(f.axis_body, f.axis_face); + if (fc.IsNull()) { da.error = "face not found"; break; } + GeometryEngine::CylinderFace cyl = GeometryEngine::cylinder_of_face(fc); + if (!cyl.ok) { da.error = "face is not a cylinder"; break; } + da.origin = cyl.base; + da.direction = cyl.axis; + break; + } + case AxisType::AlongEdge: { + Vec3d p0, dir; + if (!resolve_edge(f.axis_body, f.axis_edge, p0, dir)) { + da.error = "edge not found"; break; + } + da.origin = p0; + da.direction = dir; + break; + } + case AxisType::PlaneIntersection: { + auto find_plane = [&](int ref) -> const SketchPlane* { + if (ref >= 0 && ref < int(datum_planes.size())) + return &datum_planes[ref].second; + if (ref >= 3) { // base plane offset: 0=XY,1=XZ,2=YZ + da.error = "plane ref index out of range (datum planes not found)"; + return nullptr; + } + return nullptr; + }; + // For base planes we handle directly. + auto base_plane = [&](int ref, Vec3d& origin, Vec3d& normal) -> bool { + if (ref >= 0 && ref < int(datum_planes.size())) { + origin = datum_planes[ref].second.origin; + normal = datum_planes[ref].second.normal; + return true; + } + return false; + }; + // Both refs reference datum plane indices in the resolved list. + // Supporting cross-base-plane where ref < 0 isn't in scope. + bool ok0 = base_plane(f.axis_plane_a, da.origin, da.direction); // direction reused as normal0 + Vec3d origin1, normal1; + bool ok1 = base_plane(f.axis_plane_b, origin1, normal1); + if (!ok0 || !ok1) { da.error = "plane ref not found"; break; } + // Direction = cross product of the two plane normals. + Vec3d dir = da.direction.cross(normal1); // da.direction was normal0 + if (dir.squaredNorm() < 1e-18) { + da.error = "planes are parallel (no intersection)"; break; + } + dir.normalize(); + // Find a point on the intersection line: closest points between two planes. + // Project origin of plane A onto the intersection line. + Vec3d n0 = da.direction; // normal of plane A (stored temporarily) + Vec3d n1 = normal1; + Vec3d p0 = da.origin; + Vec3d p1 = origin1; + // Solve for point on line of intersection using vector formula. + double d0 = n0.dot(p0); + double d1 = n1.dot(p1); + double n0n1 = n0.dot(n1); + double det = 1.0 - n0n1 * n0n1; + if (std::abs(det) < 1e-18) { da.error = "planes are parallel (no intersection)"; break; } + double t0 = (d0 - d1 * n0n1) / det; + double t1 = (d1 - d0 * n0n1) / det; + da.origin = n0 * t0 + n1 * t1; + da.direction = dir; + break; + } + } + out.push_back(da); + } + return out; +} + +std::vector CadDocument::resolve_datum_coordsys() const +{ + std::vector out; + + auto resolve_face = [&](int body_idx, int face_idx) -> TopoDS_Face { + if (face_idx < 0 || body_idx < 0 || body_idx >= int(bodies.size())) + return TopoDS_Face(); + return GeometryEngine::face_by_index(bodies[body_idx].shape, face_idx); + }; + auto resolve_edge = [&](int body_idx, int edge_idx, + Vec3d& p0, Vec3d& dir) -> bool { + if (edge_idx < 0 || body_idx < 0 || body_idx >= int(bodies.size())) + return false; + TopoDS_Edge e = GeometryEngine::edge_by_index(bodies[body_idx].shape, edge_idx); + if (e.IsNull()) return false; + auto pts = GeometryEngine::sample_edge_world(e); + if (pts.size() < 2) return false; + p0 = pts.front(); + dir = (pts.back() - pts.front()).normalized(); + return true; + }; + + 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); + } + return out; +} + void CadDocument::clear() { features.clear(); @@ -1689,7 +1894,9 @@ void CadDocument::apply_mirror(std::vector& bodies, const CadFeature& f 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 + if (f.type == CadFeatureType::Plane) return; // datum plane: not part of the body pipeline + if (f.type == CadFeatureType::Axis) return; // datum axis + if (f.type == CadFeatureType::CoordSys) return; // datum coordinate system if (f.type == CadFeatureType::Boolean) { apply_boolean(bodies, f); return; } // body-body op 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 @@ -1728,7 +1935,9 @@ bool CadDocument::recompute() for (const CadFeature& f : features) { if (!f.enabled) continue; if (f.type == CadFeatureType::Sketch) continue; // consumed by an extrude - if (f.type == CadFeatureType::Plane) continue; // datum: no solid, derived on demand + 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 route_feature(built, f); } } catch (const Standard_Failure& e) { diff --git a/src/libslic3r/CadDocument.hpp b/src/libslic3r/CadDocument.hpp index 498b00d634..b7462ca6a3 100644 --- a/src/libslic3r/CadDocument.hpp +++ b/src/libslic3r/CadDocument.hpp @@ -17,9 +17,11 @@ namespace Slic3r { -enum class CadFeatureType { Sketch, Extrude, Fillet, Chamfer, Hole, Thread, Shell, Revolve, Sweep, Pattern, Plane, Loft, Draft, Import, Boolean, Cut, Mirror }; +enum class CadFeatureType { Sketch, Extrude, Fillet, Chamfer, Hole, Thread, Shell, Revolve, Sweep, Pattern, Plane, Loft, Draft, Import, Boolean, Cut, Mirror, Axis, CoordSys }; enum class SketchShape { Rectangle, Circle }; enum class PlaneType { Offset, Angle, Midplane, Tangent, TwoEdges, Coincident }; +enum class AxisType { TwoPoints, FaceNormal, CylinderCenterline, PlaneIntersection, AlongEdge }; +enum class CoordSysType { PointWorld, FaceAndDirection }; enum class BooleanMode { New, Add, Cut, Intersect }; enum class ExtrudeEnd { Blind, Symmetric, TwoSided, ThroughAll, UpToFace, UpToVertex }; @@ -208,6 +210,25 @@ struct CadFeature { // the source body survives when mode is New. bool mirror_keep_original{true}; + // Datum axis: reference line (no solid). Construction params stored; resolve_datum_axes() + // computes the world-space origin + unit direction on demand. + AxisType axis_type{AxisType::TwoPoints}; + Vec3d axis_p1{0, 0, 0}; + Vec3d axis_p2{0, 0, 10}; + int axis_body{-1}; + int axis_face{-1}; + int axis_edge{-1}; + int axis_plane_a{-1}; + int axis_plane_b{-1}; + + // Datum coordinate system (no solid). Stored as point + two orthonormal axes. + CoordSysType coordsys_type{CoordSysType::PointWorld}; + Vec3d coordsys_point{0, 0, 0}; + int coordsys_body{-1}; + int coordsys_face{-1}; + int coordsys_edge{-1}; + Vec3d coordsys_x_hint{1, 0, 0}; + template void save(Archive& ar) const { std::string brep = (type == CadFeatureType::Import) ? brep_to_string(imported_solid) : std::string(); @@ -230,7 +251,9 @@ struct CadFeature { brep, plane_type, plane_face_body, plane_face, plane_face2_body, plane_face2, plane_edge_body, plane_edge, plane_edge2_body, plane_edge2, plane_u_size, plane_v_size, - mirror_keep_original); + mirror_keep_original, + axis_type, axis_p1, axis_p2, axis_body, axis_face, axis_edge, axis_plane_a, axis_plane_b, + coordsys_type, coordsys_point, coordsys_body, coordsys_face, coordsys_edge, coordsys_x_hint); } template void load(Archive& ar) { @@ -254,7 +277,9 @@ struct CadFeature { brep, plane_type, plane_face_body, plane_face, plane_face2_body, plane_face2, plane_edge_body, plane_edge, plane_edge2_body, plane_edge2, plane_u_size, plane_v_size, - mirror_keep_original); + mirror_keep_original, + axis_type, axis_p1, axis_p2, axis_body, axis_face, axis_edge, axis_plane_a, axis_plane_b, + coordsys_type, coordsys_point, coordsys_body, coordsys_face, coordsys_edge, coordsys_x_hint); imported_solid = brep_from_string(brep); } }; @@ -360,9 +385,21 @@ public: // along its normal, optional tilt about a base axis. Produces no solid. int add_plane(int base, double offset, double angle_tilt, int axis, const std::string& name); + // Datum axis: construction method axis_type determines which ref fields are read. + 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); // Every datum plane currently in the recipe, in feature order, as (name, plane). // Used by the GUI to populate plane pickers (after the 3 base planes). std::vector> resolve_datum_planes() const; + // Resolved datum axes in feature order. axis_err is non-empty if construction failed. + struct DatumAxis { std::string name; Vec3d origin{0,0,0}; Vec3d direction{0,0,1}; + std::string error; }; + std::vector resolve_datum_axes() const; + // Resolved datum coordinate systems. X/Y unit, orthonormal (Z = X.cross(Y)). + struct DatumCoordSys { std::string name; Vec3d origin{0,0,0}; Vec3d x{1,0,0}; + Vec3d y{0,1,0}; std::string error; }; + std::vector resolve_datum_coordsys() const; void clear(); bool recompute(); // replay features -> body + display_mesh; false on error diff --git a/src/slic3r/GUI/McpControl.cpp b/src/slic3r/GUI/McpControl.cpp index 3ab59767e0..bfb67e2669 100644 --- a/src/slic3r/GUI/McpControl.cpp +++ b/src/slic3r/GUI/McpControl.cpp @@ -70,7 +70,9 @@ const char* feature_type_name(CadFeatureType t) case CadFeatureType::Draft: return "Draft"; case CadFeatureType::Import: return "Import"; case CadFeatureType::Boolean: return "Boolean"; - case CadFeatureType::Cut: return "Cut"; + case CadFeatureType::Cut: return "Cut"; + case CadFeatureType::Axis: return "Axis"; + case CadFeatureType::CoordSys: return "CoordSys"; } return "Unknown"; } @@ -184,6 +186,26 @@ json describe_tools() json{{"name", "keep_original"},{"type", "boolean"}, {"default", true}, {"description", "when mode=new, keep the source body"}}, json{{"name", "body"}, {"type", "integer"}, {"default", -1}, {"description", "target body; omit for the last body"}}, })}}, + json{{"name", "axis"}, {"summary", "Create a datum axis (reference line): two points, face normal, cylinder centreline, plane intersection, or along edge."}, + {"params", json::array({ + json{{"name", "type"}, {"type", "string"}, {"enum", json::array({"two_points", "face_normal", "cylinder", "plane_intersection", "along_edge"})}, {"default", "two_points"}}, + json{{"name", "p1"}, {"type", "array"}, {"default", json::array({0,0,0})}, {"description", "first point [x,y,z] for two_points"}}, + json{{"name", "p2"}, {"type", "array"}, {"default", json::array({0,0,10})}, {"description", "second point [x,y,z] for two_points"}}, + json{{"name", "body"}, {"type", "integer"}, {"default", -1}, {"description", "body for face/edge refs"}}, + json{{"name", "face"}, {"type", "integer"}, {"default", -1}, {"description", "face id (query_topology) for face_normal/cylinder"}}, + json{{"name", "edge"}, {"type", "integer"}, {"default", -1}, {"description", "edge id (query_topology) for along_edge"}}, + json{{"name", "plane_a"}, {"type", "integer"}, {"default", -1}, {"description", "first datum plane feature index for plane_intersection"}}, + json{{"name", "plane_b"}, {"type", "integer"}, {"default", -1}, {"description", "second datum plane feature index for plane_intersection"}}, + })}}, + json{{"name", "coordsys"}, {"summary", "Create a datum coordinate system (origin + orthonormal axes). PointWorld aligns to world; FaceAndDirection uses a face for Z and an edge/hint for X."}, + {"params", json::array({ + json{{"name", "type"}, {"type", "string"}, {"enum", json::array({"point_world", "face_and_direction"})}, {"default", "point_world"}}, + json{{"name", "point"}, {"type", "array"}, {"default", json::array({0,0,0})}, {"description", "origin [x,y,z] for point_world"}}, + json{{"name", "body"}, {"type", "integer"}, {"default", -1}, {"description", "body for face/edge refs"}}, + json{{"name", "face"}, {"type", "integer"}, {"default", -1}, {"description", "face id (query_topology) for Z axis"}}, + json{{"name", "edge"}, {"type", "integer"}, {"default", -1}, {"description", "edge id (query_topology) for X axis hint"}}, + json{{"name", "x_hint"}, {"type", "array"}, {"default", json::array({1,0,0})}, {"description", "fallback X direction hint if no edge given"}}, + })}}, 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}}, @@ -815,6 +837,63 @@ json action_mirror(DesignPanel* panel, const json& params) return json{{"ok", ok}, {"mirror_index", idx}, {"bodies", int(doc.bodies.size())}, {"error", doc.error}}; } +json action_axis(DesignPanel* panel, const json& params) +{ + CadDocument& doc = panel->mcp_doc(); + std::string t = params.value("type", std::string("two_points")); + AxisType at = AxisType::TwoPoints; + if (t == "face_normal") at = AxisType::FaceNormal; + else if (t == "cylinder") at = AxisType::CylinderCenterline; + else if (t == "plane_intersection") at = AxisType::PlaneIntersection; + else if (t == "along_edge") at = AxisType::AlongEdge; + doc.checkpoint(); + int idx = doc.add_axis(at, "Axis"); + CadFeature& f = doc.features[idx]; + if (params.contains("p1") && params["p1"].is_array() && params["p1"].size() >= 3) + f.axis_p1 = Vec3d(params["p1"][0].get(), params["p1"][1].get(), params["p1"][2].get()); + if (params.contains("p2") && params["p2"].is_array() && params["p2"].size() >= 3) + f.axis_p2 = Vec3d(params["p2"][0].get(), params["p2"][1].get(), params["p2"][2].get()); + f.axis_body = params.value("body", -1); + f.axis_face = params.value("face", -1); + f.axis_edge = params.value("edge", -1); + f.axis_plane_a = params.value("plane_a", -1); + f.axis_plane_b = params.value("plane_b", -1); + // Datum features don't produce a body; check for an error returned by resolve. + bool recompute_ok = doc.recompute(); + auto axes = doc.resolve_datum_axes(); + std::string err = doc.error; + if (recompute_ok && err.empty() && !axes.empty() && !axes.back().error.empty()) + err = axes.back().error; + panel->mcp_after_change(); + return json{{"ok", true}, {"axis_index", idx}, {"error", err}}; +} + +json action_coordsys(DesignPanel* panel, const json& params) +{ + CadDocument& doc = panel->mcp_doc(); + std::string t = params.value("type", std::string("point_world")); + CoordSysType ct = CoordSysType::PointWorld; + if (t == "face_and_direction") ct = CoordSysType::FaceAndDirection; + Vec3d pt(0, 0, 0); + if (params.contains("point") && params["point"].is_array() && params["point"].size() >= 3) + pt = Vec3d(params["point"][0].get(), params["point"][1].get(), params["point"][2].get()); + doc.checkpoint(); + int idx = doc.add_coordsys(ct, pt, "CoordSys"); + CadFeature& f = doc.features[idx]; + f.coordsys_body = params.value("body", -1); + f.coordsys_face = params.value("face", -1); + f.coordsys_edge = params.value("edge", -1); + if (params.contains("x_hint") && params["x_hint"].is_array() && params["x_hint"].size() >= 3) + f.coordsys_x_hint = Vec3d(params["x_hint"][0].get(), params["x_hint"][1].get(), params["x_hint"][2].get()); + bool recompute_ok = doc.recompute(); + auto css = doc.resolve_datum_coordsys(); + std::string err = doc.error; + if (recompute_ok && err.empty() && !css.empty() && !css.back().error.empty()) + err = css.back().error; + panel->mcp_after_change(); + return json{{"ok", true}, {"coordsys_index", idx}, {"error", err}}; +} + // Dispatch one parsed request ON THE MAIN THREAD. Returns a JSON-RPC reply string. std::string handle_on_main(const std::string& method, const json& params, const json& id) { @@ -842,6 +921,8 @@ std::string handle_on_main(const std::string& method, const json& params, const if (method == "shell") return rpc_result(id, action_shell(panel, params)); if (method == "draft") return rpc_result(id, action_draft(panel, params)); if (method == "mirror") return rpc_result(id, action_mirror(panel, params)); + if (method == "axis") return rpc_result(id, action_axis(panel, params)); + if (method == "coordsys") return rpc_result(id, action_coordsys(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 index 1da9aa6129..53c86aa410 100644 Binary files a/tests/data/cad_recipe_v2.bin and b/tests/data/cad_recipe_v2.bin differ diff --git a/tests/libslic3r/test_caddocument.cpp b/tests/libslic3r/test_caddocument.cpp index 09bba96188..a9f7d82f21 100644 --- a/tests/libslic3r/test_caddocument.cpp +++ b/tests/libslic3r/test_caddocument.cpp @@ -2054,6 +2054,164 @@ TEST_CASE("mesh_to_brep: degenerate triangles are rejected on a scale-independen CHECK(st3.degenerate_collapsed == 1); } +TEST_CASE("datum axis: two points direction is unit and analytic", "[CadDocument]") +{ + using Catch::Matchers::WithinAbs; + + CadDocument doc; + int ax = doc.add_axis(AxisType::TwoPoints, "AxisThroughZ"); + REQUIRE(ax == 0); + doc.features[ax].axis_p1 = Vec3d(0, 0, 0); + doc.features[ax].axis_p2 = Vec3d(0, 0, 10); + + auto axes = doc.resolve_datum_axes(); + REQUIRE(axes.size() == 1); + REQUIRE(axes[0].name == "AxisThroughZ"); + REQUIRE(axes[0].error.empty()); + CHECK_THAT(axes[0].direction.x(), WithinAbs(0.0, 1e-12)); + CHECK_THAT(axes[0].direction.y(), WithinAbs(0.0, 1e-12)); + CHECK_THAT(axes[0].direction.z(), WithinAbs(1.0, 1e-12)); + CHECK_THAT(axes[0].direction.norm(), WithinAbs(1.0, 1e-9)); + CHECK_THAT(axes[0].origin.x(), WithinAbs(0.0, 1e-9)); + CHECK_THAT(axes[0].origin.y(), WithinAbs(0.0, 1e-9)); + CHECK_THAT(axes[0].origin.z(), WithinAbs(0.0, 1e-9)); +} + +TEST_CASE("datum axis: degenerate two identical points fails cleanly", "[CadDocument]") +{ + CadDocument doc; + int ax = doc.add_axis(AxisType::TwoPoints, "Degenerate"); + doc.features[ax].axis_p1 = Vec3d(5, 5, 5); + doc.features[ax].axis_p2 = Vec3d(5, 5, 5); + + auto axes = doc.resolve_datum_axes(); + REQUIRE(axes.size() == 1); + REQUIRE_FALSE(axes[0].error.empty()); +} + +TEST_CASE("datum axis: two parallel planes fail with error", "[CadDocument]") +{ + CadDocument doc; + // Two offset XY planes are parallel -> no intersection + doc.add_plane(0 /*XY*/, 10.0, 0.0, 0, "PlaneA"); + doc.add_plane(0 /*XY*/, 30.0, 0.0, 0, "PlaneB"); + + int ax = doc.add_axis(AxisType::TwoPoints, "Parallel"); + doc.features[ax].axis_type = AxisType::PlaneIntersection; + doc.features[ax].axis_plane_a = 0; + doc.features[ax].axis_plane_b = 1; + + auto axes = doc.resolve_datum_axes(); + REQUIRE(axes.size() == 1); + REQUIRE_FALSE(axes[0].error.empty()); +} + +TEST_CASE("datum axis: cylinder centreline from extruded circle", "[CadDocument]") +{ + using Catch::Matchers::WithinAbs; + + CadDocument doc; + // Build a cylinder: circle r=5 at origin, extrude 20 mm along +Z -> cylinder z=[0,20] + int sk = doc.add_sketch(SketchShape::Circle, SketchPlane::XY(), 0, 0, 5.0, "Circle"); + doc.add_extrude(sk, 20.0, false, BooleanMode::New, "Cyl"); + REQUIRE(doc.recompute()); + REQUIRE(doc.bodies.size() == 1); + // Find the lateral cylindrical face + int n_faces = GeometryEngine::face_count(doc.bodies[0].shape); + int lateral_face = -1; + for (int i = 0; i < n_faces; ++i) { + TopoDS_Face fc = GeometryEngine::face_by_index(doc.bodies[0].shape, i); + GeometryEngine::CylinderFace cyl = GeometryEngine::cylinder_of_face(fc); + if (cyl.ok) { lateral_face = i; break; } + } + REQUIRE(lateral_face >= 0); + + int ax = doc.add_axis(AxisType::TwoPoints, "CylAx"); + doc.features[ax].axis_type = AxisType::CylinderCenterline; + doc.features[ax].axis_body = 0; + doc.features[ax].axis_face = lateral_face; + + auto axes = doc.resolve_datum_axes(); + REQUIRE(axes.size() == 1); + REQUIRE(axes[0].error.empty()); + // OCCT may return the axis direction as +Z or -Z depending on face orientation; + // the centreline is always collinear with Z and passes through (x=0,y=0). + CHECK_THAT(std::abs(axes[0].direction.z()), WithinAbs(1.0, 1e-12)); + CHECK_THAT(axes[0].direction.x(), WithinAbs(0.0, 1e-12)); + CHECK_THAT(axes[0].direction.y(), WithinAbs(0.0, 1e-12)); + CHECK_THAT(axes[0].origin.x(), WithinAbs(0.0, 1e-6)); + CHECK_THAT(axes[0].origin.y(), WithinAbs(0.0, 1e-6)); +} + +TEST_CASE("datum coordinate system: non-perpendicular inputs produce orthonormal axes", "[CadDocument]") +{ + using Catch::Matchers::WithinAbs; + + CadDocument doc; + // Build a body so we have a face to reference for FaceAndDirection. + int sk = doc.add_sketch(SketchShape::Rectangle, SketchPlane::XY(), 20, 20, 10, "Box"); + doc.add_extrude(sk, 10.0, false, BooleanMode::New, "BoxExt"); + 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) { + Vec3d fn = GeometryEngine::face_normal_world(GeometryEngine::face_by_index(doc.bodies[0].shape, i)); + if (fn.z() > 0.9) { top_face = i; break; } + } + REQUIRE(top_face >= 0); + + int cs = doc.add_coordsys(CoordSysType::PointWorld, Vec3d(0, 0, 0), "CS1"); + REQUIRE(cs >= 0); + doc.features[cs].coordsys_type = CoordSysType::FaceAndDirection; + doc.features[cs].coordsys_body = 0; + doc.features[cs].coordsys_face = top_face; + // Deliberately non-perpendicular X hint (NOT orthogonal to face normal ~+Z). + doc.features[cs].coordsys_x_hint = Vec3d(3.0, -1.0, 0.5); + + auto css = doc.resolve_datum_coordsys(); + REQUIRE(css.size() == 1); + REQUIRE(css[0].error.empty()); + + Vec3d X = css[0].x, Y = css[0].y; + // Orthonormality: each axis has unit length + CHECK_THAT(X.norm(), WithinAbs(1.0, 1e-9)); + CHECK_THAT(Y.norm(), WithinAbs(1.0, 1e-9)); + // Pairwise dot products are ~0 + CHECK_THAT(std::abs(X.dot(Y)), WithinAbs(0.0, 1e-9)); + // Z = X x Y (derived), also unit and perpendicular + Vec3d Z = X.cross(Y); + CHECK_THAT(Z.norm(), WithinAbs(1.0, 1e-9)); + CHECK_THAT(std::abs(X.dot(Z)), WithinAbs(0.0, 1e-9)); + CHECK_THAT(std::abs(Y.dot(Z)), WithinAbs(0.0, 1e-9)); + // Right-handedness: X x Y == Z + CHECK_THAT(Z.x(), WithinAbs((X.cross(Y)).x(), 1e-9)); + CHECK_THAT(Z.y(), WithinAbs((X.cross(Y)).y(), 1e-9)); + CHECK_THAT(Z.z(), WithinAbs((X.cross(Y)).z(), 1e-9)); +} + +TEST_CASE("datum coordinate system: point_world gives world axes", "[CadDocument]") +{ + using Catch::Matchers::WithinAbs; + + CadDocument doc; + int cs = doc.add_coordsys(CoordSysType::PointWorld, Vec3d(10, 20, 30), "CS_World"); + REQUIRE(cs == 0); + + auto css = doc.resolve_datum_coordsys(); + REQUIRE(css.size() == 1); + REQUIRE(css[0].error.empty()); + CHECK_THAT(css[0].origin.x(), WithinAbs(10.0, 1e-9)); + CHECK_THAT(css[0].origin.y(), WithinAbs(20.0, 1e-9)); + CHECK_THAT(css[0].origin.z(), WithinAbs(30.0, 1e-9)); + CHECK_THAT(css[0].x.x(), WithinAbs(1.0, 1e-9)); + CHECK_THAT(css[0].x.y(), WithinAbs(0.0, 1e-9)); + CHECK_THAT(css[0].x.z(), WithinAbs(0.0, 1e-9)); + CHECK_THAT(css[0].y.x(), WithinAbs(0.0, 1e-9)); + CHECK_THAT(css[0].y.y(), WithinAbs(1.0, 1e-9)); + CHECK_THAT(css[0].y.z(), WithinAbs(0.0, 1e-9)); +} + // --- Golden recipe fixture (v1 format tripwire) --- @@ -2183,6 +2341,27 @@ static CadDocument make_golden_doc_v1() doc.add_mirror(SketchPlane::XZ(), 0, BooleanMode::New, "Mirror_XZ"); doc.features.back().mirror_keep_original = false; + // ---- Datum Axis: two-points with distinctive non-default coordinates ---- + { + int ax = doc.add_axis(AxisType::TwoPoints, "Axis_TP"); + doc.features[ax].axis_p1 = Vec3d(10, 20, 30); + doc.features[ax].axis_p2 = Vec3d(13, 24, 34); + doc.features[ax].axis_body = 1; + doc.features[ax].axis_face = 3; + doc.features[ax].axis_edge = 2; + doc.features[ax].axis_plane_a = 4; + doc.features[ax].axis_plane_b = 5; + } + + // ---- Datum CoordSys: PointWorld with distinctive origin ---- + { + int cs = doc.add_coordsys(CoordSysType::PointWorld, Vec3d(7, 8, 9), "CS_PtWorld"); + doc.features[cs].coordsys_body = 2; + doc.features[cs].coordsys_face = 1; + doc.features[cs].coordsys_edge = 0; + doc.features[cs].coordsys_x_hint = Vec3d(0.5, 0.8, 0.3); + } + return doc; } @@ -2402,6 +2581,36 @@ TEST_CASE("golden recipe v1 still deserialises", "[CadDocument]") REQUIRE(f.mirror_keep_original == false); REQUIRE(f.target_body == 0); } + + // Datum Axis + if (f.type == CadFeatureType::Axis && e.name == "Axis_TP") { + REQUIRE(f.axis_type == AxisType::TwoPoints); + REQUIRE_THAT(f.axis_p1.x(), WithinAbs(10.0, 1e-9)); + REQUIRE_THAT(f.axis_p1.y(), WithinAbs(20.0, 1e-9)); + REQUIRE_THAT(f.axis_p1.z(), WithinAbs(30.0, 1e-9)); + REQUIRE_THAT(f.axis_p2.x(), WithinAbs(13.0, 1e-9)); + REQUIRE_THAT(f.axis_p2.y(), WithinAbs(24.0, 1e-9)); + REQUIRE_THAT(f.axis_p2.z(), WithinAbs(34.0, 1e-9)); + REQUIRE(f.axis_body == 1); + REQUIRE(f.axis_face == 3); + REQUIRE(f.axis_edge == 2); + REQUIRE(f.axis_plane_a == 4); + REQUIRE(f.axis_plane_b == 5); + } + + // Datum CoordSys + if (f.type == CadFeatureType::CoordSys && e.name == "CS_PtWorld") { + REQUIRE(f.coordsys_type == CoordSysType::PointWorld); + REQUIRE_THAT(f.coordsys_point.x(), WithinAbs(7.0, 1e-9)); + REQUIRE_THAT(f.coordsys_point.y(), WithinAbs(8.0, 1e-9)); + REQUIRE_THAT(f.coordsys_point.z(), WithinAbs(9.0, 1e-9)); + REQUIRE(f.coordsys_body == 2); + REQUIRE(f.coordsys_face == 1); + REQUIRE(f.coordsys_edge == 0); + REQUIRE_THAT(f.coordsys_x_hint.x(), WithinAbs(0.5, 1e-9)); + REQUIRE_THAT(f.coordsys_x_hint.y(), WithinAbs(0.8, 1e-9)); + REQUIRE_THAT(f.coordsys_x_hint.z(), WithinAbs(0.3, 1e-9)); + } } // --- Layer 2: geometry check (optional — only if the document recomputes) ---