From fb8efbdf0f00475d224cbadb8b6a1eaf2747b3b1 Mon Sep 17 00:00:00 2001 From: "Solvin.ai" Date: Tue, 13 Oct 2026 17:16:37 +0000 Subject: [PATCH] =?UTF-8?q?fix(occ):=20set=20non-destructive=20mode=20on?= =?UTF-8?q?=20boolean=20operations=20to=20preserve=20sh=E2=80=A6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Set NonDestructive mode on Shape._bool_op to prevent in-place tolerance mutation on C++ TopoDS_TShape instances during boolean operations. Add fail-fast validation before Shape.cast. Add regression tests in tests/test_cadquery.py. --- cadquery/occ_impl/shapes.py | 5 ++++ tests/test_cadquery.py | 47 +++++++++++++++++++++++++++++++++++++ 2 files changed, 52 insertions(+) diff --git a/cadquery/occ_impl/shapes.py b/cadquery/occ_impl/shapes.py index 7eb28caca..80bf21cc6 100644 --- a/cadquery/occ_impl/shapes.py +++ b/cadquery/occ_impl/shapes.py @@ -1436,8 +1436,13 @@ def _bool_op( op.SetTools(tool) op.SetRunParallel(parallel) + # Ensure non-destructive mode to prevent in-place tolerance mutation (Issue #2080) + op.SetNonDestructive(True) op.Build() + if not op.IsDone() or op.Shape().IsNull(): + raise ValueError(f"Boolean operation {op.__class__.__name__} failed") + return Shape.cast(op.Shape()) def cut(self, *toCut: Shape, tol: float | None = None) -> Shape: diff --git a/tests/test_cadquery.py b/tests/test_cadquery.py index a1de3c957..2b2131f01 100644 --- a/tests/test_cadquery.py +++ b/tests/test_cadquery.py @@ -5861,6 +5861,53 @@ def test_export(self): assert (w - Shape.importBrep("box.brep")).val().Volume() == approx(0) + def test_bool_op_non_destructive(self): + + from OCP.BRep import BRep_Tool + + for op_name in ("cut", "fuse"): + cyl = Workplane("XY").cylinder(10, 5) + cap = Workplane("XY", origin=(0, 0, 5)).sphere(5) + s1 = cyl.union(cap).edges().fillet(0.5).val() + s2 = s1.translate((1e-4, 0, 0)) + + v_tol_before = max(BRep_Tool.Tolerance_s(v.wrapped) for v in s1.Vertices()) + e_tol_before = max(BRep_Tool.Tolerance_s(e.wrapped) for e in s1.Edges()) + + getattr(s1, op_name)(s2) + + v_tol_after = max(BRep_Tool.Tolerance_s(v.wrapped) for v in s1.Vertices()) + e_tol_after = max(BRep_Tool.Tolerance_s(e.wrapped) for e in s1.Edges()) + + self.assertEqual(v_tol_before, v_tol_after) + self.assertEqual(e_tol_before, e_tol_after) + + def test_bool_op_failure_raises(self): + + from unittest.mock import MagicMock + + box = Solid.makeBox(1, 1, 1) + + class MockBooleanOp(MagicMock): + pass + + # op.IsDone() is False + mock_op_not_done = MockBooleanOp() + mock_op_not_done.IsDone.return_value = False + + with self.assertRaises(ValueError) as cm: + box._bool_op((box,), (), mock_op_not_done) + self.assertIn("Boolean operation MockBooleanOp failed", str(cm.exception)) + + # op.Shape().IsNull() is True + mock_op_null_shape = MockBooleanOp() + mock_op_null_shape.IsDone.return_value = True + mock_op_null_shape.Shape.return_value.IsNull.return_value = True + + with self.assertRaises(ValueError) as cm: + box._bool_op((box,), (), mock_op_null_shape) + self.assertIn("Boolean operation MockBooleanOp failed", str(cm.exception)) + def test_bool_operators(self): w1 = Workplane().box(1, 1, 2)