Port from snaporca: sketch-only projects save, scripted geometry arrives exact,

and a ladder that draws with the mouse

Three commits carried across (snaporca 4ffd60eacb, 421055c2ec, b71216ce0b):

1. A design made only of sketches must survive being saved. CadDocument::recompute
   returned false with "no solid-producing features" for a document that has no
   solid, and two callers read that as "unusable": the GUI syncs the 3MF recipe
   only after a successful recompute, so a sketch-only design was saved with no
   recipe at all, and deserialize_recipe ends with `return recompute()`, so even a
   project that carried one was refused on load. Having nothing to build is now a
   success; a feature that MEANT to build a solid and produced none still fails.
   DesignPanel::refresh_tree syncs the recipe too, for the paths that call
   m_doc.recompute() directly.

2. Scripted geometry arrives exact. The Horizontal/Vertical inference window and
   the endpoint weld window both close to zero for add_entities_scripted; void
   attribution probes from a point strictly inside each loop instead of from its
   first vertex. Corpus rung 39 graded / 39 fully clean, was 35 with 6 failures.

3. scripts/gui-ladder.py — 17 rungs, 84 properties, all driven by synthetic clicks
   and typed values rather than through the socket.

Parity 17 identical / 8 diverging as expected. Kernel suite here: 188 cases /
2532 assertions.

