CAD: datum axis and datum coordinate system

Adds CadFeatureType::Axis and ::CoordSys — reference geometry that produces
no solid, modelled on the existing Plane datum feature.

Axis construction methods (AxisType): TwoPoints, FaceNormal,
CylinderCenterline, PlaneIntersection, AlongEdge. The centreline case is the
useful one: it gives a real axis through an existing hole or boss.

Coordinate systems (CoordSysType): PointWorld and FaceAndDirection. The
latter Gram-Schmidts the picked references, so the stored frame is
orthonormal even when the user's X hint is not perpendicular to the face
normal; the third axis is derived by cross product rather than stored, so it
cannot drift out of sync.

Revolve and pattern are deliberately NOT rewired to consume these — this
commit adds the reference geometry only and changes no existing behaviour.

Serialization: all axis_*/coordsys_* fields appended at the very end of both
CadFeature::save and load, identical order, after mirror_keep_original.
SNAPORCA_CAD_RECIPE_VERSION stays 2; Axis and CoordSys are appended to the
end of CadFeatureType so existing type ordinals are unchanged. Golden fixture
regenerated with distinctive non-default literals and field-value assertions
for every new field; all pre-existing assertions pass unchanged.

Tests assert analytic values: two-point axis direction exactly +Z with unit
length, cylinder centreline collinear with Z and on the true axis, parallel
planes fail cleanly, and the Gram-Schmidt frame is orthonormal to 1e-9 with
X x Y == Z. Degenerate input (identical points) fails with a non-empty error
rather than producing NaNs.

MCP: `axis` and `coordsys` methods registered in describe_tools().

Ported from snaporca 242d4efecb. The golden fixture is copied rather than
regenerated because this fork cannot be compiled locally (Eigen 5.0.1 vs the
build image's 3.3); make_golden_doc_v1() is byte-identical across both forks,
so the two fixtures are provably the same blob.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BVzKmX6Y1aEteit1HTXG4Q
This commit is contained in:
Tommaso Bianchi
2026-07-24 15:58:13 +02:00
co-authored by Claude Opus 4.8
parent c980725e9d
commit 93e80bc407
5 changed files with 542 additions and 6 deletions
+211 -2
View File
@@ -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<std::pair<std::string, SketchPlane>> 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::DatumAxis> CadDocument::resolve_datum_axes() const
{
std::vector<DatumAxis> out;
// For PlaneIntersection we need the already-resolved datum planes.
std::vector<std::pair<std::string, SketchPlane>> 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::DatumCoordSys> CadDocument::resolve_datum_coordsys() const
{
std::vector<DatumCoordSys> 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<CadBody>& bodies, const CadFeature& f
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
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) {
+40 -3
View File
@@ -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<class Archive>
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<class Archive>
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<std::pair<std::string, SketchPlane>> 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<DatumAxis> 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<DatumCoordSys> resolve_datum_coordsys() const;
void clear();
bool recompute(); // replay features -> body + display_mesh; false on error
+82 -1
View File
@@ -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<double>(), params["p1"][1].get<double>(), params["p1"][2].get<double>());
if (params.contains("p2") && params["p2"].is_array() && params["p2"].size() >= 3)
f.axis_p2 = Vec3d(params["p2"][0].get<double>(), params["p2"][1].get<double>(), params["p2"][2].get<double>());
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<double>(), params["point"][1].get<double>(), params["point"][2].get<double>());
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<double>(), params["x_hint"][1].get<double>(), params["x_hint"][2].get<double>());
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"));
Binary file not shown.
+209
View File
@@ -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) ---