The viewport and the kernel now answer "is this joint closed?" with one number

Tommaso asked the question that names the real defect: if the sketch was open, why was
the same sketch shaded closed and offered for extrude? Because the two halves used
different tolerances. region_loops shades a region closed at 1e-3 mm; connected_loop
chained at 1e-3; the kernel welded at 1e-4 and OCCT matched vertices at 1e-7. The
2.28e-5 mm gap in the reported sketch did not cause that disagreement, it only made it
visible — and fixing the gap alone would have left the contradiction in place, ready to
reappear anywhere in (1e-4, 1e-3].

kSketchJoinTol now lives in SketchEngine.hpp and is the only place the number exists.
region_loops, loop_report, connected_loop and entities_to_wires all read it through
sketch_join_tol(). The viewport cannot promise a region the kernel refuses to build.

The welding is optional, because a kernel that silently closes loops should let you say
no: "Auto-close sketch loops" in Preferences, default ON, no restart. OFF means only
exactly coincident endpoints join — and since both halves read the same value, the
viewport simply stops shading the region closed, so an open loop is visible rather than
welded behind your back. No separate UI needed for that; it falls out of sharing one
number.

Details that matter. The kernel defaults to auto-close ON independently of the GUI, so
headless and MCP callers behave like the viewport instead of inheriting an unset
preference. With the tolerance at 0 the comparisons become <=, because OFF must mean
exact, not broken. OCCT never receives a zero vertex tolerance — it is clamped to
Precision::Confusion.

The preference is pushed from EVERY entry that starts a sketch session, not just
begin(): a Constrain session enters through begin_constrain / begin_constrain_entities
and uses region_loops and connected_loop, so a single push site would have left those
sessions running on whatever the previous one set. begin_imported_transform is excluded
deliberately — it works on imported regions, not chained entities.

