diff --git a/src/ansys/geometry/core/designer/body.py b/src/ansys/geometry/core/designer/body.py index 73118fe503..1afb7cdb4f 100644 --- a/src/ansys/geometry/core/designer/body.py +++ b/src/ansys/geometry/core/designer/body.py @@ -4,6 +4,7 @@ from functools import wraps from ansys.api.geometry.v0.bodies_pb2 import ( + BooleanRequest, CopyRequest, SetAssignedMaterialRequest, TranslateRequest, @@ -367,6 +368,55 @@ def plot( """ return + def intersect(self, other: "Body") -> None: + """ + Intersect two bodies. `self` will be directly modified with the result, and `other` will be + consumed, so it is important to make copies if needed. + + Parameters + ---------- + other : Body + The body to intersect with. + + Raises + ------ + ValueError + If the bodies do not intersect. + """ + return + + @protect_grpc + def subtract(self, other: "Body") -> None: + """ + Subtract two bodies. `self` is the minuend, and `other` is the subtrahend + (`self` - `other`). `self` will be directly modified with the result, and + `other` will be consumed, so it is important to make copies if needed. + + Parameters + ---------- + other : Body + The body to subtract from self. + + Raises + ------ + ValueError + If the subtraction results in an empty (complete) subtraction. + """ + return + + @protect_grpc + def unite(self, other: "Body") -> None: + """ + Unite two bodies. `self` will be directly modified with the resulting union, and `other` + will be consumed, so it is important to make copies if needed. + + Parameters + ---------- + other : Body + The body to unite with self. + """ + return + class TemplateBody(IBody): """ @@ -687,6 +737,21 @@ def plot( pl.add_body(self, merge=merge, **plotting_options) pl_helper.show_plotter(pl, screenshot=screenshot) + def intersect(self, other: "Body") -> None: + raise NotImplementedError( + "TemplateBody does not implement boolean methods. Call this method on a Body instead." + ) + + def subtract(self, other: "Body") -> None: + raise NotImplementedError( + "TemplateBody does not implement boolean methods. Call this method on a Body instead." + ) + + def unite(self, other: "Body") -> None: + raise NotImplementedError( + "TemplateBody does not implement boolean methods. Call this method on a Body instead." + ) + def __repr__(self) -> str: """String representation of the body.""" lines = [f"ansys.geometry.core.designer.TemplateBody {hex(id(self))}"] @@ -725,6 +790,27 @@ def __init__(self, id, name, parent: "Component", template: TemplateBody) -> Non self._parent = parent self._template = template + def reset_tessellation_cache(func): + """Decorator for ``Body`` methods that require a tessellation cache update. + + Parameters + ---------- + func : method + The method being called. + + Returns + ------- + Any + The output of the method, if any. + """ + + @wraps(func) + def wrapper(self: "Body", *args, **kwargs): + self._template._tessellation = None + return func(self, *args, **kwargs) + + return wrapper + @property def id(self) -> str: return self._id @@ -845,6 +931,38 @@ def plot( ) -> None: return self._template.plot(merge, screenshot, use_trame, **plotting_options) + @protect_grpc + @reset_tessellation_cache + def intersect(self, other: "Body") -> None: + response = self._template._bodies_stub.Boolean( + BooleanRequest(body1=self.id, body2=other.id, method="intersect") + ).empty_result + + if response == 1: + raise ValueError("Bodies do not intersect.") + + other.parent.delete_body(other) + + @protect_grpc + @reset_tessellation_cache + def subtract(self, other: "Body") -> None: + response = self._template._bodies_stub.Boolean( + BooleanRequest(body1=self.id, body2=other.id, method="subtract") + ).empty_result + + if response == 1: + raise ValueError("Subtraction of bodies results in an empty (complete) subtraction.") + + other.parent.delete_body(other) + + @protect_grpc + @reset_tessellation_cache + def unite(self, other: "Body") -> None: + self._template._bodies_stub.Boolean( + BooleanRequest(body1=self.id, body2=other.id, method="unite") + ) + other.parent.delete_body(other) + def __repr__(self) -> str: """String representation of the body.""" lines = [f"ansys.geometry.core.designer.Body {hex(id(self))}"] diff --git a/tests/integration/test_design.py b/tests/integration/test_design.py index deaeaa8fb1..47b77b084e 100644 --- a/tests/integration/test_design.py +++ b/tests/integration/test_design.py @@ -27,7 +27,7 @@ UnitVector3D, Vector3D, ) -from ansys.geometry.core.misc import DEFAULT_UNITS, UNITS, Distance +from ansys.geometry.core.misc import DEFAULT_UNITS, UNITS, Accuracy, Distance from ansys.geometry.core.sketch import Sketch @@ -1334,3 +1334,250 @@ def test_component_instances(modeler: Modeler, skip_not_on_linux_service): # If monikers were formatted properly, you should be able to use them assert len(car2.components[1].components[1].bodies[0].faces) > 0 + + +def test_boolean_body_operations(modeler: Modeler, skip_not_on_linux_service): + """ + Test cases: + 1) master/master + a) intersect + i) normal + x) identity + y) transform + ii) empty failure + b) subtract + i) normal + x) identity + y) transform + ii) empty failure + iii) disjoint + c) unite + i) normal + x) identity + y) transform + ii) disjoint + 2) instance/instance + a) intersect + i) normal + x) identity + y) transform + ii) empty failure + b) subtract + i) normal + x) identity + y) transform + ii) empty failure + c) unite + i) normal + x) identity + y) transform + """ + + design = modeler.create_design("TestBooleanOperations") + + comp1 = design.add_component("Comp1") + comp2 = design.add_component("Comp2") + comp3 = design.add_component("Comp3") + + body1 = comp1.extrude_sketch("Body1", Sketch().box(Point2D([0, 0]), 1, 1), 1) + body2 = comp2.extrude_sketch("Body2", Sketch().box(Point2D([0.5, 0]), 1, 1), 1) + body3 = comp3.extrude_sketch("Body3", Sketch().box(Point2D([5, 0]), 1, 1), 1) + + # 1.a.i.x + copy1 = body1.copy(comp1, "Copy1") + copy2 = body2.copy(comp2, "Copy2") + copy1.intersect(copy2) + + assert not copy2.is_alive + assert body2.is_alive + assert Accuracy.length_is_equal(copy1.volume.m, 0.5) + + # 1.a.i.y + copy1 = body1.copy(comp1, "Copy1") + copy2 = body2.copy(comp2, "Copy2") + copy2.translate(UnitVector3D([1, 0, 0]), 0.25) + copy1.intersect(copy2) + + assert not copy2.is_alive + assert Accuracy.length_is_equal(copy1.volume.m, 0.25) + + # 1.a.ii + copy1 = body1.copy(comp1, "Copy1") + copy3 = body3.copy(comp3, "Copy3") + with pytest.raises(ValueError, match="Bodies do not intersect."): + copy1.intersect(copy3) + + assert copy1.is_alive + assert copy3.is_alive + + # 1.b.i.x + copy1 = body1.copy(comp1, "Copy1") + copy2 = body2.copy(comp2, "Copy2") + copy1.subtract(copy2) + + assert not copy2.is_alive + assert body2.is_alive + assert Accuracy.length_is_equal(copy1.volume.m, 0.5) + + # 1.b.i.y + copy1 = body1.copy(comp1, "Copy1") + copy2 = body2.copy(comp2, "Copy2") + copy2.translate(UnitVector3D([1, 0, 0]), 0.25) + copy1.subtract(copy2) + + assert not copy2.is_alive + assert Accuracy.length_is_equal(copy1.volume.m, 0.75) + + # 1.b.ii + copy1 = body1.copy(comp1, "Copy1") + copy1a = body1.copy(comp1, "Copy1a") + with pytest.raises(ValueError): + copy1.subtract(copy1a) + + assert copy1.is_alive + assert copy1a.is_alive + + # 1.b.iii + copy1 = body1.copy(comp1, "Copy1") + copy3 = body3.copy(comp3, "Copy3") + copy1.subtract(copy3) + + assert Accuracy.length_is_equal(copy1.volume.m, 1) + assert copy1.volume + assert not copy3.is_alive + + # 1.c.i.x + copy1 = body1.copy(comp1, "Copy1") + copy2 = body2.copy(comp2, "Copy2") + copy1.unite(copy2) + + assert not copy2.is_alive + assert body2.is_alive + assert Accuracy.length_is_equal(copy1.volume.m, 1.5) + + # 1.c.i.y + copy1 = body1.copy(comp1, "Copy1") + copy2 = body2.copy(comp2, "Copy2") + copy2.translate(UnitVector3D([1, 0, 0]), 0.25) + copy1.unite(copy2) + + assert not copy2.is_alive + assert Accuracy.length_is_equal(copy1.volume.m, 1.75) + + # 1.c.ii + copy1 = body1.copy(comp1, "Copy1") + copy3 = body3.copy(comp3, "Copy3") + copy1.unite(copy3) + + assert not copy3.is_alive + assert body3.is_alive + assert Accuracy.length_is_equal(copy1.volume.m, 1) + + # Test instance/instance + comp1_i = design.add_component("Comp1_i", comp1) + comp2_i = design.add_component("Comp2_i", comp2) + comp3_i = design.add_component("Comp3_i", comp3) + + comp1_i.modify_placement( + Vector3D([52, 61, -43]), Point3D([-4, 26, 66]), UnitVector3D([-21, 20, 87]), np.pi / 4 + ) + comp2_i.modify_placement( + Vector3D([52, 61, -43]), Point3D([-4, 26, 66]), UnitVector3D([-21, 20, 87]), np.pi / 4 + ) + comp3_i.modify_placement( + Vector3D([52, 61, -43]), Point3D([-4, 26, 66]), UnitVector3D([-21, 20, 87]), np.pi / 4 + ) + + body1 = comp1_i.bodies[0] + body2 = comp2_i.bodies[0] + body3 = comp3_i.bodies[0] + + # 2.a.i.x + copy1 = body1.copy(comp1_i, "Copy1") + copy2 = body2.copy(comp2_i, "Copy2") + copy1.intersect(copy2) + + assert not copy2.is_alive + assert body2.is_alive + assert Accuracy.length_is_equal(copy1.volume.m, 0.5) + + # 2.a.i.y + copy1 = body1.copy(comp1_i, "Copy1") + copy2 = body2.copy(comp2_i, "Copy2") + copy2.translate(UnitVector3D([1, 0, 0]), 0.25) + copy1.intersect(copy2) + + assert not copy2.is_alive + assert Accuracy.length_is_equal(copy1.volume.m, 0.25) + + # 2.a.ii + copy1 = body1.copy(comp1_i, "Copy1") + copy3 = body3.copy(comp3_i, "Copy3") + with pytest.raises(ValueError, match="Bodies do not intersect."): + copy1.intersect(copy3) + + assert copy1.is_alive + assert copy3.is_alive + + # 2.b.i.x + copy1 = body1.copy(comp1_i, "Copy1") + copy2 = body2.copy(comp2_i, "Copy2") + copy1.subtract(copy2) + + assert not copy2.is_alive + assert body2.is_alive + assert Accuracy.length_is_equal(copy1.volume.m, 0.5) + + # 2.b.i.y + copy1 = body1.copy(comp1_i, "Copy1") + copy2 = body2.copy(comp2_i, "Copy2") + copy2.translate(UnitVector3D([1, 0, 0]), 0.25) + copy1.subtract(copy2) + + assert not copy2.is_alive + assert Accuracy.length_is_equal(copy1.volume.m, 0.75) + + # 2.b.ii + copy1 = body1.copy(comp1_i, "Copy1") + copy1a = body1.copy(comp1_i, "Copy1a") + with pytest.raises(ValueError): + copy1.subtract(copy1a) + + assert copy1.is_alive + assert copy1a.is_alive + + # 2.b.iii + copy1 = body1.copy(comp1_i, "Copy1") + copy3 = body3.copy(comp3_i, "Copy3") + copy1.subtract(copy3) + + assert Accuracy.length_is_equal(copy1.volume.m, 1) + assert copy1.volume + assert not copy3.is_alive + + # 2.c.i.x + copy1 = body1.copy(comp1_i, "Copy1") + copy2 = body2.copy(comp2_i, "Copy2") + copy1.unite(copy2) + + assert not copy2.is_alive + assert body2.is_alive + assert Accuracy.length_is_equal(copy1.volume.m, 1.5) + + # 2.c.i.y + copy1 = body1.copy(comp1_i, "Copy1") + copy2 = body2.copy(comp2_i, "Copy2") + copy2.translate(UnitVector3D([1, 0, 0]), 0.25) + copy1.unite(copy2) + + assert not copy2.is_alive + assert Accuracy.length_is_equal(copy1.volume.m, 1.75) + + # 2.c.ii + copy1 = body1.copy(comp1_i, "Copy1") + copy3 = body3.copy(comp3_i, "Copy3") + copy1.unite(copy3) + + assert not copy3.is_alive + assert body3.is_alive + assert Accuracy.length_is_equal(copy1.volume.m, 1)