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