Design tab: reference planes at bed centre, slot dims, hole cube handle, real threads

Port of the snaporca CAD work to the mainline fork.

- Onshape default planes (XY/XZ/YZ) at the bed centre (transparent, labelled);
  modeling origin unified to the bed centre (CadDocument::modeling_origin);
  world-axis triad moved to the bed centre on the Design canvas only.
- Datum plane: clickable ghost-plane base pick + draggable offset arrow.
- Slot: dims reassessed to inter-centre distance / radius / angle; fixed the
  duplicate cap-arc radius quote.
- Hole: 3D cube move-handle on the face; binds to the face on the first click;
  decluttered side-distance construction lines.
- Thread: derive the M spec (diameter/pitch/depth) from a picked cylindrical
  surface or circular edge (GeometryEngine::circle_of_edge); fuse the helical
  ridge onto the existing body; MakePipeShell fixed-binormal sweep (uniform, no
  twist) + self-intersection/param guards.

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-06-30 00:24:39 +02:00
co-authored by Claude Opus 4.8
parent 852804450c
commit 71b7a73e86
14 changed files with 1383 additions and 179 deletions
+213 -22
View File
@@ -15,6 +15,7 @@
#include <BRepAlgoAPI_Common.hxx>
#include <BRepAlgoAPI_BooleanOperation.hxx>
#include <BRepOffsetAPI_MakePipe.hxx>
#include <BRepOffsetAPI_MakePipeShell.hxx>
#include <BRepOffsetAPI_MakeThickSolid.hxx>
#include <BRepOffsetAPI_DraftAngle.hxx>
#include <Bnd_Box.hxx>
@@ -22,6 +23,7 @@
#include <gp_Pln.hxx>
#include <TopTools_ListOfShape.hxx>
#include <BRepPrimAPI_MakeCylinder.hxx>
#include <BRepCheck_Analyzer.hxx>
#include <BRepLib.hxx>
#include <Geom_CylindricalSurface.hxx>
#include <Geom2d_TrimmedCurve.hxx>
@@ -74,19 +76,26 @@ static TopoDS_Wire make_helix_wire(const gp_Ax3& axis, double radius,
// - internal: the V is CUT from the wall -> a sunken helical groove. The cut MUST
// go outward into the wall to be visible; an inward V (the old behaviour) only
// sweeps already-empty bore space and removes nothing.
static TopoDS_Face make_thread_profile(const gp_Pnt& origin, const gp_Dir& xdir,
static TopoDS_Wire make_thread_profile(const gp_Pnt& origin, const gp_Dir& xdir,
const gp_Dir& zdir, double radius,
double pitch, double depth, bool internal)
{
(void)internal;
gp_Vec vx(xdir), vz(zdir);
double inner = radius - 0.05; // base, just inside the wall (overlaps rod / open bore)
// Root the V CLEARLY inside the wall (a real overlap, not a 0.05 mm tangency) so the boolean
// has clean intersections — near-coincident faces are what make OCCT's fuse/cut unstable.
const double over = std::min(std::max(depth, 0.25), radius * 0.4);
double inner = radius - over; // base, well inside the wall (solid overlap)
double crest = radius + depth; // apex, `depth` into the surrounding material
gp_Pnt top (origin.XYZ() + (vx * inner).XYZ() + (vz * ( 0.5 * pitch)).XYZ());
gp_Pnt bot (origin.XYZ() + (vx * inner).XYZ() + (vz * (-0.5 * pitch)).XYZ());
// Axial half-height must be < pitch/2 so ADJACENT helix turns don't collide — a full-pitch
// profile makes the swept solid self-intersect (invalid -> never renders, or crashes the
// boolean). 0.42*pitch leaves a clean gap between turns; the V still reads as a thread.
const double half = 0.42 * pitch;
gp_Pnt top (origin.XYZ() + (vx * inner).XYZ() + (vz * ( half)).XYZ());
gp_Pnt bot (origin.XYZ() + (vx * inner).XYZ() + (vz * (-half)).XYZ());
gp_Pnt apex(origin.XYZ() + (vx * crest).XYZ());
BRepBuilderAPI_MakePolygon poly(top, bot, apex, Standard_True);
return BRepBuilderAPI_MakeFace(poly.Wire(), Standard_True).Face();
return poly.Wire(); // closed triangle, swept by MakePipeShell with a fixed binormal
}
// ---------------------------------------------------------------------------
@@ -697,20 +706,173 @@ static SketchPlane offset_angle_plane(const SketchPlane& base, double offset,
return p;
}
// Build a full orthonormal frame from a normal + origin.
static SketchPlane frame_from(const Vec3d& origin, const Vec3d& normal)
{
Vec3d n = normal.normalized();
Vec3d ref = (std::abs(n.z()) < 0.9) ? Vec3d(0, 0, 1) : Vec3d(1, 0, 0);
Vec3d x = ref.cross(n);
if (x.squaredNorm() < 1e-12) x = Vec3d(1, 0, 0);
x.normalize();
Vec3d y = n.cross(x).normalized();
SketchPlane p;
p.origin = origin;
p.normal = n;
p.x_axis = x;
p.y_axis = y;
return p;
}
std::vector<std::pair<std::string, SketchPlane>> CadDocument::resolve_datum_planes() const
{
std::vector<std::pair<std::string, SketchPlane>> out;
for (const CadFeature& f : features) {
if (f.type != CadFeatureType::Plane || !f.enabled) continue;
// Resolve base reference plane. The default XY/XZ/YZ planes pass through the modeling
// origin (bed centre); datum bases (>=3) are already in world coords from earlier passes.
SketchPlane base;
if (f.plane_base == 1) base = SketchPlane::XZ();
else if (f.plane_base == 2) base = SketchPlane::YZ();
if (f.plane_base == 1) { base = SketchPlane::XZ(); base.origin += modeling_origin; }
else if (f.plane_base == 2) { base = SketchPlane::YZ(); base.origin += modeling_origin; }
else if (f.plane_base >= 3) {
const int di = f.plane_base - 3; // index into earlier datum planes
if (di < int(out.size())) base = out[di].second; // else XY default
const int di = f.plane_base - 3;
if (di < int(out.size())) base = out[di].second;
}
out.emplace_back(f.name,
offset_angle_plane(base, f.plane_offset, f.plane_angle_tilt, f.plane_axis));
else { base = SketchPlane::XY(); base.origin += modeling_origin; }
// --- Resolve refs from bodies ---
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;
};
// Face A
TopoDS_Face faceA = resolve_face(f.plane_face_body, f.plane_face);
SketchPlane faceA_plane;
bool has_faceA = false;
if (!faceA.IsNull()) {
faceA_plane = frame_from(
GeometryEngine::face_centroid_world(faceA),
GeometryEngine::face_normal_world(faceA));
has_faceA = true;
}
// Face B
TopoDS_Face faceB = resolve_face(f.plane_face2_body, f.plane_face2);
SketchPlane faceB_plane;
bool has_faceB = false;
if (!faceB.IsNull()) {
faceB_plane = frame_from(
GeometryEngine::face_centroid_world(faceB),
GeometryEngine::face_normal_world(faceB));
has_faceB = true;
}
// Edge A
Vec3d eA_p0, eA_dir;
bool has_edgeA = resolve_edge(f.plane_edge_body, f.plane_edge, eA_p0, eA_dir);
// Edge B
Vec3d eB_p0, eB_dir;
bool has_edgeB = resolve_edge(f.plane_edge2_body, f.plane_edge2, eB_p0, eB_dir);
// --- Dispatch on plane_type ---
auto fallback_offset = [&]() {
return offset_angle_plane(base, f.plane_offset, f.plane_angle_tilt, f.plane_axis);
};
SketchPlane result;
switch (f.plane_type) {
case PlaneType::Offset: {
// From a picked face: pure offset along its normal. From a base/datum plane:
// offset + the legacy tilt-about-axis (keeps the old Offset/Tilt controls live).
if (has_faceA)
result = frame_from(faceA_plane.origin + faceA_plane.normal * f.plane_offset,
faceA_plane.normal);
else
result = offset_angle_plane(base, f.plane_offset, f.plane_angle_tilt, f.plane_axis);
break;
}
case PlaneType::Coincident: {
result = has_faceA ? faceA_plane : base;
break;
}
case PlaneType::Angle: {
if (!has_edgeA) { result = fallback_offset(); break; }
const SketchPlane& ref = has_faceA ? faceA_plane : base;
Vec3d n0 = ref.normal - eA_dir * ref.normal.dot(eA_dir);
if (n0.squaredNorm() < 1e-12) {
Vec3d perp = (std::abs(eA_dir.z()) < 0.9) ? Vec3d(0, 0, 1) : Vec3d(1, 0, 0);
n0 = perp - eA_dir * perp.dot(eA_dir);
}
n0.normalize();
const double a = f.plane_angle_tilt * M_PI / 180.0;
Vec3d n_rot = n0 * std::cos(a) + eA_dir.cross(n0) * std::sin(a)
+ eA_dir * (eA_dir.dot(n0)) * (1.0 - std::cos(a));
result = frame_from(eA_p0, n_rot);
break;
}
case PlaneType::Midplane: {
if (!has_faceA || !has_faceB) { result = fallback_offset(); break; }
Vec3d origin = 0.5 * (faceA_plane.origin + faceB_plane.origin);
Vec3d nB = (faceA_plane.normal.dot(faceB_plane.normal) >= 0)
? faceB_plane.normal : -faceB_plane.normal;
Vec3d normal = (faceA_plane.normal + nB).normalized();
result = frame_from(origin, normal);
break;
}
case PlaneType::Tangent: {
if (!has_faceA) { result = fallback_offset(); break; }
GeometryEngine::CylinderFace cyl = GeometryEngine::cylinder_of_face(faceA);
if (!cyl.ok) { result = fallback_offset(); break; }
Vec3d refdir = (std::abs(cyl.axis.z()) < 0.9) ? Vec3d(0, 0, 1) : Vec3d(1, 0, 0);
refdir = refdir - cyl.axis * refdir.dot(cyl.axis);
refdir.normalize();
const double theta = f.plane_angle_tilt * M_PI / 180.0;
Vec3d r = refdir * std::cos(theta) + cyl.axis.cross(refdir) * std::sin(theta);
Vec3d touch = cyl.base + r * cyl.radius;
result = frame_from(touch, r);
break;
}
case PlaneType::TwoEdges: {
if (!has_edgeA) { result = fallback_offset(); break; }
if (!has_edgeB) { result = fallback_offset(); break; }
Vec3d cross = eA_dir.cross(eB_dir);
if (cross.squaredNorm() > 1e-12) {
result = frame_from(eA_p0, cross.normalized());
} else {
Vec3d v = eA_dir.cross(eB_p0 - eA_p0);
if (v.squaredNorm() > 1e-12) {
result = frame_from(eA_p0, v.normalized());
} else {
result = fallback_offset();
}
}
break;
}
}
out.emplace_back(f.name, result);
}
return out;
}
@@ -1187,6 +1349,17 @@ void CadDocument::apply_feature(TopoDS_Shape& result, bool& have_body,
break;
}
case CadFeatureType::Thread: {
// Reject degenerate parameters that make OCCT's helical sweep / boolean unstable (a tiny
// pitch, depth >= half-pitch, an enormous turn count, depth eating the whole wall). Better
// a no-op than a crash. Leave the body unchanged when the spec can't be built safely.
{
const double R = f.thread_radius, P = f.thread_pitch, H = f.thread_height, D = f.thread_depth;
// ISO external thread depth is ~0.61*P, so allow up to 0.7*P (0.49 wrongly rejected
// every real thread -> nothing rendered). Still bound it well under a full pitch.
const bool ok = R > 0.5 && P > 0.1 && D > 1e-3 && D < 0.7 * P && D < 0.45 * R
&& H > 0.5 * P && (H / P) < 400.0;
if (!ok) break; // result/have_body untouched
}
// Axis at the positioned point on the plane; +normal = thread rise.
Vec3d c3 = f.plane.to_world(Vec2d(f.thread_x, f.thread_y));
gp_Pnt c(c3.x(), c3.y(), c3.z());
@@ -1201,17 +1374,25 @@ void CadDocument::apply_feature(TopoDS_Shape& result, bool& have_body,
try {
TopoDS_Wire spine = make_helix_wire(ax3, f.thread_radius,
f.thread_pitch, f.thread_height);
TopoDS_Face prof = make_thread_profile(c, xdir, zdir, f.thread_radius,
TopoDS_Wire prof = make_thread_profile(c, xdir, zdir, f.thread_radius,
f.thread_pitch, f.thread_depth,
f.thread_internal);
BRepOffsetAPI_MakePipe pipe(spine, prof);
// MakePipeShell with a FIXED BINORMAL = cylinder axis keeps the V-profile's orientation
// constant along the helix (axial edge always parallel to the axis, V always pointing
// radially out). The plain MakePipe used a Frenet frame that TWISTED the profile around
// the helix -> the wedge inclination varied and looked mirrored.
BRepOffsetAPI_MakePipeShell pipe(spine);
pipe.SetMode(zdir);
pipe.Add(prof);
pipe.Build();
if (pipe.IsDone()) {
if (pipe.IsDone() && pipe.MakeSolid()) {
ridge = pipe.Shape();
have_ridge = !ridge.IsNull();
}
} catch (const std::exception&) {
have_ridge = false; // fall back to the bare cylinder/bore below
} catch (const Standard_Failure&) {
have_ridge = false; // OCCT failure (not a std::exception) — must be caught here too
}
if (f.thread_internal) {
@@ -1233,15 +1414,25 @@ void CadDocument::apply_feature(TopoDS_Shape& result, bool& have_body,
result = cut_ridge.Shape();
}
} else {
// External threaded rod = a New body: base cylinder + fused ridge.
TopoDS_Shape rod = BRepPrimAPI_MakeCylinder(ax2, f.thread_radius,
f.thread_height).Shape();
if (have_ridge) {
BRepAlgoAPI_Fuse fuse(rod, ridge);
if (fuse.IsDone()) rod = fuse.Shape();
// External thread: FUSE the helical ridge ONTO the existing body (the picked cylinder),
// leaving the rest of the part intact. Replacing the body with a bare rod — the old
// behaviour — wiped whatever the user picked; that was the "mess". With no body yet
// (a thread from scratch on a dropdown plane), fall back to a standalone threaded rod.
if (have_body && !result.IsNull()) {
if (have_ridge) {
BRepAlgoAPI_Fuse fuse(result, ridge);
if (fuse.IsDone() && !fuse.Shape().IsNull()) result = fuse.Shape();
}
} else {
TopoDS_Shape rod = BRepPrimAPI_MakeCylinder(ax2, f.thread_radius,
f.thread_height).Shape();
if (have_ridge) {
BRepAlgoAPI_Fuse fuse(rod, ridge);
if (fuse.IsDone()) rod = fuse.Shape();
}
result = rod;
have_body = true;
}
result = rod;
have_body = true;
}
break;
}
+24 -3
View File
@@ -19,6 +19,7 @@ namespace Slic3r {
enum class CadFeatureType { Sketch, Extrude, Fillet, Chamfer, Hole, Thread, Shell, Revolve, Sweep, Pattern, Plane, Loft, Draft, Import, Boolean, Cut };
enum class SketchShape { Rectangle, Circle };
enum class PlaneType { Offset, Angle, Midplane, Tangent, TwoEdges, Coincident };
enum class BooleanMode { New, Add, Cut, Intersect };
enum class ExtrudeEnd { Blind, Symmetric, TwoSided, ThroughAll, UpToFace, UpToVertex };
@@ -169,6 +170,17 @@ struct CadFeature {
double plane_offset{20};
double plane_angle_tilt{0}; // degrees (named *_tilt to avoid clash w/ revolve)
int plane_axis{0}; // tilt axis: 0 = base X, 1 = base Y
PlaneType plane_type{PlaneType::Offset};
int plane_face_body{-1};
int plane_face{-1};
int plane_face2_body{-1};
int plane_face2{-1};
int plane_edge_body{-1};
int plane_edge{-1};
int plane_edge2_body{-1};
int plane_edge2{-1};
double plane_u_size{60};
double plane_v_size{60};
// Boolean: combine two EXISTING bodies. `mode` reuses BooleanMode (Add = union,
// Cut = subtract tool from target, Intersect = keep overlap; New unused). `target_body`
@@ -208,8 +220,10 @@ struct CadFeature {
pattern_circular, pattern_count, pattern_spacing, pattern_dir, pattern_angle,
plane_base, plane_offset, plane_angle_tilt, plane_axis,
bool_tool_body, bool_keep_tool, bool_tolerance, bool_target_face, bool_tool_face,
cut_offset, cut_flip, cut_keep_upper, cut_keep_lower,
brep);
cut_offset, cut_flip, cut_keep_upper, cut_keep_lower,
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);
}
template<class Archive>
void load(Archive& ar) {
@@ -230,7 +244,9 @@ struct CadFeature {
plane_base, plane_offset, plane_angle_tilt, plane_axis,
bool_tool_body, bool_keep_tool, bool_tolerance, bool_target_face, bool_tool_face,
cut_offset, cut_flip, cut_keep_upper, cut_keep_lower,
brep);
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);
imported_solid = brep_from_string(brep);
}
};
@@ -263,6 +279,11 @@ public:
std::vector<int> display_tri_body; // per-triangle source body index (into bodies)
std::string error; // last recompute error ("" = ok)
// Modeling origin: the world point the default XY/XZ/YZ planes pass through. The GUI sets this
// to the bed centre so sketches/datums land in the middle of the bed (not the bed corner =
// world 0). Not serialized — the GUI re-applies it from the live bed on every tab show.
Vec3d modeling_origin{Vec3d::Zero()};
double linear_deflection{0.01};
double angular_deflection{0.5};
+18
View File
@@ -23,6 +23,7 @@
#include <GProp_GProps.hxx>
#include <GeomLProp_SLProps.hxx>
#include <BRepAdaptor_Curve.hxx>
#include <gp_Circ.hxx>
#include <GCPnts_TangentialDeflection.hxx>
#include <STEPControl_Reader.hxx>
#include <IFSelect_ReturnStatus.hxx>
@@ -397,6 +398,23 @@ GeometryEngine::CylinderFace GeometryEngine::cylinder_of_face(const TopoDS_Face&
return cf;
}
GeometryEngine::CylinderFace GeometryEngine::circle_of_edge(const TopoDS_Edge& edge)
{
CylinderFace cf;
if (edge.IsNull()) return cf;
BRepAdaptor_Curve curve(edge);
if (curve.GetType() != GeomAbs_Circle) return cf;
const gp_Circ c = curve.Circle();
const gp_Ax1 ax = c.Axis();
cf.base = Vec3d(c.Location().X(), c.Location().Y(), c.Location().Z());
cf.axis = Vec3d(ax.Direction().X(), ax.Direction().Y(), ax.Direction().Z());
cf.radius = c.Radius();
cf.height = 0.0; // an edge carries no axial extent; the card keeps the current length
cf.internal = false; // ambiguous from an edge alone — default external, user can toggle
cf.ok = true;
return cf;
}
bool GeometryEngine::face_plane_bounds(const TopoDS_Face& face, const Vec3d& origin,
const Vec3d& x_axis, const Vec3d& y_axis,
double& umin, double& umax, double& vmin, double& vmax)
+4
View File
@@ -103,6 +103,10 @@ public:
bool internal{false};
};
static CylinderFace cylinder_of_face(const TopoDS_Face& face);
// Circular edge (a cylinder's perimeter): base = circle centre, axis = circle normal,
// radius = circle radius, height = 0 (unknown from an edge), internal = false. ok=false if
// the edge is not a circle. Lets the Thread tool be driven by a picked circular rim.
static CylinderFace circle_of_edge(const TopoDS_Edge& edge);
// Plane-coordinate (u,v) bounding box of a face's vertices, measured from `origin` along
// `x_axis`/`y_axis`. Lets the Hole tool dimension the hole from the face SIDES (umin/vmin =