diff --git a/pyproject.toml b/pyproject.toml index 8f6ac70..a7b47a0 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "scikit_build_core.build" [project] name = "openscad_cpp_evaluator" -version = "0.13.9" +version = "0.13.10" description = "C++ OpenSCAD evaluator with Python bindings" readme = "README.md" requires-python = ">=3.12" diff --git a/src/builtins/booleans.cpp b/src/builtins/booleans.cpp index 534a246..e04bdec 100644 --- a/src/builtins/booleans.cpp +++ b/src/builtins/booleans.cpp @@ -176,7 +176,23 @@ std::vector generateCsg(Evaluator& ev, const CSGParams& params, con std::vector bodies3d, sections2d; for (const ColoredBody& c : split.foreground) { - if (c.body) bodies3d.push_back(c); + // A body whose own Manifold::Status() isn't NoError (e.g. + // NonFiniteVertex, from a degenerate accumulated transform deep + // in an unrelated ancestor's positioning math -- found via a + // real user script, snappy-reprap's z_tower_assembly chain, + // where one already-invalid sub-part silently zeroed out an + // entire 65-operand union of otherwise-valid geometry) must + // never reach Manifold's own `+`/`-`/`^` operators: unlike a + // genuinely empty operand (0 triangles, NoError), Manifold + // propagates a non-NoError status through boolean ops onto the + // WHOLE result instead of treating it as a no-op contributor, + // so a single bad part silently discards every valid sibling. + // Real OpenSCAD's CGAL backend doesn't hit this at all here + // (more robust to the same input) -- dropping the one invalid + // operand and unioning everything else is the closest match to + // its own behavior available without new numerical-robustness + // work on Manifold's own boolean ops. + if (c.body && c.body->Status() == manifold::Manifold::Error::NoError) bodies3d.push_back(c); } for (const ColoredBody& c : split.foreground) { if (c.section) sections2d.push_back(c); diff --git a/tests/test_booleans.cpp b/tests/test_booleans.cpp index bd10e54..2ff002a 100644 --- a/tests/test_booleans.cpp +++ b/tests/test_booleans.cpp @@ -87,6 +87,25 @@ TEST(Difference, EmptySubtractorLeavesBaseUnchanged) { EXPECT_NEAR(e.bodies[0].body->Volume(), 64.0, 1e-6); } +TEST(Union, OneInvalidOperandDoesNotDiscardValidSiblings) { + // Regression: Manifold's own `+` propagates a non-NoError Status() + // (e.g. NonFiniteVertex, from a degenerate NaN-vertex polyhedron here -- + // in the wild, from an accumulated transform deep in an unrelated + // ancestor's own positioning math) onto the WHOLE combined result + // instead of treating the bad operand as a no-op contributor. Before + // filtering invalid operands out in generateCsg, a single bad part + // anywhere in a many-operand union() silently discarded every valid + // sibling too -- found via a real user project (snappy-reprap) where + // this zeroed out an entire ~65-part extruder assembly with no warning. + Evaluated e = evalSrc("union() {" + " polyhedron(points=[[0,0,0],[2,0,0],[0,2,0],[0/0,0,2]]," + " faces=[[0,1,2],[0,3,1],[0,2,3],[1,3,2]]);" + " translate([10,0,0]) cube(2);" + "}"); + ASSERT_EQ(e.bodies.size(), 1u); + EXPECT_NEAR(e.bodies[0].body->Volume(), 8.0, 1e-6); // just the valid cube -- the NaN polyhedron is dropped, not fatal +} + // -- 2D-native boolean CSG (union/difference/intersection applied directly // to 2D children, not mediated through linear_extrude()) -----------------