From 45494c603521bbe6ebe735b96ff33d96af9c1762 Mon Sep 17 00:00:00 2001 From: Tommaso Bianchi Date: Mon, 31 Aug 2026 02:29:24 +0200 Subject: [PATCH] The origin and the two axes become things you can constrain to Port of snaporca 5b1294de59. Parity OK: 17 files identical, 8 diverging at their expected counts. Every industrial sketcher gives you the origin and the axes as references. Here the origin was only a SNAP target and the axes did not exist, so Symmetric needed a third picked ENTITY as its mirror axis: symmetry about the sketch's vertical axis first required drawing a construction line. Every constraint reference resolves through four lambdas in the solver (valid/ptOf/primOf/coordOf), so teaching those about three negative sentinel indices makes the origin and both axes available to EVERY constraint type at once. No new SketchEntity type, no serialization change; -1 still means "unset". The references live in G_FIXED and add no degrees of freedom, which a test asserts via the reported DoF. SymmetricAboutY / SymmetricAboutX are two buttons that need no third pick and no construction line. Making the axes clickable in the viewport is deliberately left out: that is canvas hit-testing work with its own risks. Recorded in the tests because it will catch the next person: sys.dragged[] is populated only during a drag, so a plain sketch_solve of an UNDER-constrained system may move any free parameter -- solvespace runs Newton, it does not minimise movement. PointOnLine onto an axis is one equation in two unknowns and the point legitimately slides along it. Those tests pin the free direction instead of asserting the other coordinate is untouched. VERIFICATION LIMIT, as with the previous two commits: this fork's kernel suite still cannot run (find_package(assimp) fails at configure, snaporca-w80c). The shared sources are byte-identical to snaporca's, where kernel is 2624 assertions / 206 cases and ALL LADDERS HELD across all seven rungs. --- resources/images/design_c_sym_h.svg | 1 + resources/images/design_c_sym_v.svg | 1 + src/libslic3r/CAD/SketchEngine.hpp | 14 +++- src/libslic3r/CAD/SketchSolver.cpp | 26 +++++- src/slic3r/GUI/CAD/DesignPanel.cpp | 28 +++++++ tests/libslic3r/test_slvs_constraints.cpp | 99 +++++++++++++++++++++++ 6 files changed, 167 insertions(+), 2 deletions(-) create mode 100644 resources/images/design_c_sym_h.svg create mode 100644 resources/images/design_c_sym_v.svg diff --git a/resources/images/design_c_sym_h.svg b/resources/images/design_c_sym_h.svg new file mode 100644 index 0000000000..4e152787b1 --- /dev/null +++ b/resources/images/design_c_sym_h.svg @@ -0,0 +1 @@ + diff --git a/resources/images/design_c_sym_v.svg b/resources/images/design_c_sym_v.svg new file mode 100644 index 0000000000..f4cd99371a --- /dev/null +++ b/resources/images/design_c_sym_v.svg @@ -0,0 +1 @@ + diff --git a/src/libslic3r/CAD/SketchEngine.hpp b/src/libslic3r/CAD/SketchEngine.hpp index 27ed4f21ea..2f2fcdf7ca 100644 --- a/src/libslic3r/CAD/SketchEngine.hpp +++ b/src/libslic3r/CAD/SketchEngine.hpp @@ -90,7 +90,9 @@ enum class SketchConstraintType { EqualRadius, Collinear, DistanceX, // |dx| between two points, projected onto the sketch X axis - DistanceY // |dy| between two points, projected onto the sketch Y axis + DistanceY, // |dy| between two points, projected onto the sketch Y axis + SymmetricAboutY, // mirror across the sketch's vertical axis (x = 0); axis is implicit + SymmetricAboutX // mirror across the sketch's horizontal axis (y = 0); axis is implicit }; // Constraint on a SketchProfile, referencing profile point indices (a,b,c,d). @@ -125,6 +127,16 @@ struct SketchEntityConstraintDef { template void serialize(Archive& ar) { ar(type, ea, eb, ra, rb, value, ec, rc); } }; +// Implicit references every sketch has, addressable from a constraint's ea/eb/ec without +// existing as SketchEntity objects. NEGATIVE so they cannot collide with an entity index; +// -1 is already "unset" and stays that way. Values are serialized inside existing int +// fields, so they are append-only in spirit: never renumber these. +constexpr int kSketchRefOrigin = -2; // the sketch origin point (0,0) +constexpr int kSketchRefAxisX = -3; // the sketch X axis, through the origin, +X +constexpr int kSketchRefAxisY = -4; // the sketch Y axis, through the origin, +Y + +inline bool is_sketch_ref(int ei) { return ei <= kSketchRefOrigin; } + // Solve a bare entity list in place against entity-form constraints. Shared by // CadDocument::solve_sketch_feature (committed features) and the in-session GUI // sketch tool (live solving as dimensions/constraints are added). Returns true on diff --git a/src/libslic3r/CAD/SketchSolver.cpp b/src/libslic3r/CAD/SketchSolver.cpp index 0b168ecb85..e206b4bfdd 100644 --- a/src/libslic3r/CAD/SketchSolver.cpp +++ b/src/libslic3r/CAD/SketchSolver.cpp @@ -82,6 +82,15 @@ static SketchSolveResult solve_system(std::vector& entities, const Slvs_hEntity dir_y [[maybe_unused]] = b.E(Slvs_MakeLineSegment(++b.eh, G_FIXED, b.wp, b.pt2d(G_FIXED, 0.0, 1.0), b.pt2d(G_FIXED, 0.0, 0.0))); + // Implicit sketch references (origin, X axis, Y axis), addressable by the negative + // sentinels in SketchEngine.hpp. G_FIXED: held constant, zero added DOF. The axis lines + // are built head-first so their direction reads +X / +Y, matching dir_x / dir_y. + const Slvs_hEntity ref_origin_pt = b.pt2d(G_FIXED, 0.0, 0.0); + const Slvs_hEntity ref_axis_x = b.E(Slvs_MakeLineSegment(++b.eh, G_FIXED, b.wp, + b.pt2d(G_FIXED, 1.0, 0.0), ref_origin_pt)); + const Slvs_hEntity ref_axis_y = b.E(Slvs_MakeLineSegment(++b.eh, G_FIXED, b.wp, + b.pt2d(G_FIXED, 0.0, 1.0), ref_origin_pt)); + // ---- Entities ------------------------------------------------------------------- std::vector slot(entities.size()); for (size_t i = 0; i < entities.size(); ++i) { @@ -138,6 +147,8 @@ static SketchSolveResult solve_system(std::vector& entities, auto valid = [&](int ei) { return ei >= 0 && ei < int(entities.size()); }; auto ptOf = [&](int ei, Role r) -> Slvs_hEntity { + if (ei == kSketchRefOrigin) return ref_origin_pt; + if (ei == kSketchRefAxisX || ei == kSketchRefAxisY) return ref_origin_pt; // axes pass through it if (!valid(ei)) return 0; const Slots& s = slot[ei]; switch (r) { @@ -147,8 +158,13 @@ static SketchSolveResult solve_system(std::vector& entities, } return 0; }; - auto primOf = [&](int ei) -> Slvs_hEntity { return valid(ei) ? slot[ei].prim : 0; }; + auto primOf = [&](int ei) -> Slvs_hEntity { + if (ei == kSketchRefAxisX) return ref_axis_x; + if (ei == kSketchRefAxisY) return ref_axis_y; + return valid(ei) ? slot[ei].prim : 0; // origin has no prim: it is a point + }; auto coordOf = [&](int ei, Role r) -> Vec2d { + if (is_sketch_ref(ei)) return Vec2d(0, 0); // all three pass through the origin if (!valid(ei)) return Vec2d(0, 0); const SketchEntity& e = entities[ei]; switch (r) { case Role::P0: return e.p0; case Role::P1: return e.p1; case Role::Center: return e.center; } @@ -183,6 +199,8 @@ static SketchSolveResult solve_system(std::vector& entities, ref_ok = ptOf(c.ea, c.ra) && primOf(c.eb); break; case CT::Symmetric: ref_ok = ptOf(c.ea, c.ra) && ptOf(c.eb, c.rb) && primOf(c.ec); break; + case CT::SymmetricAboutY: case CT::SymmetricAboutX: + ref_ok = ptOf(c.ea, c.ra) && ptOf(c.eb, c.rb); break; case CT::PointOnLine: case CT::PointOnObject: ref_ok = ptOf(c.ea, c.ra) && primOf(c.eb); break; case CT::EqualRadius: @@ -245,6 +263,12 @@ static SketchSolveResult solve_system(std::vector& entities, // ptA, ptB symmetric about the axis line (ec). b.C(SLVS_C_SYMMETRIC_LINE, 0, ptOf(c.ea, c.ra), ptOf(c.eb, c.rb), primOf(c.ec), 0); break; + case CT::SymmetricAboutY: + b.C(SLVS_C_SYMMETRIC_LINE, 0, ptOf(c.ea, c.ra), ptOf(c.eb, c.rb), primOf(kSketchRefAxisY), 0); + break; + case CT::SymmetricAboutX: + b.C(SLVS_C_SYMMETRIC_LINE, 0, ptOf(c.ea, c.ra), ptOf(c.eb, c.rb), primOf(kSketchRefAxisX), 0); + break; case CT::Angle: // model stores radians; slvs angle is in degrees. b.C(SLVS_C_ANGLE, c.value * 180.0 / M_PI, 0, 0, primOf(c.ea), primOf(c.eb)); diff --git a/src/slic3r/GUI/CAD/DesignPanel.cpp b/src/slic3r/GUI/CAD/DesignPanel.cpp index 07e46ff24d..7a3e6a170e 100644 --- a/src/slic3r/GUI/CAD/DesignPanel.cpp +++ b/src/slic3r/GUI/CAD/DesignPanel.cpp @@ -1526,6 +1526,8 @@ DesignPanel::DesignPanel(wxWindow* parent) cbtn("design_c_tangent", _L("Tangent"), SketchConstraintType::Tangent); cbtn("design_c_midpoint", _L("Midpoint"), SketchConstraintType::Midpoint); cbtn("design_c_symmetric", _L("Symmetric"), SketchConstraintType::Symmetric); + cbtn("design_c_sym_v", _L("Symmetric about the vertical axis"), SketchConstraintType::SymmetricAboutY); + cbtn("design_c_sym_h", _L("Symmetric about the horizontal axis"), SketchConstraintType::SymmetricAboutX); cbtn("design_c_angle", _L("Angle"), SketchConstraintType::Angle); cbtn("design_c_radius", _L("Radius"), SketchConstraintType::Radius); cbtn("design_c_diameter", _L("Diameter"), SketchConstraintType::Diameter); @@ -7737,6 +7739,7 @@ void DesignPanel::apply_entity_constraint(SketchConstraintType type) type == T::Angle || type == T::Midpoint || type == T::Symmetric || type == T::EqualRadius || type == T::Collinear || + type == T::SymmetricAboutY || type == T::SymmetricAboutX || type == T::DistanceX || type == T::DistanceY); if (e0 < 0 || e0 >= int(feat.entities.size()) || (needs_two && (e1 < 0 || e1 >= int(feat.entities.size())))) { @@ -7881,6 +7884,27 @@ void DesignPanel::apply_entity_constraint(SketchConstraintType type) commit_entity_constraints(defs); return; // multi-def commit done here } + case T::SymmetricAboutY: + case T::SymmetricAboutX: { + // Two entities made symmetric about the sketch's vertical/horizontal axis, which + // is implicit (no picked axis line). Picks: slot0=A, slot1=B. Two Points -> one + // pair; two Lines -> endpoint pairs. The axis is a negative sentinel in ec. + using ET = SketchEntity::Type; + const int axis = (type == T::SymmetricAboutY) ? kSketchRefAxisY : kSketchRefAxisX; + const ET ta = feat.entities[e0].type, tb = feat.entities[e1].type; + std::vector defs; + auto mk = [&](R ra, R rb) { + SketchEntityConstraintDef d; + d.type = type; + d.ea = e0; d.ra = ra; d.eb = e1; d.rb = rb; d.ec = axis; + defs.push_back(d); + }; + if (ta == ET::Point && tb == ET::Point) { mk(R::P0, R::P0); } + else if (ta == ET::Line && tb == ET::Line) { mk(R::P0, R::P0); mk(R::P1, R::P1); } + else { fail(_L("Symmetric needs two points or two lines")); return; } + commit_entity_constraints(defs); + return; // multi-def commit done here + } case T::EqualRadius: { if (!is_round(feat.entities[e0]) || !is_round(feat.entities[e1])) { fail(_L("Equal radius needs two circles or arcs")); return; @@ -8050,6 +8074,10 @@ wxString DesignPanel::constraint_label(const SketchEntityConstraintDef& d) const case T::Midpoint: return two(_L("Midpoint")); case T::Symmetric: return wxString::Format(_L("Symmetric %s — %s / %s"), tag(d.ea, d.ra), tag(d.eb, d.rb), tag(d.ec, d.rc)); + case T::SymmetricAboutY: return wxString::Format(_L("Symmetric about Y axis %s — %s"), + tag(d.ea, d.ra), tag(d.eb, d.rb)); + case T::SymmetricAboutX: return wxString::Format(_L("Symmetric about X axis %s — %s"), + tag(d.ea, d.ra), tag(d.eb, d.rb)); case T::Angle: return wxString::Format("%s = %s°", two(_L("Angle")), en_format(d.value * 180.0 / M_PI, 1)); case T::Radius: return wxString::Format("%s %s = %s", _L("Radius"), tag(d.ea, d.ra), en_format(d.value)); case T::Diameter: return wxString::Format("%s %s = %s", _L("Diameter"), tag(d.ea, d.ra), en_format(d.value)); diff --git a/tests/libslic3r/test_slvs_constraints.cpp b/tests/libslic3r/test_slvs_constraints.cpp index 95734a81d9..b665a1df0c 100644 --- a/tests/libslic3r/test_slvs_constraints.cpp +++ b/tests/libslic3r/test_slvs_constraints.cpp @@ -300,3 +300,102 @@ TEST_CASE("slvs: applying a point's own distance-x is a no-op", "[slvs][CadDocum CHECK(ents[0].p1.x() == Approx(-4.0).margin(1e-9)); // stayed left, did not flip to +4 CHECK(ents[0].p1.y() == Approx(7.0).margin(1e-9)); } + +static SketchEntity point(Vec2d p) +{ + SketchEntity e; e.type = SketchEntity::Type::Point; e.p0 = p; return e; +} + +TEST_CASE("slvs: coincident onto the origin sentinel pins a point", "[slvs][CadDocument]") +{ + std::vector ents = { point({5, 5}) }; + std::vector cons = { + con(CT::Coincident, 0, R::P0, kSketchRefOrigin, R::P0), + }; + auto res = sketch_solve(ents, cons); + REQUIRE(res.ok); + CHECK(ents[0].p0.x() == Approx(0.0).margin(1e-9)); + CHECK(ents[0].p0.y() == Approx(0.0).margin(1e-9)); +} + +// NOTE on why these pin the free direction instead of asserting "the other coordinate is +// left alone". sys.dragged[] is populated only while a drag is in progress, so a plain +// sketch_solve of an UNDER-constrained system is free to move any parameter -- solvespace +// runs a Newton iteration, it does not minimise movement. PointOnLine alone is one equation +// in two unknowns, and the point measurably slides along the axis (from (7,4) to (4,0)). +// That is legal, not a defect, so the well-posed test states both coordinates. +TEST_CASE("slvs: point-on-line onto the X axis, located along it from the origin", "[slvs][CadDocument]") +{ + std::vector ents = { point({7, 4}) }; + std::vector cons = { + con(CT::PointOnLine, 0, R::P0, kSketchRefAxisX, R::P0), + con(CT::DistanceX, kSketchRefOrigin, R::P0, 0, R::P0, 7.0), // both sentinels at once + }; + auto res = sketch_solve(ents, cons); + REQUIRE(res.ok); + CHECK(ents[0].p0.y() == Approx(0.0).margin(1e-9)); // driven onto the X axis + CHECK(ents[0].p0.x() == Approx(7.0).margin(1e-9)); // and located along it +} + +TEST_CASE("slvs: point-on-line onto the Y axis, located along it from the origin", "[slvs][CadDocument]") +{ + std::vector ents = { point({4, 7}) }; + std::vector cons = { + con(CT::PointOnLine, 0, R::P0, kSketchRefAxisY, R::P0), + con(CT::DistanceY, kSketchRefOrigin, R::P0, 0, R::P0, 7.0), + }; + auto res = sketch_solve(ents, cons); + REQUIRE(res.ok); + CHECK(ents[0].p0.x() == Approx(0.0).margin(1e-9)); // driven onto the Y axis + CHECK(ents[0].p0.y() == Approx(7.0).margin(1e-9)); // and located along it +} + +TEST_CASE("slvs: parallel to the X axis levels a line without collapsing it", "[slvs][CadDocument]") +{ + std::vector ents = { line({0, 0}, {10, 3}) }; + std::vector cons = { + con(CT::Fix, 0, R::P0, 0, R::P0), + con(CT::Parallel, 0, R::P0, kSketchRefAxisX, R::P0), + }; + auto res = sketch_solve(ents, cons); + REQUIRE(res.ok); + CHECK(ents[0].p1.y() == Approx(0.0).margin(1e-9)); // leveled onto y = 0 + // A bare Parallel leaves length free; the solver preserves the endpoint's free + // x-coordinate, so the line lands at (10,0) — length 10, not the original sqrt(109). + // Assert that free coordinate rather than abs(): a flipped/collapsed line would not + // land exactly here. + CHECK(ents[0].p1.x() == Approx(10.0).margin(1e-9)); + CHECK((ents[0].p1 - ents[0].p0).norm() == Approx(10.0).margin(1e-6)); // did not collapse +} + +TEST_CASE("slvs: symmetric-about-Y mirrors two points across x = 0", "[slvs][CadDocument]") +{ + std::vector ents = { point({3, 5}), point({9, 5}) }; + std::vector cons = { + con(CT::SymmetricAboutY, 0, R::P0, 1, R::P0), + }; + auto res = sketch_solve(ents, cons); + REQUIRE(res.ok); + CHECK(ents[0].p0.x() == Approx(-ents[1].p0.x()).margin(1e-9)); // mirror across x = 0 + // Neither x may be 0: a both-collapsed-to-the-axis solution also satisfies the mirror + // trivially. Squared, not abs(), so a near-zero x still fails cleanly. + CHECK(ents[0].p0.x() * ents[0].p0.x() > 1e-12); + CHECK(ents[1].p0.x() * ents[1].p0.x() > 1e-12); + CHECK(ents[0].p0.y() == Approx(5.0).margin(1e-9)); // Y values untouched + CHECK(ents[1].p0.y() == Approx(5.0).margin(1e-9)); +} + +TEST_CASE("slvs: reference-based constraint adds no degrees of freedom", "[slvs][CadDocument]") +{ + // A free line with Fix on P0 and Parallel to the X axis: 4 DoF - 2 (fix) - 1 (angle) + // = 1 (length still free). If the G_FIXED reference entities leaked unknowns into the + // solved group, this figure would be wrong. + std::vector ents = { line({0, 0}, {3, 4}) }; + std::vector cons = { + con(CT::Fix, 0, R::P0, 0, R::P0), + con(CT::Parallel, 0, R::P0, kSketchRefAxisX, R::P0), + }; + auto res = sketch_solve(ents, cons); + REQUIRE(res.ok); + CHECK(res.dof == 1); +}