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) {