mirror of
https://github.com/OrcaSlicer/OrcaSlicer.git
synced 2026-09-18 14:32:36 +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:
File diff suppressed because it is too large
Load Diff
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user