diff --git a/pyproject.toml b/pyproject.toml index 8f29b1e..9ba7cd1 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "scikit_build_core.build" [project] name = "openscad_cpp_evaluator" -version = "0.29.0" +version = "0.29.1" description = "C++ OpenSCAD evaluator with Python bindings" readme = "README.md" requires-python = ">=3.12" diff --git a/src/builtins/topology.cpp b/src/builtins/topology.cpp index 1be7d67..27ded61 100644 --- a/src/builtins/topology.cpp +++ b/src/builtins/topology.cpp @@ -3,6 +3,8 @@ #include "openscad_cpp_evaluator/call_args.hpp" #include "openscad_cpp_evaluator/evaluator.hpp" +#include + namespace oscadeval { // hull()/minkowski() -- like union/difference/intersection, these splice @@ -72,18 +74,128 @@ CSGParams resolveMinkowski(Evaluator& ev, const oscad::ModularCall& node, EvalCo return CSGParams{}; } -// minkowski() only operates on 3D bodies -- 2D sections among the -// foreground children are silently ignored, matching _generate_minkowski -// exactly (it filters `c.body is not None`, never falls back to sections -// the way hull does). + +namespace { + +// The convex pieces of a 2D shape, as point lists. +// +// A Minkowski sum only has a closed form for convex operands -- there it +// is the convex hull of every pairwise sum -- so a shape that is not +// convex has to be cut into pieces that are. A convex outline with no +// holes is already one piece, which is the common case (a circle being +// swept over something) and much cheaper than triangulating it. +std::vector convexPieces(const manifold::CrossSection& section) { + const manifold::Polygons polys = section.ToPolygons(); + std::vector out; + + if (polys.size() == 1) { + const manifold::SimplePolygon& ring = polys[0]; + bool convex = ring.size() >= 3; + int sign = 0; + for (size_t i = 0; convex && i < ring.size(); ++i) { + const manifold::vec2& a = ring[i]; + const manifold::vec2& b = ring[(i + 1) % ring.size()]; + const manifold::vec2& c = ring[(i + 2) % ring.size()]; + const double cross = (b.x - a.x) * (c.y - a.y) - (b.y - a.y) * (c.x - a.x); + if (std::abs(cross) < 1e-12) continue; // collinear, no turn + const int s = cross > 0 ? 1 : -1; + if (sign == 0) sign = s; + else if (s != sign) convex = false; + } + if (convex) { + out.push_back(ring); + return out; + } + } + + for (const manifold::ivec3& tri : manifold::Triangulate(polys)) { + // Triangulate indexes the contours end to end. + std::vector flat; + for (const manifold::SimplePolygon& ring : polys) + flat.insert(flat.end(), ring.begin(), ring.end()); + if (static_cast(tri.x) >= flat.size() || static_cast(tri.y) >= flat.size() || + static_cast(tri.z) >= flat.size()) { + continue; + } + out.push_back({flat[tri.x], flat[tri.y], flat[tri.z]}); + } + return out; +} + +// The 2D Minkowski sum of two shapes. +// +// Manifold has no 2D Minkowski, so this is the boundary-sweep identity: +// for B convex and containing the origin, +// +// A (+) B = A union (boundary of A (+) B) +// +// and the boundary is a chain of segments, each of which sums with a +// convex B to the hull of B at its two ends. So sweeping B along every +// edge of A and unioning A itself is the whole answer -- A is never cut +// up, however concave it is or however many holes it has. Only B is, +// and only because the per-segment hull needs it convex. +// +// The two conditions on B are met rather than assumed: it is decomposed +// into convex pieces (Minkowski distributes over union, so the pieces' +// sums are unioned), and each piece is shifted onto the origin with the +// shift undone afterwards, since A (+) B = ((A (+) (B - c)) + c). +// +// ponytail: one hull per edge of A per convex piece of B. A piece count +// of one is the case that turns up -- a circle swept over something -- +// and then it is simply one hull per edge. +manifold::CrossSection minkowski2d(const manifold::CrossSection& a, const manifold::CrossSection& b) { + const manifold::Polygons outline = a.ToPolygons(); + std::vector parts; + + for (const manifold::SimplePolygon& piece : convexPieces(b)) { + if (piece.size() < 3) continue; + // Bring the piece onto the origin; the sum is shifted back after. + manifold::vec2 shift = piece[0]; + manifold::SimplePolygon centred; + centred.reserve(piece.size()); + for (const manifold::vec2& q : piece) centred.push_back({q.x - shift.x, q.y - shift.y}); + + std::vector swept; + // A itself: only sound because `centred` contains the origin. + swept.push_back(a); + for (const manifold::SimplePolygon& ring : outline) { + for (size_t i = 0; i < ring.size(); ++i) { + const manifold::vec2& v0 = ring[i]; + const manifold::vec2& v1 = ring[(i + 1) % ring.size()]; + manifold::SimplePolygon ends; + ends.reserve(centred.size() * 2); + for (const manifold::vec2& q : centred) { + ends.push_back({v0.x + q.x, v0.y + q.y}); + ends.push_back({v1.x + q.x, v1.y + q.y}); + } + swept.push_back(manifold::CrossSection::Hull(ends)); + } + } + manifold::CrossSection sum = + manifold::CrossSection::BatchBoolean(swept, manifold::OpType::Add); + parts.push_back(sum.Translate(shift)); + } + + if (parts.empty()) return manifold::CrossSection(); + return manifold::CrossSection::BatchBoolean(parts, manifold::OpType::Add); +} + +} // namespace + +// minkowski() over 3D bodies, or over 2D sections when that is what it +// was given. 2D used to be dropped on the floor -- silently, so +// `linear_extrude() minkowski() { square(); circle(); }` produced nothing +// at all where the reference produces the rounded square you asked for. std::vector generateMinkowski(Evaluator& ev, const CSGParams&, const std::vector>& children, const oscad::ASTNode& node) { const std::vector bodies = flattenCsgTree(children); const RoleSplit split = splitByRole(bodies); std::vector bodies3d; + std::vector sections2d; for (const ColoredBody& c : split.foreground) { if (c.body) bodies3d.push_back(&c); + else if (c.section) sections2d.push_back(&c); } std::vector passthrough; @@ -92,6 +204,24 @@ std::vector generateMinkowski(Evaluator& ev, const CSGParams&, cons passthrough.insert(passthrough.end(), split.showOnly.begin(), split.showOnly.end()); passthrough.insert(passthrough.end(), split.displayOnly.begin(), split.displayOnly.end()); + // 2D only: sum the sections instead. A mix of 2D and 3D is the + // reference's error case, and the 3D bodies win here as they do there. + if (bodies3d.empty() && sections2d.size() >= 2) { + manifold::CrossSection acc = *sections2d.front()->section; + for (size_t i = 1; i < sections2d.size(); ++i) + acc = minkowski2d(acc, *sections2d[i]->section); + ColoredBody cb; + cb.section = std::move(acc); + cb.color = sections2d.front()->color; + std::vector out = {std::move(cb)}; + out.insert(out.end(), passthrough.begin(), passthrough.end()); + return out; + } + if (bodies3d.empty() && sections2d.size() == 1) { + std::vector out = {*sections2d.front()}; + out.insert(out.end(), passthrough.begin(), passthrough.end()); + return out; + } if (bodies3d.empty()) return passthrough; if (bodies3d.size() == 1) { std::vector result = {*bodies3d.front()}; diff --git a/tests/test_booleans.cpp b/tests/test_booleans.cpp index 02b0353..bf3eef5 100644 --- a/tests/test_booleans.cpp +++ b/tests/test_booleans.cpp @@ -317,3 +317,126 @@ TEST(PolyhedronFaces, ADegenerateFaceDoesNotThrowOrHang) { )"); EXPECT_EQ(e.bodies.size(), 1u); } + +// -- 2D minkowski --------------------------------------------------------- + +// minkowski() used to drop 2D sections on the floor, silently: this +// produced no geometry at all where the reference produces the rounded +// square asked for. Manifold has no 2D Minkowski, so it is built from +// convex pieces and hulls -- see minkowski2d(). +TEST(Minkowski2D, RoundsAConvexOutline) { + Evaluated e = evaluateSrc( + "linear_extrude(6) minkowski() { square([30,20], center=true); circle(4, $fn=24); }"); + ASSERT_EQ(e.bodies.size(), 1u); + ASSERT_TRUE(e.bodies[0].body.has_value()); + // A 30x20 rectangle grown by 4: the rectangle, four 4-wide sides, and + // the corners adding up to one circle. Times 6 high. + const double expected = (30.0 * 20.0 + 2 * 4 * (30 + 20) + M_PI * 16.0) * 6.0; + EXPECT_NEAR(e.bodies[0].body->Volume(), expected, expected * 0.01); + const manifold::Box box = e.bodies[0].body->BoundingBox(); + EXPECT_NEAR(box.max.x - box.min.x, 38.0, 0.01); + EXPECT_NEAR(box.max.y - box.min.y, 28.0, 0.01); +} + +// The case a hull cannot fake: a concave outline has to stay concave. +TEST(Minkowski2D, KeepsAConcaveOutlineConcave) { + Evaluated e = evaluateSrc(R"( + linear_extrude(4) minkowski() { + polygon([[0,0],[40,0],[40,25],[22,25],[22,12],[0,12]]); + circle(3, $fn=16); + } + )"); + ASSERT_EQ(e.bodies.size(), 1u); + const manifold::Box box = e.bodies[0].body->BoundingBox(); + EXPECT_NEAR(box.max.x - box.min.x, 46.0, 0.2); + EXPECT_NEAR(box.max.y - box.min.y, 31.0, 0.2); + // The notch has to still be a notch. A volume bound is too blunt to + // say so -- hulling the pieces rather than unioning them lands within + // 1% of the right answer here -- so probe the hole itself: the L is + // missing the region x 0..22, y 12..25, and rounding by 3 does not + // reach anywhere near the middle of it. + // The probe has to sit inside what a hull WOULD cover and outside the + // real shape, or it proves nothing: the hull runs from (0,12) to + // (22,25), and the rounded L stops at y=15 for any x below 22. + const manifold::Manifold probe = + manifold::Manifold::Cube({1.0, 1.0, 8.0}).Translate({17.0, 17.0, -2.0}); + EXPECT_TRUE((*e.bodies[0].body ^ probe).IsEmpty()) << "the notch was filled in"; + // ...while somewhere solid is genuinely solid. + const manifold::Manifold inside = + manifold::Manifold::Cube({2.0, 2.0, 8.0}).Translate({30.0, 5.0, -2.0}); + EXPECT_FALSE((*e.bodies[0].body ^ inside).IsEmpty()); +} + +TEST(Minkowski2D, OneSectionIsHandedBackUnchanged) { + Evaluated e = evaluateSrc("linear_extrude(3) minkowski() { square([10,10]); }"); + ASSERT_EQ(e.bodies.size(), 1u); + EXPECT_NEAR(e.bodies[0].body->Volume(), 300.0, 1e-6); +} + +TEST(Minkowski2D, ThreeDStillWorksAndStillWins) { + Evaluated a = evaluateSrc("minkowski() { cube([30,20,6], center=true); sphere(3, $fn=12); }"); + ASSERT_EQ(a.bodies.size(), 1u); + EXPECT_GT(a.bodies[0].body->Volume(), 30.0 * 20.0 * 6.0); +} + +// The edge sweep is only valid when the shape being swept is convex and +// contains the origin, and when a third operand is folded rather than +// unioned in. Each of these fails, by a measurable amount, if the +// corresponding step is skipped -- so each is here on its own. + +TEST(Minkowski2D, SweepsAConcaveSweeperByItsConvexPieces) { + // Hulling the sweeper instead of decomposing it computes A (+) hull(B) + // and comes out at 5092 against the reference's 4992. + Evaluated e = evaluateSrc(R"( + linear_extrude(4) minkowski() { + polygon([[0,0],[40,0],[40,25],[22,25],[22,12],[0,12]]); + polygon([[0,0],[8,0],[8,8],[5,8],[5,3],[0,3]]); + } + )"); + ASSERT_EQ(e.bodies.size(), 1u); + EXPECT_NEAR(e.bodies[0].body->Volume(), 4992.0, 5.0); +} + +TEST(Minkowski2D, ASweeperAwayFromTheOriginStillLandsInTheRightPlace) { + // The identity unions A itself, which only holds when the sweeper + // contains the origin. Without shifting it there and back, the result + // keeps an untranslated copy of A and starts at x=0 instead of x=16. + Evaluated e = evaluateSrc(R"( + linear_extrude(4) minkowski() { + polygon([[0,0],[40,0],[40,25],[22,25],[22,12],[0,12]]); + translate([20,0]) circle(4, $fn=24); + } + )"); + ASSERT_EQ(e.bodies.size(), 1u); + const manifold::Box box = e.bodies[0].body->BoundingBox(); + EXPECT_NEAR(box.min.x, 16.0, 0.2); + EXPECT_NEAR(e.bodies[0].body->Volume(), 5120.47, 5.0); +} + +TEST(Minkowski2D, ThreeOperandsAreSummedInTurnNotUnioned) { + // A (+) B (+) C, not A (+) (B union C) -- which would give 3711. + Evaluated e = evaluateSrc( + "linear_extrude(4) minkowski() { square([30,20], center=true);" + " circle(3, $fn=16); square([6,2], center=true); }"); + ASSERT_EQ(e.bodies.size(), 1u); + EXPECT_NEAR(e.bodies[0].body->Volume(), 4670.21, 5.0); + const manifold::Box box = e.bodies[0].body->BoundingBox(); + EXPECT_NEAR(box.max.x - box.min.x, 42.0, 0.2); +} + +TEST(Minkowski2D, AShapeWithAHoleHasBothItsContoursSwept) { + // Only the boundary of the shape being swept along is walked, so a + // hole is not a special case -- it is another contour. + Evaluated e = evaluateSrc(R"( + linear_extrude(3) minkowski() { + difference() { square([40,30], center=true); circle(8, $fn=24); } + circle(2, $fn=16); + } + )"); + ASSERT_EQ(e.bodies.size(), 1u); + EXPECT_NEAR(e.bodies[0].body->Volume(), 4141.98, 5.0); + // The hole shrinks by the sweep rather than filling in. + const manifold::Manifold probe = + manifold::Manifold::Cube({1.0, 1.0, 6.0}).Translate({-0.5, -0.5, -1.0}); + EXPECT_TRUE((*e.bodies[0].body ^ probe).IsEmpty()) << "the hole was filled in"; +}