Files
OrcaSlicer/tests/libslic3r/test_slvs_constraints.cpp
T
Tommaso Bianchi 45494c6035 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.
2026-08-31 02:29:24 +02:00

402 lines
18 KiB
C++

#include <catch2/catch_all.hpp> // mainline OrcaSlicer ships Catch2 v3 (v2 was catch2/catch.hpp)
using Catch::Approx; // v3 scopes Approx into the Catch namespace; v2 had it at global scope
#include "libslic3r/CAD/SketchSolver.hpp"
#include "libslic3r/CAD/SketchEngine.hpp"
using namespace Slic3r;
using CT = SketchConstraintType;
using R = SketchPointRole;
static SketchEntity line(Vec2d a, Vec2d b)
{
SketchEntity e; e.type = SketchEntity::Type::Line; e.p0 = a; e.p1 = b; return e;
}
static SketchEntity circle(Vec2d c, double r)
{
SketchEntity e; e.type = SketchEntity::Type::Circle; e.center = c; e.p0 = c; e.radius = r; return e;
}
static SketchEntityConstraintDef con(CT t, int ea, R ra, int eb, R rb, double v = 0.0)
{
SketchEntityConstraintDef c; c.type = t; c.ea = ea; c.ra = ra; c.eb = eb; c.rb = rb; c.value = v; return c;
}
TEST_CASE("slvs: distance + horizontal + fix solves a line length", "[slvs]")
{
std::vector<SketchEntity> ents = { line({0, 0}, {5, 1}) };
std::vector<SketchEntityConstraintDef> cons = {
con(CT::Fix, 0, R::P0, 0, R::P0),
con(CT::Horizontal, 0, R::P0, 0, R::P1),
con(CT::Distance, 0, R::P0, 0, R::P1, 10.0),
};
auto res = sketch_solve(ents, cons);
REQUIRE(res.ok);
CHECK((ents[0].p1 - ents[0].p0).norm() == Approx(10.0).margin(1e-6));
CHECK(ents[0].p0.x() == Approx(0.0).margin(1e-6));
CHECK(ents[0].p0.y() == Approx(0.0).margin(1e-6));
CHECK(ents[0].p1.y() == Approx(0.0).margin(1e-6)); // horizontal
}
TEST_CASE("slvs: coincident joins two line endpoints (loop closes)", "[slvs]")
{
std::vector<SketchEntity> ents = { line({0, 0}, {10, 0}), line({10.3, 0.2}, {10, 10}) };
std::vector<SketchEntityConstraintDef> cons = {
con(CT::Coincident, 0, R::P1, 1, R::P0),
};
auto res = sketch_solve(ents, cons);
REQUIRE(res.ok);
CHECK((ents[0].p1 - ents[1].p0).norm() == Approx(0.0).margin(1e-6));
}
TEST_CASE("slvs: parallel + perpendicular on lines", "[slvs]")
{
std::vector<SketchEntity> ents = { line({0, 0}, {10, 1}), line({0, 5}, {10, 5.5}), line({0, 0}, {0.5, 10}) };
std::vector<SketchEntityConstraintDef> cons = {
con(CT::Fix, 0, R::P0, 0, R::P0),
con(CT::Horizontal, 0, R::P0, 0, R::P1),
con(CT::Parallel, 0, R::P0, 1, R::P0), // line1 parallel to line0
con(CT::Perpendicular, 0, R::P0, 2, R::P0), // line2 perpendicular to line0
};
auto res = sketch_solve(ents, cons);
REQUIRE(res.ok);
CHECK(ents[1].p1.y() - ents[1].p0.y() == Approx(0.0).margin(1e-6)); // line1 horizontal
CHECK(ents[2].p1.x() - ents[2].p0.x() == Approx(0.0).margin(1e-6)); // line2 vertical
}
TEST_CASE("slvs: circle radius constraint", "[slvs]")
{
std::vector<SketchEntity> ents = { circle({2, 2}, 3.0) };
std::vector<SketchEntityConstraintDef> cons = { con(CT::Radius, 0, R::P0, -1, R::P0, 7.0) };
auto res = sketch_solve(ents, cons);
REQUIRE(res.ok);
CHECK(ents[0].radius == Approx(7.0).margin(1e-6));
}
TEST_CASE("slvs: degrees of freedom reported", "[slvs]")
{
// One free line with only a Fix on the start: 4 DoF total minus 2 (fix) = 2 remaining.
std::vector<SketchEntity> ents = { line({0, 0}, {3, 4}) };
std::vector<SketchEntityConstraintDef> cons = { con(CT::Fix, 0, R::P0, 0, R::P0) };
auto res = sketch_solve(ents, cons);
REQUIRE(res.ok);
CHECK(res.dof == 2);
}
TEST_CASE("slvs: drag pulls a point while constraints hold", "[slvs]")
{
// A vertical line of fixed length 10, P0 pinned at the origin. Dragging P1 toward
// (10,0) must keep the length (Distance constraint) but rotate the line so the end
// follows the cursor into positive x — the dragged param wins the under-constrained DoF.
std::vector<SketchEntity> ents = { line({0, 0}, {0, 10}) };
std::vector<SketchEntityConstraintDef> cons = {
con(CT::Fix, 0, R::P0, 0, R::P0),
con(CT::Distance, 0, R::P0, 0, R::P1, 10.0),
};
ents[0].p1 = Vec2d(10, 0); // user dropped the endpoint here
auto res = sketch_solve_drag(ents, cons, 0, R::P1);
REQUIRE(res.ok);
CHECK((ents[0].p1 - ents[0].p0).norm() == Approx(10.0).margin(1e-6)); // length held
CHECK(ents[0].p0.x() == Approx(0.0).margin(1e-6)); // P0 still pinned
CHECK(ents[0].p0.y() == Approx(0.0).margin(1e-6));
CHECK(ents[0].p1.x() > 1.0); // end followed the drag toward +x (not stuck vertical)
}
TEST_CASE("slvs: over-constrained / inconsistent is detected", "[slvs]")
{
std::vector<SketchEntity> ents = { line({0, 0}, {5, 0}) };
std::vector<SketchEntityConstraintDef> cons = {
con(CT::Fix, 0, R::P0, 0, R::P0),
con(CT::Fix, 0, R::P1, 0, R::P1),
con(CT::Distance, 0, R::P0, 0, R::P1, 99.0), // contradicts the pinned endpoints
};
auto res = sketch_solve(ents, cons);
CHECK_FALSE(res.ok); // SLVS_RESULT_INCONSISTENT
}
// snaporca-yww4. libslvs sizes its System with a compile-time `MAX_UNKNOWNS = 1024`, and the
// solver is handed every entity in the sketch at 2 params per point — so a sketch of about 480
// lines is the last one that fits and the next comes back TOO_MANY_UNKNOWNS. Because
// try_add_constraints rolls a failed batch back, that turned into: every auto-inferred constraint
// on a large sketch silently dropped, and from then on no dimension could ever be applied to it.
// Constraints only couple entities that share a point, so the sketch is solved component by
// component when the whole system does not fit.
TEST_CASE("slvs: a sketch past the solver's unknown limit still solves", "[slvs]")
{
// 300 disjoint squares: 1200 lines, 4800 unknowns whole, 8 per component.
const int N = 300;
std::vector<SketchEntity> ents;
std::vector<SketchEntityConstraintDef> cons;
for (int i = 0; i < N; ++i) {
const double x = (i % 30) * 10.0, y = (i / 30) * 10.0;
const int b = int(ents.size());
ents.push_back(line({x, y}, {x + 4.0, y}));
ents.push_back(line({x + 4.0, y}, {x + 4.0, y + 4.0}));
ents.push_back(line({x + 4.0, y + 4.0}, {x, y + 4.0}));
ents.push_back(line({x, y + 4.0}, {x, y}));
for (int k = 0; k < 4; ++k)
cons.push_back(con(CT::Coincident, b + k, R::P1, b + (k + 1) % 4, R::P0));
}
REQUIRE(ents.size() == size_t(4 * N));
std::vector<SketchEntity> before = ents;
auto res = sketch_solve(ents, cons);
REQUIRE(res.ok);
for (size_t i = 0; i < ents.size(); ++i) { // already satisfied: nothing may move
CHECK(ents[i].p0.x() == Approx(before[i].p0.x()).margin(1e-9));
CHECK(ents[i].p0.y() == Approx(before[i].p0.y()).margin(1e-9));
CHECK(ents[i].p1.x() == Approx(before[i].p1.x()).margin(1e-9));
CHECK(ents[i].p1.y() == Approx(before[i].p1.y()).margin(1e-9));
}
// And a dimension typed onto one of them lands exactly, which is what stopped working.
cons.push_back(con(CT::Distance, 0, R::P0, 0, R::P1, 7.0));
auto res2 = sketch_solve(ents, cons);
REQUIRE(res2.ok);
CHECK((ents[0].p1 - ents[0].p0).norm() == Approx(7.0).margin(1e-9));
// A conflict inside ONE component must still be caught, not swallowed by the split.
cons.push_back(con(CT::Distance, 0, R::P0, 0, R::P1, 99.0));
auto res3 = sketch_solve(ents, cons);
CHECK_FALSE(res3.ok);
}
TEST_CASE("slvs: equal radius drives two circles to one radius", "[slvs][CadDocument]")
{
std::vector<SketchEntity> ents = { circle({0, 0}, 5.0), circle({10, 0}, 12.0) };
std::vector<SketchEntityConstraintDef> cons = {
con(CT::EqualRadius, 0, R::P0, 1, R::P0),
};
auto res = sketch_solve(ents, cons);
REQUIRE(res.ok);
CHECK(ents[0].radius == Approx(ents[1].radius).margin(1e-9));
CHECK(ents[0].radius > 1e-6); // equal-at-zero would satisfy the line above trivially
}
TEST_CASE("slvs: equal radius plus a radius dimension pins both", "[slvs][CadDocument]")
{
std::vector<SketchEntity> ents = { circle({0, 0}, 5.0), circle({10, 0}, 12.0) };
std::vector<SketchEntityConstraintDef> cons = {
con(CT::EqualRadius, 0, R::P0, 1, R::P0),
con(CT::Radius, 0, R::P0, -1, R::P0, 8.0),
};
auto res = sketch_solve(ents, cons);
REQUIRE(res.ok);
CHECK(ents[0].radius == Approx(8.0).margin(1e-9));
CHECK(ents[1].radius == Approx(8.0).margin(1e-9));
}
TEST_CASE("slvs: collinear makes two offset lines share one line", "[slvs][CadDocument]")
{
std::vector<SketchEntity> ents = { line({0, 0}, {10, 0}), line({0, 4}, {10, 4}) };
std::vector<SketchEntityConstraintDef> cons = {
con(CT::Collinear, 0, R::P0, 1, R::P0),
};
auto res = sketch_solve(ents, cons);
REQUIRE(res.ok);
const Vec2d& a0 = ents[0].p0;
const Vec2d ad = ents[0].p1 - ents[0].p0;
for (int k = 0; k <= 1; ++k) {
const Vec2d& pk = (k == 0) ? ents[1].p0 : ents[1].p1;
const double cross = ad.x() * (pk.y() - a0.y()) - ad.y() * (pk.x() - a0.x());
CHECK(cross == Approx(0.0).margin(1e-9));
}
// A line collapsed to a point is trivially collinear with anything, so the cross
// products above would pass on a degenerate solve. Both lines must survive intact.
CHECK(ad.norm() == Approx(10.0).margin(1e-9));
CHECK((ents[1].p1 - ents[1].p0).norm() == Approx(10.0).margin(1e-9));
}
TEST_CASE("slvs: collinear on already-collinear lines moves nothing", "[slvs][CadDocument]")
{
std::vector<SketchEntity> ents = { line({0, 0}, {10, 0}), line({20, 0}, {30, 0}) };
std::vector<SketchEntityConstraintDef> cons = {
con(CT::Collinear, 0, R::P0, 1, R::P0),
};
std::vector<SketchEntity> before = ents;
auto res = sketch_solve(ents, cons);
REQUIRE(res.ok);
for (size_t i = 0; i < ents.size(); ++i) { // already satisfied: nothing may move
CHECK(ents[i].p0.x() == Approx(before[i].p0.x()).margin(1e-9));
CHECK(ents[i].p0.y() == Approx(before[i].p0.y()).margin(1e-9));
CHECK(ents[i].p1.x() == Approx(before[i].p1.x()).margin(1e-9));
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<SketchEntity> ents = { line({0, 0}, {3, 7}) };
std::vector<SketchEntityConstraintDef> 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<SketchEntity> ents = { line({0, 0}, {3, 7}) };
std::vector<SketchEntityConstraintDef> 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<SketchEntity> ents = { line({0, 0}, {6, 8}) };
std::vector<SketchEntityConstraintDef> 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<SketchEntity> ents = { line({0, 0}, {1, 1}) };
std::vector<SketchEntityConstraintDef> 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<SketchEntity> ents = { line({0, 0}, {-4, 7}) };
std::vector<SketchEntityConstraintDef> 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));
}
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<SketchEntity> ents = { point({5, 5}) };
std::vector<SketchEntityConstraintDef> 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<SketchEntity> ents = { point({7, 4}) };
std::vector<SketchEntityConstraintDef> 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<SketchEntity> ents = { point({4, 7}) };
std::vector<SketchEntityConstraintDef> 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<SketchEntity> ents = { line({0, 0}, {10, 3}) };
std::vector<SketchEntityConstraintDef> 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<SketchEntity> ents = { point({3, 5}), point({9, 5}) };
std::vector<SketchEntityConstraintDef> 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<SketchEntity> ents = { line({0, 0}, {3, 4}) };
std::vector<SketchEntityConstraintDef> 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);
}