mirror of
https://github.com/OrcaSlicer/OrcaSlicer.git
synced 2026-09-18 22:42:37 +00:00
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:
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user