Tests: a loop with one joint open by 9e-4 mm, given out of traversal order, builds a
closed four-edge wire; with auto-close off the same loop yields no wire; and an exactly
closed loop still builds with auto-close off, proving OFF means exact. Kernel 66104
assertions / 606 cases green.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011FbJKJAJxxkhDTs9XdZzKA
This commit is contained in:
Tommaso Bianchi
2026-09-02 14:34:17 +02:00
co-authored by Claude Opus 5
parent faf4406f89
commit 8b568b7b9d
7 changed files with 174 additions and 11 deletions
+6
View File
@@ -316,6 +316,12 @@ void AppConfig::set_defaults()
if (get("enable_cad_feature").empty())
set_bool("enable_cad_feature", false);
// Auto-weld sketch endpoints within kSketchJoinTol when building closed loops.
// Default ON: it is what the ~90% case wants; OFF makes the kernel demand an exact
// joint. The GUI pushes it into SketchEngine via set_sketch_auto_close().
if (get("auto_close_sketch_loops").empty())
set_bool("auto_close_sketch_loops", true);
// Design tab: draw a mate connector as a face rather than as the abstract disc + roll
// quadrant. Defaults ON — face orientation is hardwired perception, so the roll and the
// verse read without being learned, which no abstract glyph achieves. Turning it off
+23 -8
View File
@@ -42,6 +42,7 @@
#include <ShapeFix_Face.hxx>
#include <GeomAbs_Shape.hxx>
#include <Standard_Failure.hxx>
#include <Precision.hxx>
#include <TopoDS_Face.hxx>
#include <TopoDS_Wire.hxx>
#include <GeomAPI_IntCS.hxx>
@@ -51,6 +52,14 @@
namespace Slic3r {
// Single source of truth for the weld tolerance the viewport and the kernel share.
// Defaults ON so headless/kernel-only callers keep welding; the GUI pushes the
// "auto_close_sketch_loops" preference in via set_sketch_auto_close().
static bool s_auto_close = true;
double sketch_join_tol() { return s_auto_close ? kSketchJoinTol : 0.0; }
void set_sketch_auto_close(bool on) { s_auto_close = on; }
// ---- SketchPlane ----
gp_Pln SketchPlane::to_occt() const
@@ -535,6 +544,11 @@ TriangleMesh SketchEngine::tessellate(const TopoDS_Shape& shape,
std::vector<TopoDS_Wire> SketchEngine::entities_to_wires(const std::vector<SketchEntity>& entities,
const SketchPlane& plane)
{
// Effective weld tolerance: kSketchJoinTol when auto-close is on, 0.0 when off.
// Read ONCE so the union-find, the node weld and the vertex tolerance below all
// agree. With 0.0 the comparisons use <= so exactly coincident endpoints still join.
const double tol = sketch_join_tol();
struct Item { const SketchEntity* e; size_t idx; };
std::vector<Item> valid;
valid.reserve(entities.size());
@@ -600,12 +614,12 @@ std::vector<TopoDS_Wire> SketchEngine::entities_to_wires(const std::vector<Sketc
return true;
};
// Sketch weld tolerance. Nothing legitimate in a mm-scale sketch is 0.1 um apart, so two
// Sketch weld tolerance. Nothing legitimate in a mm-scale sketch is 1 um apart, so two
// endpoints within this distance count as one joint. The union-find grouping and the wire
// build below MUST use the SAME number, or a joint can be united into a loop and then
// rejected by the wire builder (which silently drops the edge — see the wire build).
static constexpr double kSketchWeldTol = 1e-4; // mm
auto same = [&](const Vec2d& p, const Vec2d& q) { return (p - q).norm() < kSketchWeldTol; };
// `<=` (not `<`) so exactly coincident endpoints still join when tol == 0 (auto-close off).
auto same = [&](const Vec2d& p, const Vec2d& q) { return (p - q).norm() <= tol; };
// Union-find over the valid index list: chain entities sharing an endpoint belong to one loop.
std::vector<int> parent(valid.size());
@@ -687,7 +701,7 @@ std::vector<TopoDS_Wire> SketchEngine::entities_to_wires(const std::vector<Sketc
// loop.members is in ENTITY-CREATION order, not loop-traversal order. A partial
// wire rejects an out-of-order edge even for a perfectly closed sketch, so first
// weld every endpoint into a shared node, then traverse the chain in order. Each
// endpoint snaps to an existing node when within kSketchWeldTol, and every edge
// endpoint snaps to an existing node when within `tol`, and every edge
// touching a node shares ONE TopoDS_Vertex: the wire builder then sees vertex
// identity, not geometric proximity, so a joint open by a few um (larger than
// OCCT's default 1e-7 vertex tolerance) still connects.
@@ -696,7 +710,7 @@ std::vector<TopoDS_Wire> SketchEngine::entities_to_wires(const std::vector<Sketc
std::vector<int> node_deg; // endpoint count per node
auto node_id = [&](const Vec2d& p) -> int {
for (size_t i = 0; i < node_pt.size(); ++i)
if ((node_pt[i] - p).norm() < kSketchWeldTol) return int(i);
if ((node_pt[i] - p).norm() <= tol) return int(i);
node_pt.push_back(p);
node_deg.push_back(0);
return int(node_pt.size()) - 1;
@@ -736,15 +750,16 @@ std::vector<TopoDS_Wire> SketchEngine::entities_to_wires(const std::vector<Sketc
if (order.size() != ms.size()) return {};
// One TopoDS_Vertex per node at the welded world point. Tolerance widened to
// kSketchWeldTol because MakeEdge(curve, va, vb) projects each vertex onto the
// `tol` because MakeEdge(curve, va, vb) projects each vertex onto the
// curve within the vertex tolerance (BRepLib_MakeEdge::Init), and a welded node
// can be up to the weld gap off another entity's curve.
// can be up to the weld gap off another entity's curve. OCCT must never be handed
// a zero vertex tolerance, so clamp at Precision::Confusion() when tol == 0.
std::vector<TopoDS_Vertex> verts(node_pt.size());
BRep_Builder B;
for (size_t i = 0; i < node_pt.size(); ++i) {
Vec3d w = plane.to_world(node_pt[i]);
verts[i] = BRepBuilderAPI_MakeVertex(gp_Pnt(w.x(), w.y(), w.z())).Vertex();
B.UpdateVertex(verts[i], kSketchWeldTol);
B.UpdateVertex(verts[i], std::max(tol, Precision::Confusion()));
}
for (size_t o : order) {
+18
View File
@@ -78,6 +78,24 @@ struct SketchProfile {
void serialize(Archive& ar) { ar(points, closed); }
};
// Two sketch endpoints this close are ONE joint. Shared deliberately by the viewport
// (region_loops / connected_loop / open-end detection) and by the kernel
// (entities_to_wires): the viewport is what shades a region closed and offers it for
// extrude, so the kernel MUST be able to build every loop the viewport shades. When
// these two numbers disagreed the viewport promised a closed region at 1e-3 and the
// kernel refused it at 1e-4, which extruded a solid the user never drew.
// Nothing legitimate in a mm-scale sketch is 1 um apart.
inline constexpr double kSketchJoinTol = 1e-3; // mm
// Effective sketch joint tolerance. ONE value for the viewport (region_loops /
// loop_report / connected_loop) and the kernel (entities_to_wires): if these ever
// disagree again, the viewport shades a region closed that the kernel refuses to
// build, which is how a sketch got extruded into the wrong solid. The GUI pushes
// the "auto_close_sketch_loops" preference in via set_sketch_auto_close(); the
// kernel defaults to ON so headless/kernel-only callers keep welding.
double sketch_join_tol();
void set_sketch_auto_close(bool on);
enum class SketchConstraintType {
Fix, Coincident, Horizontal, Vertical, Distance,
LockX, LockY, EqualLength, Parallel, Perpendicular,