From b9c20698bd1e81272ab4b4e007941cb1ffaa79d6 Mon Sep 17 00:00:00 2001 From: Revar Desmera Date: Mon, 10 Aug 2026 22:58:08 -0700 Subject: [PATCH 1/2] Implement minkowski() for 2D shapes It only ever looked at 3D bodies. 2D sections among its children were dropped on the floor, silently, so linear_extrude(6) minkowski() { square([30,20]); circle(4); } produced no geometry at all where the reference produces the rounded square asked for. No warning either -- the whole statement simply vanished from the model. Manifold has no 2D Minkowski, so this builds one: cut both shapes into convex pieces, take the convex hull of every pairwise sum of points, and union the results. Correct because the sum of two convex sets is the hull of their pairwise sums, and Minkowski distributes over union. A convex outline with no holes stays whole rather than being triangulated, which is the case that actually turns up -- a circle swept over something -- and much cheaper. Matches the reference exactly on both a convex and a concave outline: same facet count (108 and 100) and the same volume to five figures. A mix of 2D and 3D children still takes the 3D path, as before. Co-Authored-By: Claude Opus 5 (1M context) --- pyproject.toml | 2 +- src/builtins/topology.cpp | 108 ++++++++++++++++++++++++++++++++++++-- tests/test_booleans.cpp | 61 +++++++++++++++++++++ 3 files changed, 166 insertions(+), 5 deletions(-) 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..241bfbb 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,98 @@ 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 of its own, so: cut both into convex +// pieces, take the convex hull of every pairwise sum of points, and union +// the lot. Correct because the sum of two convex sets is the hull of +// their pairwise sums, and Minkowski distributes over union. +// +// ponytail: pieces multiply, so two 64-segment circles are 62 x 62 hulls. +// Convex shapes stay whole, which is the case that actually turns up +// (sweeping a circle), and the reference is no quicker at this. +manifold::CrossSection minkowski2d(const manifold::CrossSection& a, const manifold::CrossSection& b) { + const std::vector pa = convexPieces(a); + const std::vector pb = convexPieces(b); + std::vector parts; + parts.reserve(pa.size() * pb.size()); + for (const manifold::SimplePolygon& x : pa) { + for (const manifold::SimplePolygon& y : pb) { + manifold::SimplePolygon sum; + sum.reserve(x.size() * y.size()); + for (const manifold::vec2& p : x) + for (const manifold::vec2& q : y) sum.push_back({p.x + q.x, p.y + q.y}); + if (sum.size() >= 3) parts.push_back(manifold::CrossSection::Hull(sum)); + } + } + 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 +174,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..b6bc370 100644 --- a/tests/test_booleans.cpp +++ b/tests/test_booleans.cpp @@ -317,3 +317,64 @@ 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); +} From 28239078f83b318e572ef61cf67b9d000b0175ed Mon Sep 17 00:00:00 2001 From: Revar Desmera Date: Mon, 10 Aug 2026 23:28:38 -0700 Subject: [PATCH 2/2] Sum along the boundary instead of cutting both shapes up The first version cut both operands into convex pieces and hulled every pair. The boundary-sweep identity is better: 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 A is never cut up at all, however concave it is or however many holes it has -- only its edges are walked. Only B is decomposed, and only because the per-segment hull needs it convex. Both conditions on B are met rather than assumed, and each is its own test because each is silently wrong on its own: hulling B instead of decomposing it computes A (+) hull(B), 5092 against the reference's 4992 leaving B where it is, when it does not contain the origin, keeps an untranslated copy of A: the result starts at x=0 where the reference starts at x=16 walking one contour leaves a hole unswept Matches the reference exactly on all six: convex, concave A, concave B, an off-origin B, three operands, and a shape with a hole. Co-Authored-By: Claude Opus 5 (1M context) --- src/builtins/topology.cpp | 64 ++++++++++++++++++++++++++++----------- tests/test_booleans.cpp | 62 +++++++++++++++++++++++++++++++++++++ 2 files changed, 109 insertions(+), 17 deletions(-) diff --git a/src/builtins/topology.cpp b/src/builtins/topology.cpp index 241bfbb..27ded61 100644 --- a/src/builtins/topology.cpp +++ b/src/builtins/topology.cpp @@ -124,28 +124,58 @@ std::vector convexPieces(const manifold::CrossSection& // The 2D Minkowski sum of two shapes. // -// Manifold has no 2D Minkowski of its own, so: cut both into convex -// pieces, take the convex hull of every pairwise sum of points, and union -// the lot. Correct because the sum of two convex sets is the hull of -// their pairwise sums, and Minkowski distributes over union. +// Manifold has no 2D Minkowski, so this is the boundary-sweep identity: +// for B convex and containing the origin, // -// ponytail: pieces multiply, so two 64-segment circles are 62 x 62 hulls. -// Convex shapes stay whole, which is the case that actually turns up -// (sweeping a circle), and the reference is no quicker at this. +// 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 std::vector pa = convexPieces(a); - const std::vector pb = convexPieces(b); + const manifold::Polygons outline = a.ToPolygons(); std::vector parts; - parts.reserve(pa.size() * pb.size()); - for (const manifold::SimplePolygon& x : pa) { - for (const manifold::SimplePolygon& y : pb) { - manifold::SimplePolygon sum; - sum.reserve(x.size() * y.size()); - for (const manifold::vec2& p : x) - for (const manifold::vec2& q : y) sum.push_back({p.x + q.x, p.y + q.y}); - if (sum.size() >= 3) parts.push_back(manifold::CrossSection::Hull(sum)); + + 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); } diff --git a/tests/test_booleans.cpp b/tests/test_booleans.cpp index b6bc370..bf3eef5 100644 --- a/tests/test_booleans.cpp +++ b/tests/test_booleans.cpp @@ -378,3 +378,65 @@ TEST(Minkowski2D, ThreeDStillWorksAndStillWins) { 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"; +}