diff --git a/resources/images/design_c_dist_x.svg b/resources/images/design_c_dist_x.svg
new file mode 100644
index 0000000000..e811a58df6
--- /dev/null
+++ b/resources/images/design_c_dist_x.svg
@@ -0,0 +1 @@
+
diff --git a/resources/images/design_c_dist_y.svg b/resources/images/design_c_dist_y.svg
new file mode 100644
index 0000000000..5aefec85ee
--- /dev/null
+++ b/resources/images/design_c_dist_y.svg
@@ -0,0 +1 @@
+
diff --git a/src/libslic3r/CAD/SketchEngine.hpp b/src/libslic3r/CAD/SketchEngine.hpp
index 571a5e0428..27ed4f21ea 100644
--- a/src/libslic3r/CAD/SketchEngine.hpp
+++ b/src/libslic3r/CAD/SketchEngine.hpp
@@ -88,7 +88,9 @@ enum class SketchConstraintType {
// Append-only: cereal serializes this enum positionally as its underlying int, so
// inserting anywhere but the end reinterprets every constraint in every saved recipe.
EqualRadius,
- Collinear
+ Collinear,
+ DistanceX, // |dx| between two points, projected onto the sketch X axis
+ DistanceY // |dy| between two points, projected onto the sketch Y axis
};
// Constraint on a SketchProfile, referencing profile point indices (a,b,c,d).
diff --git a/src/libslic3r/CAD/SketchSolver.cpp b/src/libslic3r/CAD/SketchSolver.cpp
index 248ff95beb..0b168ecb85 100644
--- a/src/libslic3r/CAD/SketchSolver.cpp
+++ b/src/libslic3r/CAD/SketchSolver.cpp
@@ -73,6 +73,15 @@ static SketchSolveResult solve_system(std::vector& entities,
b.P(G_FIXED, qw), b.P(G_FIXED, qx), b.P(G_FIXED, qy), b.P(G_FIXED, qz)));
b.wp = b.E(Slvs_MakeWorkplane(++b.eh, G_FIXED, origin, b.normal));
+ // Unit direction references for the axis-projected distance constraints. Both live in
+ // G_FIXED, so they are held constant and add no DOF to the system.
+ // libslvs defines a LINE_SEGMENT's direction as point[0] - point[1] (entity.cpp
+ // VectorGetExprs), so the unit vector's head is listed first to yield +X / +Y.
+ const Slvs_hEntity dir_x [[maybe_unused]] = b.E(Slvs_MakeLineSegment(++b.eh, G_FIXED, b.wp,
+ b.pt2d(G_FIXED, 1.0, 0.0), b.pt2d(G_FIXED, 0.0, 0.0)));
+ 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)));
+
// ---- Entities -------------------------------------------------------------------
std::vector slot(entities.size());
for (size_t i = 0; i < entities.size(); ++i) {
@@ -158,6 +167,9 @@ static SketchSolveResult solve_system(std::vector& entities,
switch (c.type) {
case CT::Coincident: case CT::Horizontal: case CT::Vertical: case CT::Distance:
ref_ok = ptOf(c.ea, c.ra) && ptOf(c.eb, c.rb); break;
+ case CT::DistanceX:
+ case CT::DistanceY:
+ ref_ok = ptOf(c.ea, c.ra) && ptOf(c.eb, c.rb); break;
case CT::Concentric:
ref_ok = ptOf(c.ea, Role::Center) && ptOf(c.eb, Role::Center); break;
case CT::Fix: case CT::LockX: case CT::LockY:
@@ -194,6 +206,14 @@ static SketchSolveResult solve_system(std::vector& entities,
case CT::Distance:
b.C(SLVS_C_PT_PT_DISTANCE, c.value, ptOf(c.ea, c.ra), ptOf(c.eb, c.rb), 0, 0);
break;
+ case CT::DistanceX:
+ // Distance between the two points measured along X only: project the vector
+ // between them onto the fixed unit X direction.
+ b.C(SLVS_C_PROJ_PT_DISTANCE, c.value, ptOf(c.ea, c.ra), ptOf(c.eb, c.rb), dir_x, 0);
+ break;
+ case CT::DistanceY:
+ b.C(SLVS_C_PROJ_PT_DISTANCE, c.value, ptOf(c.ea, c.ra), ptOf(c.eb, c.rb), dir_y, 0);
+ break;
case CT::Fix: {
const Vec2d p = coordOf(c.ea, c.ra);
b.C(SLVS_C_POINTS_COINCIDENT, 0, ptOf(c.ea, c.ra), fixedRef(p.x(), p.y()), 0, 0);
diff --git a/src/slic3r/GUI/CAD/DesignPanel.cpp b/src/slic3r/GUI/CAD/DesignPanel.cpp
index 003c6bc311..07e46ff24d 100644
--- a/src/slic3r/GUI/CAD/DesignPanel.cpp
+++ b/src/slic3r/GUI/CAD/DesignPanel.cpp
@@ -1530,6 +1530,8 @@ DesignPanel::DesignPanel(wxWindow* parent)
cbtn("design_c_radius", _L("Radius"), SketchConstraintType::Radius);
cbtn("design_c_diameter", _L("Diameter"), SketchConstraintType::Diameter);
cbtn("design_c_fix", _L("Fix point (anchor in place)"), SketchConstraintType::Fix);
+ cbtn("design_c_dist_x", _L("Horizontal distance"), SketchConstraintType::DistanceX);
+ cbtn("design_c_dist_y", _L("Vertical distance"), SketchConstraintType::DistanceY);
// Trim/Extend are now standalone SKETCH scissors (Mode::Trim/Extend) in the sketch
// toolbar, NOT Constrain buttons. The other edit ops (Mirror/Offset/Fillet/Chamfer/
// Move/…) are first-class sketch tools too. Done constraining = the action-bar ✓.
@@ -7734,7 +7736,8 @@ void DesignPanel::apply_entity_constraint(SketchConstraintType type)
type == T::Concentric || type == T::Tangent ||
type == T::Angle || type == T::Midpoint ||
type == T::Symmetric || type == T::EqualRadius ||
- type == T::Collinear);
+ type == T::Collinear ||
+ type == T::DistanceX || type == T::DistanceY);
if (e0 < 0 || e0 >= int(feat.entities.size()) ||
(needs_two && (e1 < 0 || e1 >= int(feat.entities.size())))) {
fail(needs_two ? _L("Pick two entities first") : _L("Pick an entity first"));
@@ -7772,6 +7775,41 @@ void DesignPanel::apply_entity_constraint(SketchConstraintType type)
def.ea = e0; def.ra = ra; def.eb = e1; def.rb = rb;
break;
}
+ case T::DistanceX:
+ case T::DistanceY: {
+ // Axis-projected distance between the closest endpoint pair of the two picked
+ // entities. Typed in-canvas pre-filled with the current projection, committed on
+ // the typed value (same deferred pattern as Angle).
+ const SketchEntity& A = feat.entities[e0];
+ const SketchEntity& B = feat.entities[e1];
+ const std::pair aps[2] = {{R::P0, A.p0}, {R::P1, A.p1}};
+ const std::pair bps[2] = {{R::P0, B.p0}, {R::P1, B.p1}};
+ R ra = R::P1, rb = R::P0;
+ Vec2d pa = A.p1, pb = B.p0;
+ double best = 1e30;
+ for (const auto& ap : aps)
+ for (const auto& bp : bps) {
+ const double d = (ap.second - bp.second).squaredNorm();
+ if (d < best) { best = d; ra = ap.first; rb = bp.first; pa = ap.second; pb = bp.second; }
+ }
+ // The constraint is SIGNED: PROJ_PT_DISTANCE fixes (pB - pA).dot(axis), not its
+ // magnitude. Showing |delta| while the current signed delta is negative would mean
+ // that opening the dimension and simply accepting the number on screen flips the
+ // point to the other side of its anchor. Opening a dimension and accepting its own
+ // value must be a no-op, so order the two refs to make the shown value the positive
+ // one -- which is also how a dimension ought to read.
+ int a = e0, b = e1;
+ double delta = (type == T::DistanceX) ? (pb.x() - pa.x()) : (pb.y() - pa.y());
+ if (delta < 0.0) { std::swap(a, b); std::swap(ra, rb); delta = -delta; }
+ const double cur = delta;
+ const T tt = type;
+ m_viewport->open_inline_value(cur, [this, a, b, ra, rb, tt](double v) {
+ SketchEntityConstraintDef d;
+ d.type = tt; d.ea = a; d.ra = ra; d.eb = b; d.rb = rb; d.value = v;
+ commit_entity_constraint(d);
+ });
+ return; // deferred: commit runs on the typed value
+ }
case T::Concentric: {
// Two circles/arcs: make their centres coincide.
if (!is_round(feat.entities[e0]) || !is_round(feat.entities[e1])) {
diff --git a/tests/libslic3r/test_slvs_constraints.cpp b/tests/libslic3r/test_slvs_constraints.cpp
index 9bbadd614d..95734a81d9 100644
--- a/tests/libslic3r/test_slvs_constraints.cpp
+++ b/tests/libslic3r/test_slvs_constraints.cpp
@@ -222,3 +222,81 @@ TEST_CASE("slvs: collinear on already-collinear lines moves nothing", "[slvs][Ca
CHECK(ents[i].p1.y() == Approx(before[i].p1.y()).margin(1e-9));
}
}
+
+TEST_CASE("slvs: distance-x drives the horizontal gap and leaves Y alone", "[slvs][CadDocument]")
+{
+ std::vector ents = { line({0, 0}, {3, 7}) };
+ std::vector cons = {
+ con(CT::Fix, 0, R::P0, 0, R::P0),
+ con(CT::DistanceX, 0, R::P0, 0, R::P1, 10.0),
+ };
+ auto res = sketch_solve(ents, cons);
+ REQUIRE(res.ok);
+ // SIGNED, not abs. PROJ_PT_DISTANCE constrains (pB - pA).dot(unit(dir)), and a
+ // LINE_SEGMENT's direction is point[0] - point[1] (slvs entity.cpp), so the reference
+ // line is built head-first to mean +X. Assert on abs and a flipped reference passes
+ // while every dimension lands the point on the wrong side of its anchor.
+ CHECK(ents[0].p1.x() - ents[0].p0.x() == Approx(10.0).margin(1e-9));
+ CHECK(ents[0].p1.y() == Approx(7.0).margin(1e-9)); // Y must not be disturbed
+}
+
+TEST_CASE("slvs: distance-y drives the vertical gap and leaves X alone", "[slvs][CadDocument]")
+{
+ std::vector ents = { line({0, 0}, {3, 7}) };
+ std::vector cons = {
+ con(CT::Fix, 0, R::P0, 0, R::P0),
+ con(CT::DistanceY, 0, R::P0, 0, R::P1, 10.0),
+ };
+ auto res = sketch_solve(ents, cons);
+ REQUIRE(res.ok);
+ CHECK(ents[0].p1.y() - ents[0].p0.y() == Approx(10.0).margin(1e-9)); // signed: see above
+ CHECK(ents[0].p1.x() == Approx(3.0).margin(1e-9)); // X must not be disturbed
+}
+
+TEST_CASE("slvs: distance-x is not the straight-line distance", "[slvs][CadDocument]")
+{
+ // B is at straight-line distance 10 from A; DistanceX = 6 is already satisfied, so a
+ // correct projection leaves B untouched. This is the case that fails if the constraint
+ // were wired to SLVS_C_PT_PT_DISTANCE, which would drag B onto the radius-6 circle.
+ std::vector ents = { line({0, 0}, {6, 8}) };
+ std::vector cons = {
+ con(CT::Fix, 0, R::P0, 0, R::P0),
+ con(CT::DistanceX, 0, R::P0, 0, R::P1, 6.0),
+ };
+ auto res = sketch_solve(ents, cons);
+ REQUIRE(res.ok);
+ CHECK(ents[0].p1.x() == Approx(6.0).margin(1e-9));
+ CHECK(ents[0].p1.y() == Approx(8.0).margin(1e-9));
+}
+
+TEST_CASE("slvs: distance-x plus distance-y fully locates a point", "[slvs][CadDocument]")
+{
+ std::vector ents = { line({0, 0}, {1, 1}) };
+ std::vector cons = {
+ con(CT::Fix, 0, R::P0, 0, R::P0),
+ con(CT::DistanceX, 0, R::P0, 0, R::P1, 4.0),
+ con(CT::DistanceY, 0, R::P0, 0, R::P1, 3.0),
+ };
+ auto res = sketch_solve(ents, cons);
+ REQUIRE(res.ok);
+ CHECK(ents[0].p1.x() - ents[0].p0.x() == Approx(4.0).margin(1e-9)); // signed: see above
+ CHECK(ents[0].p1.y() - ents[0].p0.y() == Approx(3.0).margin(1e-9));
+}
+
+// The property the GUI's ref-ordering exists to preserve: DistanceX is SIGNED, so applying
+// the CURRENT projected delta as the target must not move anything. If the refs are ordered
+// so the shown value is positive while the actual signed delta is negative, accepting the
+// value a dimension opens with teleports the point to the other side of its anchor.
+TEST_CASE("slvs: applying a point's own distance-x is a no-op", "[slvs][CadDocument]")
+{
+ // p1 sits to the LEFT of p0, so the signed delta p1 - p0 is negative.
+ std::vector ents = { line({0, 0}, {-4, 7}) };
+ std::vector cons = {
+ con(CT::Fix, 0, R::P0, 0, R::P0),
+ con(CT::DistanceX, 0, R::P0, 0, R::P1, -4.0), // the CURRENT signed delta
+ };
+ auto res = sketch_solve(ents, cons);
+ REQUIRE(res.ok);
+ 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));
+}