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); +}