Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
138 changes: 134 additions & 4 deletions src/builtins/topology.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,8 @@
#include "openscad_cpp_evaluator/call_args.hpp"
#include "openscad_cpp_evaluator/evaluator.hpp"

#include <manifold/polygon.h>

namespace oscadeval {

// hull()/minkowski() -- like union/difference/intersection, these splice
Expand Down Expand Up @@ -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<manifold::SimplePolygon> convexPieces(const manifold::CrossSection& section) {
const manifold::Polygons polys = section.ToPolygons();
std::vector<manifold::SimplePolygon> 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<manifold::vec2> flat;
for (const manifold::SimplePolygon& ring : polys)
flat.insert(flat.end(), ring.begin(), ring.end());
if (static_cast<size_t>(tri.x) >= flat.size() || static_cast<size_t>(tri.y) >= flat.size() ||
static_cast<size_t>(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<manifold::CrossSection> 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<manifold::CrossSection> 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<ColoredBody> generateMinkowski(Evaluator& ev, const CSGParams&, const std::vector<std::unique_ptr<CSGNode>>& children,
const oscad::ASTNode& node) {
const std::vector<ColoredBody> bodies = flattenCsgTree(children);
const RoleSplit split = splitByRole(bodies);

std::vector<const ColoredBody*> bodies3d;
std::vector<const ColoredBody*> sections2d;
for (const ColoredBody& c : split.foreground) {
if (c.body) bodies3d.push_back(&c);
else if (c.section) sections2d.push_back(&c);
}

std::vector<ColoredBody> passthrough;
Expand All @@ -92,6 +204,24 @@ std::vector<ColoredBody> 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<ColoredBody> out = {std::move(cb)};
out.insert(out.end(), passthrough.begin(), passthrough.end());
return out;
}
if (bodies3d.empty() && sections2d.size() == 1) {
std::vector<ColoredBody> out = {*sections2d.front()};
out.insert(out.end(), passthrough.begin(), passthrough.end());
return out;
}
if (bodies3d.empty()) return passthrough;
if (bodies3d.size() == 1) {
std::vector<ColoredBody> result = {*bodies3d.front()};
Expand Down
123 changes: 123 additions & 0 deletions tests/test_booleans.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -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";
}