Files
OrcaSlicer/tests/libslic3r/test_sketchprofile.cpp
T
Tommaso BianchiandClaude Opus 5 942f6c28c7 Port: mirror emits a half that continues the chain
Carries snaporca 0231bd5b68. Parity holds: 17 files identical, 8 diverging as expected.

A reflection reverses orientation, so mirror_entities now hands the reflected half back reversed
in ORDER and flipped per ENTITY — an arc swapping its angles as well as its ends, a spline
reversing its control points. Appending it to the source then yields one walkable chain instead
of two halves meeting head-to-head, and a mirrored CCW loop stays CCW.

This is the producer half of the confusion that cost three defects; the consumers (offset, and
the exact loop area) keep their defensive handling, because that is what makes them correct for
hand-built and imported sketches rather than only for geometry this function produced.

Contract change, carried with the reason: a mirrored line's p0 is the reflection of the SOURCE's
p1, and a mirrored CCW arc keeps a POSITIVE sweep — the reflection negates it, walking it the
other way negates it again. Both [SketchEdit] cases updated, and a new [SketchProfile] case
"a mirrored half continues the original chain" pins the property directly.

Kernel here: all tests passed, 2687 assertions in 232 test cases. GUI target builds and links.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-22 15:00:50 +02:00

210 lines
8.7 KiB
C++