snaporca-mtav, snaporca-8xg1, snaporca-5hvl, snaporca-730j
This commit is contained in:
Tommaso Bianchi
2026-08-23 01:22:32 +02:00
parent 05ce2607a8
commit 82db99f337
7 changed files with 1211 additions and 11 deletions
File diff suppressed because it is too large Load Diff
+34 -1
View File
@@ -202,6 +202,33 @@ def point_in(pt, ring):
return inside
def interior_point(ring):
"""A point strictly inside a simple closed ring (first == last).
The lowest vertex of a simple polygon is always convex, so stepping from it along the
bisector of its two edges goes inward; the step is a small fraction of the shorter edge so
it stays inside however sharp the corner is.
"""
q = ring[:-1] if len(ring) > 1 and ring[0] == ring[-1] else ring
if len(q) < 3:
return ring[0]
k = min(range(len(q)), key=lambda i: (q[i][1], q[i][0]))
v = q[k]
a = (q[(k - 1) % len(q)][0] - v[0], q[(k - 1) % len(q)][1] - v[1])
b = (q[(k + 1) % len(q)][0] - v[0], q[(k + 1) % len(q)][1] - v[1])
la, lb = math.hypot(*a), math.hypot(*b)
if la < 1e-12 or lb < 1e-12:
return v
a = (a[0] / la, a[1] / la)
b = (b[0] / lb, b[1] / lb)
bx, by = a[0] + b[0], a[1] + b[1]
n = math.hypot(bx, by)
if n < 1e-12:
return v
step = 1e-3 * min(la, lb)
return (v[0] + bx / n * step, v[1] + by / n * step)
# ── one drawing ──────────────────────────────────────────────────────────────
def grade(pdf, name, report):
segs = drawing_segments(pdf)
@@ -258,11 +285,17 @@ def grade(pdf, name, report):
# void. So compute the same rule here, independently, and compare the whole attribution.
if got:
rings = [outer] + voids
# Probe from a point STRICTLY INSIDE each ring, never from one of its vertices — the
# same rule the engine now uses (DesignSketchTool::region_loops). A vertex is exactly
# where two loops touch in a real drawing, and a ray cast from a point lying ON the
# polygon under test answers by rounding: that alone accounted for every one of the 6
# sheets where the two attributions used to disagree. snaporca-5hvl.
probes = [interior_point(r) for r in rings]
mine_parent = {}
for i, r in enumerate(rings):
best, best_a = -1, 0.0
for j, q in enumerate(rings):
if i == j or not point_in(r[0], q):
if i == j or not point_in(probes[i], q):
continue
a = shoelace(q)
if best < 0 or a < best_a:
+14 -2
View File
@@ -3510,6 +3510,14 @@ bool CadDocument::recompute()
error.clear();
detect_mate_conflicts();
std::vector<CadBody> built;
// Did any feature in this document even ASK for a solid? A document made only of sketches
// and datums has nothing to build, and that is a legitimate state — it is every document
// between drawing the first profile and extruding it. Reporting it as a failure is what
// made a sketch-only design unsaveable AND unopenable: DesignPanel::recompute_guarded syncs
// the 3MF recipe only "on success", so nothing was written, and deserialize_recipe ends with
// `return recompute()`, so a project that did carry a recipe was refused on load with
// "Could not restore the CAD model" while its features sat correctly in the list. snaporca-mtav.
bool any_solid_feature = false;
try {
// Parametric pass: evaluate document variables, then each feature's expression bindings,
// writing the results into the feature's numeric fields before geometry runs.
@@ -3525,6 +3533,8 @@ bool CadDocument::recompute()
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
// Past the skips: this feature is one that means to leave a body behind.
any_solid_feature = true;
if (f.type == CadFeatureType::Project) { apply_project(built, f); }
else { route_feature(built, f); }
// Record which feature made each body. "Still unset?" is the whole rule, and it is
@@ -3551,7 +3561,7 @@ bool CadDocument::recompute()
error = "unknown geometry error";
return false;
}
if (built.empty()) { error = "no solid-producing features"; return false; }
if (built.empty() && any_solid_feature) { error = "no solid-producing features"; return false; }
// A feature that leaves a body with a null shape must fail loudly. Until this existed,
// recompute() returned true and the document kept advertising the body: describe_scene
@@ -3624,7 +3634,9 @@ bool CadDocument::recompute()
display_mesh = tessellate_bodies(bodies, display_tri_face, display_tri_body,
display_body_meshes,
linear_deflection, angular_deflection);
if (display_mesh.its.indices.empty()) {
if (display_mesh.its.indices.empty() && any_solid_feature) {
// Empty only because there are no bodies to tessellate is the same legitimate state as
// above: a sketch-only document has nothing to draw as a solid, and that is not a fault.
error = "tessellation produced an empty mesh";
return false;
}
+18
View File
@@ -6630,6 +6630,24 @@ void DesignPanel::load_recipe(const std::string& blob)
void DesignPanel::refresh_tree()
{
// The recipe mirrors the FEATURE LIST, and this is the moment the feature list changed —
// every add, delete, reorder, rename and suppression ends here to redraw the tree. Putting
// the sync in recompute_guarded instead tied it to "a solid was built", and CadDocument::
// recompute() returns FALSE for a document that has no solid ("no solid-producing features",
// CadDocument.cpp) — which is precisely a document the user has only drawn sketches in. So
// drawing a profile, pressing Confirm and saving wrote a 3MF with no SnapOrca_cad.bin in it
// at all, and the app reported success: the whole design was gone on reopen (snaporca-mtav).
// The three sites that say "a lone sketch yields an empty body; that is expected" call
// m_doc.recompute() directly and so never reached the sync either. One hook here covers all
// of them, including the live sketch tool's own commit path.
//
// ONLY when the document has something in it. sync_recipe_to_model() CLEARS the blob for an
// empty document, and the tree is also refreshed while the Design tab is still empty — before
// the deferred load at on_show() has had the chance to read the blob the project arrived
// with. Clearing there would destroy the recipe of every project being opened. Deleting the
// last feature still clears it, through the tree-edit call site that always did.
if (!m_doc.features.empty()) sync_recipe_to_model();
// Preserve the selected row across the rebuild — wxTreeCtrl::DeleteAllItems
// drops the selection, which made every edit/add feel like it "lost" the
// selection (and broke Edit/Move/Delete on the just-touched feature).
+48 -5
View File
@@ -2311,7 +2311,7 @@ bool DesignSketchTool::try_add_constraints(const std::vector<SketchEntityConstra
return false;
}
void DesignSketchTool::infer_auto_constraints(int base, double ang_tol_rad)
void DesignSketchTool::infer_auto_constraints(int base, double ang_tol_rad, double weld_tol)
{
const int n = int(m_entities.size());
if (base < 0 || base >= n) return;
@@ -2341,7 +2341,7 @@ void DesignSketchTool::infer_auto_constraints(int base, double ang_tol_rad)
for (int b = 0; b < nj; ++b) {
if (j >= base && j < i) continue; // avoid duplicate (i,j)/(j,i)
Vec2d pb; if (!point_at(j, jr[b], pb)) continue;
if ((pa - pb).squaredNorm() > 1e-6) continue;
if ((pa - pb).squaredNorm() > weld_tol * weld_tol) continue;
if (has_coincident(i, ir[a], j, jr[b])) continue;
SketchEntityConstraintDef c;
c.type = SketchConstraintType::Coincident;
@@ -6261,9 +6261,17 @@ DesignSketchTool::region_loops(const std::vector<SketchEntity>& ents) const
// what Tommaso hit: a rectangle with a circle inside extruded to a plain box, because only
// the rectangle loop could be picked and only its entities were passed on.
//
// Loops in a well-formed sketch do not cross, so testing ONE vertex decides containment.
// Loops in a well-formed sketch do not cross, so testing ONE point decides containment.
// Each loop is assigned to the SMALLEST loop that contains it, which is what makes a hole
// belong to the region that actually bounds it rather than to every enclosing loop.
//
// The point must be STRICTLY INSIDE the loop, not one of its vertices. A vertex is exactly
// where two loops are most likely to touch in a real drawing — a bore breaking out through
// a boss wall, a slot that ends on an outline — and a ray cast from a point that lies ON the
// polygon being tested answers by rounding, so the same drawing can be read either way.
// Measured on the StudyCadCam corpus: the engine and an independent containment check
// disagreed on 6 of 39 sheets, and every disagreement was a probe point sitting on the other
// loop's boundary. snaporca-5hvl.
auto poly_area = [](const std::vector<Vec2d>& q) {
double a2 = 0.0;
for (size_t i = 0, j = q.size() - 1; i < q.size(); j = i++)
@@ -6280,13 +6288,36 @@ DesignSketchTool::region_loops(const std::vector<SketchEntity>& ents) const
}
return in;
};
// A point strictly inside a simple polygon: the lowest vertex of a simple polygon is always
// CONVEX, so stepping from it along the bisector of its two edges goes into the interior.
// The step is a small fraction of the shorter adjacent edge, so it stays inside however
// sharp the corner is.
auto interior_point = [](const std::vector<Vec2d>& q) {
size_t k = 0;
for (size_t i = 1; i < q.size(); ++i)
if (q[i].y() < q[k].y() || (q[i].y() == q[k].y() && q[i].x() < q[k].x())) k = i;
const Vec2d& v = q[k];
Vec2d a = q[(k + q.size() - 1) % q.size()] - v;
Vec2d b = q[(k + 1) % q.size()] - v;
const double la = a.norm(), lb = b.norm();
if (la < 1e-12 || lb < 1e-12) return v; // degenerate: nothing better to say
a /= la; b /= lb;
Vec2d bis = a + b;
if (bis.norm() < 1e-12) return v; // 180 deg spike: same
bis.normalize();
return Vec2d(v + bis * (1e-3 * std::min(la, lb)));
};
std::vector<Vec2d> probe(regions.size());
for (size_t i = 0; i < regions.size(); ++i)
if (regions[i].poly.size() >= 3) probe[i] = interior_point(regions[i].poly);
else if (!regions[i].poly.empty()) probe[i] = regions[i].poly.front();
for (size_t i = 0; i < regions.size(); ++i) {
if (regions[i].poly.empty()) continue;
int best = -1;
double best_area = 0.0;
for (size_t j = 0; j < regions.size(); ++j) {
if (i == j || regions[j].poly.size() < 3) continue;
if (!point_in(regions[i].poly.front(), regions[j].poly)) continue;
if (!point_in(probe[i], regions[j].poly)) continue;
const double a2 = poly_area(regions[j].poly);
if (best < 0 || a2 < best_area) { best = int(j); best_area = a2; }
}
@@ -8904,7 +8935,19 @@ int DesignSketchTool::add_entities_scripted(const std::vector<SketchEntity>& ent
// 0.067%, because several segments of the polygon fell inside that 3 degree window. Exact
// coincidence inference is unaffected — it already tests to 1e-6 — so chains still weld
// and genuinely axis-aligned scripted geometry still gets its Horizontal/Vertical.
infer_auto_constraints(base, 1e-4);
//
// ZERO, not 1e-4. Any window at all is a window that moves the caller's points, and 1e-4 rad
// was still wide enough to catch the short chords of a small flattened circle: on four of
// the 39 corpus drawings the loops that came back wrong were all TINY (1.4 to 13 mm^2), out
// by up to 7e-4 relative, because a 0.005 degree tilt on a 0.3 mm chord is inside 1e-4.
// With zero, only a segment that is EXACTLY axis-aligned is constrained, and constraining
// something already true cannot move it. snaporca-8xg1.
// The weld window closes too. Two endpoints a micron apart are not the same point when a
// caller typed both of them: on MPD681, 20 of 363 scripted segments were dragged onto a
// common point up to 0.0021 mm away, because welding is TRANSITIVE and three vertices near
// the origin chained into one. Exactly-equal endpoints still weld, which is what keeps a
// scripted profile closed — a ring's last point IS its first point.
infer_auto_constraints(base, 0.0, 0.0);
resolve_live();
return base;
}
+7 -1
View File
@@ -632,7 +632,13 @@ private:
// A gesture needs the default 3 degrees — nobody clicks a horizontal line exactly — but
// that same slack MOVES geometry that was given exactly, so the scripted path passes a
// tolerance tight enough to recognise only what is already true. See add_entities_scripted.
void infer_auto_constraints(int base, double ang_tol_rad = 3.0 * M_PI / 180.0);
// ang_tol_rad: how far off axis a segment may be and still be called Horizontal/Vertical.
// weld_tol: how far apart two endpoints may be and still be called Coincident.
// Both default to GESTURE slack. A scripted add passes zero for both: the caller has
// already said exactly what it means, and every non-zero window is a window in which the
// inference rewrites it. snaporca-8xg1.
void infer_auto_constraints(int base, double ang_tol_rad = 3.0 * M_PI / 180.0,
double weld_tol = 1e-3);
// Selection helpers (Mode::Select).
int hit_test(const Vec2d& p, double tol) const; // nearest entity within tol, or -1
+58 -2
View File
@@ -1328,10 +1328,15 @@ TEST_CASE("datum plane: offset + tilt resolution and sketching on it", "[CadDocu
CHECK_THAT(zmax, WithinAbs(34.0, 1e-6));
CHECK_THAT(double(doc.display_mesh.volume()), WithinRel(400.0, 0.02));
// A datum-plane-only document has no solid -> recompute is a benign failure.
// A datum-plane-only document has no solid, and that is a benign SUCCESS, not a benign
// failure. It used to return false, and "benign failure" is exactly the phrasing that hid
// snaporca-mtav: two callers read the false as "unusable document" and threw the design
// away — the 3MF recipe was never written, and a project that had one was refused on load.
CadDocument only_plane;
only_plane.add_plane(0, 10.0, 0.0, 0, "P");
REQUIRE_FALSE(only_plane.recompute());
REQUIRE(only_plane.recompute());
REQUIRE(only_plane.error.empty());
REQUIRE(only_plane.bodies.empty());
}
TEST_CASE("loft builds a solid skinning two profiles on parallel planes", "[CadDocument]")
@@ -8041,3 +8046,54 @@ TEST_CASE("add_extrude_entities builds a plate with a bore (clockwise circle)",
REQUIRE(face_count == 7);
}
// snaporca-mtav. A document that has only sketches in it is not a broken document, it is the
// state every design passes through between drawing a profile and extruding it. recompute()
// used to call that "no solid-producing features" and return false, and two things downstream
// read that false as "the document is unusable": the GUI syncs the 3MF recipe only after a
// successful recompute, so a sketch-only design was saved with NO recipe at all and vanished on
// reopen; and deserialize_recipe ends with `return recompute()`, so even a project that did
// carry one was refused on load. The failure has to stay for a document that ASKED for a solid
// and got none — that is a real geometry failure — so both halves are asserted here.
TEST_CASE("A sketch-only document recomputes and round-trips", "[CadDocument]")
{
CadDocument doc;
std::vector<SketchEntity> ents{
{SketchEntity::Type::Line, Vec2d(-60, -40), Vec2d(60, -40)},
{SketchEntity::Type::Line, Vec2d(60, -40), Vec2d(60, 40)},
{SketchEntity::Type::Line, Vec2d(60, 40), Vec2d(-60, 40)},
{SketchEntity::Type::Line, Vec2d(-60, 40), Vec2d(-60, -40)},
};
const int sk = doc.add_sketch_entities(ents, SketchPlane::XY(), "Profile");
REQUIRE(sk == 0);
const bool built = doc.recompute(); // nothing to build is not a failure
INFO("recompute error: " << doc.error);
REQUIRE(built);
REQUIRE(doc.error.empty());
REQUIRE(doc.bodies.empty());
const std::string blob = doc.serialize_recipe();
REQUIRE_FALSE(blob.empty());
CadDocument fresh;
REQUIRE(fresh.deserialize_recipe(blob));
REQUIRE(fresh.error.empty());
REQUIRE(fresh.features.size() == 1);
REQUIRE(fresh.features[0].name == "Profile");
REQUIRE(fresh.features[0].entities.size() == 4);
for (size_t i = 0; i < ents.size(); ++i) {
REQUIRE(fresh.features[0].entities[i].p0.x() == ents[i].p0.x());
REQUIRE(fresh.features[0].entities[i].p0.y() == ents[i].p0.y());
REQUIRE(fresh.features[0].entities[i].p1.x() == ents[i].p1.x());
REQUIRE(fresh.features[0].entities[i].p1.y() == ents[i].p1.y());
}
// The other half of the rule: a feature that MEANT to build a solid and produced none is
// still an error, and must not be swallowed by the change above.
CadDocument bad;
bad.add_sketch_entities(ents, SketchPlane::XY(), "Profile");
bad.add_extrude(0, 0.0, false, BooleanMode::New, "ZeroDepth");
REQUIRE_FALSE(bad.recompute());
REQUIRE_FALSE(bad.error.empty());
}