// Closed-profile harness for the 2D sketch layer.
//
// The existing [SketchEdit] cases check one entity at a time — offset ONE line, mirror ONE
// arc — and every one of them passes while the feature they belong to is unusable. What a
// user actually does is combine 2D features into a CLOSED PROFILE and extrude it, and the
// property that makes that work is topological, not per-entity: after the operation, do the
// pieces still form a single closed loop?
//
// So these cases assert the loop, not the coordinates. That is the invariant every sketch
// operation has to preserve and the only one that predicts whether the GUI can build a solid
// out of the result.
#include <catch2/catch_all.hpp> // mainline OrcaSlicer ships Catch2 v3 (v2 was catch2/catch.hpp)
#include "libslic3r/CAD/SketchEngine.hpp"
#include <BRepGProp.hxx>
#include <GProp_GProps.hxx>
#include <cmath>
using namespace Slic3r;
using Catch::Matchers::WithinAbs;
namespace {
SketchPlane xy_plane() { return SketchPlane::XY(); }
SketchEntity line(const Vec2d& a, const Vec2d& b)
{
SketchEntity e;
e.type = SketchEntity::Type::Line;
e.p0 = a; e.p1 = b;
return e;
}
// A CCW rectangle as four Line entities sharing endpoints exactly.
std::vector<SketchEntity> rect(double w, double h)
{
return { line({0, 0}, {w, 0}), line({w, 0}, {w, h}),
line({w, h}, {0, h}), line({0, h}, {0, 0}) };
}
// How many of the wires the sketch resolves to are CLOSED.
int closed_wires(const std::vector<SketchEntity>& ents)
{
const auto ws = SketchEngine::entities_to_wires(ents, xy_plane());
int n = 0;
for (const auto& w : ws)
if (!w.IsNull() && w.Closed()) ++n;
return n;
}
// Enclosed area of the single closed loop the sketch resolves to. -1 when it is not one
// closed loop — the failure the whole file exists to catch.
double profile_area(const std::vector<SketchEntity>& ents)
{
const auto ws = SketchEngine::entities_to_wires(ents, xy_plane());
if (ws.size() != 1 || ws[0].IsNull() || !ws[0].Closed()) return -1.0;
const TopoDS_Face f = SketchEngine::wires_to_face(ws, xy_plane());
GProp_GProps props;
BRepGProp::SurfaceProperties(f, props);
return props.Mass();
}
SketchEntity arc(const Vec2d& c, double r, double a0, double a1)
{
SketchEntity e;
e.type = SketchEntity::Type::Arc;
e.center = c;
e.radius = r;
e.start_angle = a0;
e.end_angle = a1;
e.p0 = c + r * Vec2d(std::cos(a0), std::sin(a0));
e.p1 = c + r * Vec2d(std::cos(a1), std::sin(a1));
return e;
}
} // namespace
TEST_CASE("profile baseline: a hand-built rectangle is one closed loop", "[SketchProfile]")
{
REQUIRE(closed_wires(rect(40, 20)) == 1);
}
TEST_CASE("profile: mirroring a closed rectangle keeps it closed", "[SketchProfile]")
{
const auto m = SketchEngine::mirror_entities(rect(40, 20), Vec2d(-10, 0), Vec2d(-10, 1));
REQUIRE(m.size() == 4);
REQUIRE(closed_wires(m) == 1);
}
TEST_CASE("profile: mirroring an open half-profile closes it against the axis", "[SketchProfile]")
{
// Half a rectangle, open along x=0 — the classic "draw half, mirror it" gesture.
const std::vector<SketchEntity> half = {
line({0, 0}, {20, 0}), line({20, 0}, {20, 10}), line({20, 10}, {0, 10}) };
auto all = half;
for (const auto& e : SketchEngine::mirror_entities(half, Vec2d(0, 0), Vec2d(0, 1)))
all.push_back(e);
REQUIRE(all.size() == 6);
REQUIRE(closed_wires(all) == 1);
}
TEST_CASE("profile: offsetting a closed rectangle keeps it closed", "[SketchProfile]")
{
const auto out = SketchEngine::offset_entities(rect(40, 20), 5.0);
REQUIRE(out.size() == 4);
REQUIRE(closed_wires(out) == 1);
}
TEST_CASE("profile: offset outward grows the enclosed area by the right amount", "[SketchProfile]")
{
// A rectangle offset outward by d is (w+2d) x (h+2d) with the corners rounded at r=d,
// so its area is w*h + 2d(w+h) + pi*d^2 whichever way the corners are healed... except
// for a sharp-corner offset, which is exactly (w+2d)*(h+2d). Either healing is defensible;
// a set of four disconnected segments is not, and that is what this measures.
const double w = 40, h = 20, d = 5;
const auto out = SketchEngine::offset_entities(rect(w, h), d);
const auto ws = SketchEngine::entities_to_wires(out, xy_plane());
REQUIRE(ws.size() == 1);
REQUIRE(ws[0].Closed());
}
TEST_CASE("profile: offset sign is left-of-travel, so +d shrinks a CCW rectangle", "[SketchProfile]")
{
// The convention has to be pinned by a test, because it is the one thing a caller cannot
// read off the geometry: +d = left of the direction of travel = inward for a CCW loop.
// Miter join on a rectangle keeps the corners sharp, so the result is exact.
const double w = 40, h = 20, d = 5;
REQUIRE_THAT(profile_area(SketchEngine::offset_entities(rect(w, h), d)),
WithinAbs((w - 2 * d) * (h - 2 * d), 1e-6));
REQUIRE_THAT(profile_area(SketchEngine::offset_entities(rect(w, h), -d)),
WithinAbs((w + 2 * d) * (h + 2 * d), 1e-6));
}
TEST_CASE("profile: offsetting a stadium (two lines + two arcs) stays closed", "[SketchProfile]")
{
// A slot outline: straight top and bottom joined by half-circle caps. This is the case the
// per-entity offset could never repair, because both seams are line-to-arc.
const double L = 30, r = 8, d = 3;
const std::vector<SketchEntity> slot = {
line({0, -r}, {L, -r}),
arc({L, 0}, r, -M_PI / 2, M_PI / 2),
line({L, r}, {0, r}),
arc({0, 0}, r, M_PI / 2, 3 * M_PI / 2),
};
REQUIRE(closed_wires(slot) == 1);
const auto out = SketchEngine::offset_entities(slot, -d); // -d = outward for this CCW loop
REQUIRE(closed_wires(out) == 1);
// Offsetting a stadium outward by d gives the stadium with radius r+d: L*2(r+d) + pi(r+d)^2.
// Lines and caps must move the SAME way — that is the assertion this case exists for.
const double rr = r + d;
REQUIRE_THAT(profile_area(out), WithinAbs(L * 2 * rr + M_PI * rr * rr, 1e-6));
}
TEST_CASE("profile: an open chain offsets without being forced closed", "[SketchProfile]")
{
// A sweep path is legitimately open; the repair must join its interior seams and leave
// the two free ends alone.
const std::vector<SketchEntity> open_chain = {
line({0, 0}, {20, 0}), line({20, 0}, {20, 10}) };
const auto out = SketchEngine::offset_entities(open_chain, 4.0);
REQUIRE(out.size() == 2);
REQUIRE(closed_wires(out) == 0);
// The interior seam is repaired: the two offset segments still meet.
REQUIRE_THAT((out[0].p1 - out[1].p0).norm(), WithinAbs(0.0, 1e-9));
}
TEST_CASE("profile: a mirrored half offsets as one loop, not two", "[SketchProfile]")
{
// The classic "draw half, mirror it" gesture on a stadium. mirror_entities emits a half
// that travels the opposite way round, so the concatenation must still chain as ONE closed
// loop and its mirrored cap must offset outward like the original, not inward.
const double L = 30, R = 15, d = 4;
const std::vector<SketchEntity> half = {
line({0, -R}, {L, -R}),
arc({L, 0}, R, -M_PI / 2, M_PI / 2),
line({L, R}, {0, R}),
};
std::vector<SketchEntity> all = half;
for (const auto& e : SketchEngine::mirror_entities(half, Vec2d(0, 0), Vec2d(0, 1)))
all.push_back(e);
REQUIRE(closed_wires(all) == 1);
const auto out = SketchEngine::offset_entities(all, -d); // -d = outward for this loop
REQUIRE(closed_wires(out) == 1);
for (const auto& o : out)
if (o.type == SketchEntity::Type::Arc)
REQUIRE_THAT(o.radius, WithinAbs(R + d, 1e-9));
}
TEST_CASE("profile: a mirrored half continues the original chain", "[SketchProfile]")
{
// The point of emitting the reflected half reversed: appending it to the source must give a
// chain you can WALK, head-to-tail, with no consumer having to notice that half of it came
// from a mirror. The end of the last source entity must be the start of the first mirrored
// one, and the end of the last mirrored one must close back to the very first start.
const std::vector<SketchEntity> half = {
line({0, -15}, {50, -15}), line({50, -15}, {50, 15}), line({50, 15}, {0, 15}) };
const auto m = SketchEngine::mirror_entities(half, Vec2d(0, 0), Vec2d(0, 1));
REQUIRE(m.size() == 3);
REQUIRE_THAT((half.back().p1 - m.front().p0).norm(), WithinAbs(0.0, 1e-9));
REQUIRE_THAT((m.back().p1 - half.front().p0).norm(), WithinAbs(0.0, 1e-9));
for (size_t i = 0; i + 1 < m.size(); ++i)
REQUIRE_THAT((m[i].p1 - m[i + 1].p0).norm(), WithinAbs(0.0, 1e-9));
auto all = half;
for (const auto& e : m) all.push_back(e);
REQUIRE(closed_wires(all) == 1);
}