From a12427e44ad6da1e1ab166cdce7a1c7baee60953 Mon Sep 17 00:00:00 2001 From: Dion Moult Date: Sat, 18 Jan 2025 21:13:37 +1100 Subject: [PATCH] See #5972. Add tests for adding boolean and check against recursive booleans and invalid boolean items. --- src/bonsai/bonsai/bim/module/model/opening.py | 40 +--- src/bonsai/bonsai/tool/geometry.py | 16 ++ .../ifcopenshell/api/geometry/add_boolean.py | 177 +++++------------- .../test/api/geometry/test_add_boolean.py | 133 ++++++++++--- 4 files changed, 177 insertions(+), 189 deletions(-) diff --git a/src/bonsai/bonsai/bim/module/model/opening.py b/src/bonsai/bonsai/bim/module/model/opening.py index 26674bf96d..263b84c92f 100644 --- a/src/bonsai/bonsai/bim/module/model/opening.py +++ b/src/bonsai/bonsai/bim/module/model/opening.py @@ -533,11 +533,11 @@ class AddBoolean(Operator, tool.Ifc.Operator): def _execute(self, context): first_obj = tool.Blender.get_active_object() - if not tool.Geometry.is_representation_item(first_obj): - self.report({"INFO"}, "At least two representation items must be selected to add a boolean.") + if not first_obj or not tool.Geometry.is_boolean_operand(first_obj): + self.report({"INFO"}, "At least two valid objects must be selected to add a boolean.") return {"CANCELLED"} second_objs = [ - o for o in tool.Blender.get_selected_objects() if o != first_obj and tool.Geometry.is_representation_item(o) + o for o in tool.Blender.get_selected_objects() if o != first_obj and tool.Geometry.is_boolean_operand(o) ] if not second_objs: self.report({"INFO"}, "At least two representation items must be selected to add a boolean.") @@ -546,38 +546,8 @@ class AddBoolean(Operator, tool.Ifc.Operator): props = context.scene.BIMBooleanProperties first_item = tool.Ifc.get().by_id(first_obj.data.BIMMeshProperties.ifc_definition_id) - - while True: - is_part_of_boolean = False - for inverse in tool.Ifc.get().get_inverse(first_item): - if inverse.is_a("IfcBooleanResult"): - is_part_of_boolean = True - first_item = inverse - if not is_part_of_boolean: - break - - # Don't replace style or aspect relationships. - to_replace = set( - [ - i - for i in tool.Ifc.get().get_inverse(first_item) - if i.is_a("IfcShapeRepresentation") or i.is_a("IfcBooleanResult") - ] - ) - - first = first_item - - booleans = set() - for second_obj in second_objs: - second = tool.Ifc.get().by_id(second_obj.data.BIMMeshProperties.ifc_definition_id) - for inverse in tool.Ifc.get().get_inverse(second): - if inverse.is_a("IfcShapeRepresentation"): - inverse.Items = list(set(inverse.Items) - {second}) - first = tool.Ifc.get().create_entity("IfcBooleanResult", props.operator, first, second) - booleans.add(first) - - for inverse in to_replace: - ifcopenshell.util.element.replace_attribute(inverse, first_item, first) + second_items = [tool.Ifc.get().by_id(o.data.BIMMeshProperties.ifc_definition_id) for o in second_objs] + booleans = ifcopenshell.api.geometry.add_boolean(tool.Ifc.get(), first_item, second_items, props.operator) rep_obj = bpy.context.scene.BIMGeometryProperties.representation_obj rep_element = tool.Ifc.get_entity(rep_obj) diff --git a/src/bonsai/bonsai/tool/geometry.py b/src/bonsai/bonsai/tool/geometry.py index d981614bcc..fc6874e2dd 100644 --- a/src/bonsai/bonsai/tool/geometry.py +++ b/src/bonsai/bonsai/tool/geometry.py @@ -978,6 +978,22 @@ class Geometry(bonsai.core.tool.Geometry): and tool.Ifc.get().by_id(ifc_id).is_a("IfcRepresentationItem") ) + @classmethod + def is_boolean_operand(cls, obj: bpy.types.Object) -> bool: + return bool( + (data := obj.data) + and isinstance(data, Geometry.TYPES_WITH_MESH_PROPERTIES) + and (ifc_id := data.BIMMeshProperties.ifc_definition_id) + and (item := tool.Ifc.get().by_id(ifc_id)) + and ( + item.is_a("IfcBooleanResult") + or item.is_a("IfcCsgPrimitive3D") + or item.is_a("IfcHalfSpaceSolid") + or item.is_a("IfcSolidModel") + or item.is_a("IfcTessellatedFaceSet") + ) + ) + @classmethod def is_text_literal(cls, representation: ifcopenshell.entity_instance) -> bool: items = ifcopenshell.util.representation.resolve_items(representation) diff --git a/src/ifcopenshell-python/ifcopenshell/api/geometry/add_boolean.py b/src/ifcopenshell-python/ifcopenshell/api/geometry/add_boolean.py index 026e118805..31e5f9646e 100644 --- a/src/ifcopenshell-python/ifcopenshell/api/geometry/add_boolean.py +++ b/src/ifcopenshell-python/ifcopenshell/api/geometry/add_boolean.py @@ -17,147 +17,60 @@ # along with IfcOpenShell. If not, see . from __future__ import annotations -import ifcopenshell.util.unit -import numpy as np -import numpy.typing as npt -from typing import Optional, TYPE_CHECKING, Literal - -if TYPE_CHECKING: - import bpy.types - - -NPArrayOfFloats = npt.NDArray[np.float64] +import ifcopenshell.util.element def add_boolean( file: ifcopenshell.file, - representation: ifcopenshell.entity_instance, - # A matrix to define a clipping Ifchalfspacesolid. - # The XY plane is the clipping boundary and +Z is removed. + first_item: ifcopenshell.entity_instance, + second_items: list[ifcopenshell.entity_instance], operator: str = "DIFFERENCE", - # IfcHalfSpaceSolid, Mesh - type: Literal["IfcHalfSpaceSolid", "Mesh"] = "IfcHalfSpaceSolid", - matrix: Optional[NPArrayOfFloats] = None, - # A Blender OBJ to define the voided OBJ for a "Mesh" type - blender_obj: Optional[bpy.types.Object] = None, - # A Blender OBJ to define the void OBJ for a "Mesh" type - blender_void: Optional[bpy.types.Object] = None, - should_force_faceted_brep: bool = False, - should_force_triangulation: bool = False, -) -> list[ifcopenshell.entity_instance]: - """For `type` values: - - "IfcHalfSpaceSolid" - `matrix` is not optional. - - "Mesh" - `blender_obj` and `blender_void` are not optional - """ - usecase = Usecase() - usecase.file = file - usecase.settings = { - "representation": representation, - "operator": operator, - "type": type, - "matrix": matrix, - "blender_obj": blender_obj, - "blender_void": blender_void, - "should_force_faceted_brep": should_force_faceted_brep, - "should_force_triangulation": should_force_triangulation, - } - return usecase.execute() +) -> set[ifcopenshell.entity_instance]: + original_first_item = first_item + if first_item in second_items: + second_items.remove(first_item) + while True: + is_part_of_boolean = False + for inverse in file.get_inverse(first_item): + if inverse.is_a("IfcBooleanResult"): + is_part_of_boolean = True + first_item = inverse + if inverse.FirstOperand == original_first_item and inverse.SecondOperand in second_items: + second_items.remove(inverse.SecondOperand) + elif inverse.SecondOperand == original_first_item and inverse.FirstOperand in second_items: + second_items.remove(inverse.FirstOperand) + break + if not is_part_of_boolean: + break -class Usecase: - def execute(self): - self.settings["unit_scale"] = ifcopenshell.util.unit.calculate_unit_scale(self.file) - if self.settings["type"] == "IfcHalfSpaceSolid": - result = self.create_half_space_solid() - elif self.settings["type"] == "Mesh": - if self.settings["blender_obj"]: - result = self.create_blender_mesh() - items = [] - for item in self.settings["representation"].Items: - if ( - self.settings["operator"] == "DIFFERENCE" - and result.is_a("IfcHalfSpaceSolid") - and ( - item.is_a("IfcSweptAreaSolid") - or item.is_a("IfcSweptDiskSolid") - or item.is_a("IfcBooleanClippingResult") - ) - ): - items.append(self.file.createIfcBooleanClippingResult(self.settings["operator"], item, result)) - representation_type = "Clipping" - else: - items.append(self.file.createIfcBooleanResult(self.settings["operator"], item, result)) - representation_type = "CSG" - self.settings["representation"].RepresentationType = representation_type - self.settings["representation"].Items = items - return items + if not second_items: + return - def create_half_space_solid(self): - clipping = np.array(self.settings["matrix"])[:3] - local_z = self.file.createIfcDirection(clipping[:, 2].tolist()) - local_x = self.file.createIfcDirection(clipping[:, 0].tolist()) - point = self.file.createIfcCartesianPoint(self.convert_si_to_unit(clipping[:, 3]).tolist()) - placement = self.file.createIfcAxis2Placement3D(point, local_z, local_x) - plane = self.file.createIfcPlane(placement) - return self.file.createIfcHalfSpaceSolid(plane, AgreementFlag=False) + # Don't replace style or aspect relationships. + to_replace = set( + [ + i + for i in file.get_inverse(first_item) + if i.is_a("IfcShapeRepresentation") or i.is_a("IfcBooleanResult") + ] + ) - def create_blender_mesh(self): - self.ifc_vertices = [] - if self.file.schema == "IFC2X3" or self.settings["should_force_faceted_brep"]: - return self.create_faceted_brep() - if self.settings["should_force_triangulation"]: - return self.create_triangulated_face_set() - return self.create_polygonal_face_set() + first = first_item - def create_faceted_brep(self): - self.create_vertices() - faces = [] - for polygon in self.settings["blender_void"].data.polygons: - faces.append( - self.file.createIfcFace( - [ - self.file.createIfcFaceOuterBound( - self.file.createIfcPolyLoop([self.ifc_vertices[vertice] for vertice in polygon.vertices]), - True, - ) - ] - ) - ) - # TODO: May not actually be a closed shell, but who checks anyway? - return self.file.createIfcFacetedBrep(self.file.createIfcClosedShell(faces)) + booleans = set() + for second_item in second_items: + for inverse in file.get_inverse(second_item): + if inverse.is_a("IfcShapeRepresentation"): + inverse.Items = list(set(inverse.Items) - {second_item}) + if first.is_a("IfcTesselatedFaceSet"): + first.Closed = True # For now, trust the user to do the right thing. + if second_item.is_a("IfcTesselatedFaceSet"): + second_item.Closed = True # For now, trust the user to do the right thing. + first = file.create_entity("IfcBooleanResult", operator, first, second_item) + booleans.add(first) - def create_triangulated_face_set(self): - faces = [] - for polygon in self.settings["blender_void"].data.polygons: - faces.append([v + 1 for v in polygon.vertices]) + for inverse in to_replace: + ifcopenshell.util.element.replace_attribute(inverse, first_item, first) - mat1 = self.settings["blender_void"].matrix_world - mat2 = self.settings["blender_obj"].matrix_world.inverted() - coordinates = self.file.createIfcCartesianPointList3D( - [self.convert_si_to_unit(mat2 @ mat1 @ v.co) for v in self.settings["blender_void"].data.vertices] - ) - return self.file.createIfcTriangulatedFaceSet(coordinates, None, None, faces) - - def create_polygonal_face_set(self): - faces = [] - for polygon in self.settings["blender_void"].data.polygons: - faces.append(self.file.createIfcIndexedPolygonalFace([v + 1 for v in polygon.vertices])) - mat1 = self.settings["blender_void"].matrix_world - mat2 = self.settings["blender_obj"].matrix_world.inverted() - coordinates = self.file.createIfcCartesianPointList3D( - [self.convert_si_to_unit(mat2 @ mat1 @ v.co) for v in self.settings["blender_void"].data.vertices] - ) - return self.file.createIfcPolygonalFaceSet(coordinates, None, faces) - - def create_vertices(self): - mat1 = self.settings["blender_void"].matrix_world - mat2 = self.settings["blender_obj"].matrix_world.inverted() - self.ifc_vertices.extend( - [ - self.file.createIfcCartesianPoint(self.convert_si_to_unit(mat2 @ mat1 @ v.co)) - for v in self.settings["blender_void"].data.vertices - ] - ) - - def convert_si_to_unit(self, co): - return co / self.settings["unit_scale"] + return booleans diff --git a/src/ifcopenshell-python/test/api/geometry/test_add_boolean.py b/src/ifcopenshell-python/test/api/geometry/test_add_boolean.py index 04b829b264..584099fc62 100644 --- a/src/ifcopenshell-python/test/api/geometry/test_add_boolean.py +++ b/src/ifcopenshell-python/test/api/geometry/test_add_boolean.py @@ -20,38 +20,127 @@ import test.bootstrap import ifcopenshell.api.root import ifcopenshell.api.context import ifcopenshell.api.geometry -import numpy as np class TestAddBoolean(test.bootstrap.IFC4): - def test_returning_ifc_boolean_clipping_result(self): + def test_adding_a_boolean_from_two_top_level_items(self): ifcopenshell.api.root.create_entity(self.file, ifc_class="IfcProject") model = ifcopenshell.api.context.add_context(self.file, context_type="Model") body = ifcopenshell.api.context.add_context( - self.file, - context_type="Model", - context_identifier="Body", - target_view="MODEL_VIEW", - parent=model, + self.file, context_type="Model", context_identifier="Body", target_view="MODEL_VIEW", parent=model ) - wall = ifcopenshell.api.root.create_entity(self.file, ifc_class="IfcWall") + builder = ifcopenshell.util.shape_builder.ShapeBuilder(self.file) + first = builder.sphere() + second = builder.block() + rep = builder.get_representation(body, [first, second]) - profile = self.file.create_entity( - "IfcIShapeProfileDef", - ProfileName="HEA100", - ProfileType="AREA", - OverallWidth=100, - OverallDepth=96, - WebThickness=5, - FlangeThickness=8, - FilletRadius=12, + booleans = ifcopenshell.api.geometry.add_boolean(self.file, first, [second]) + assert len(booleans) == 1 + boolean = list(booleans)[0] + assert boolean.is_a("IfcBooleanResult") + assert boolean.FirstOperand == first + assert boolean.SecondOperand == second + assert boolean.Operator == "DIFFERENCE" + assert set(rep.Items) == {boolean} + + def test_adding_multiple_booleans_from_three_top_level_items(self): + ifcopenshell.api.root.create_entity(self.file, ifc_class="IfcProject") + model = ifcopenshell.api.context.add_context(self.file, context_type="Model") + body = ifcopenshell.api.context.add_context( + self.file, context_type="Model", context_identifier="Body", target_view="MODEL_VIEW", parent=model ) - rep = ifcopenshell.api.geometry.add_profile_representation(self.file, context=body, profile=profile, depth=5) - ifcopenshell.api.geometry.assign_representation(self.file, product=wall, representation=rep) + builder = ifcopenshell.util.shape_builder.ShapeBuilder(self.file) + first = builder.sphere() + second1 = builder.block() + second2 = builder.block() + rep = builder.get_representation(body, [first, second1, second2]) - ifcopenshell.api.geometry.add_boolean(self.file, representation=rep, matrix=np.eye(4)) - assert rep.Items[0].is_a() == "IfcBooleanClippingResult" - assert rep.RepresentationType == "Clipping" + booleans = ifcopenshell.api.geometry.add_boolean(self.file, first, [second1, second2]) + assert len(booleans) == 2 + assert len(rep.Items) == 1 + assert rep.Items[0].FirstOperand.is_a("IfcBooleanResult") + assert rep.Items[0].FirstOperand.is_a("IfcBooleanResult") + assert rep.Items[0].SecondOperand == second2 + assert rep.Items[0].Operator == "DIFFERENCE" + assert rep.Items[0].FirstOperand.FirstOperand == first + assert rep.Items[0].FirstOperand.SecondOperand == second1 + assert rep.Items[0].FirstOperand.Operator == "DIFFERENCE" + + def test_adding_a_boolean_to_an_existing_operand_from_a_top_level_item(self): + ifcopenshell.api.root.create_entity(self.file, ifc_class="IfcProject") + model = ifcopenshell.api.context.add_context(self.file, context_type="Model") + body = ifcopenshell.api.context.add_context( + self.file, context_type="Model", context_identifier="Body", target_view="MODEL_VIEW", parent=model + ) + builder = ifcopenshell.util.shape_builder.ShapeBuilder(self.file) + first = builder.sphere() + second1 = builder.block() + second2 = builder.block() + rep = builder.get_representation(body, [first, second1]) + booleans = ifcopenshell.api.geometry.add_boolean(self.file, first, [second1]) + rep.Items = list(rep.Items) + [second2] + booleans = ifcopenshell.api.geometry.add_boolean(self.file, first, [second2]) + assert len(booleans) == 1 + assert len(rep.Items) == 1 + assert rep.Items[0].FirstOperand.is_a("IfcBooleanResult") + assert rep.Items[0].SecondOperand == second2 + assert rep.Items[0].FirstOperand.FirstOperand == first + assert rep.Items[0].FirstOperand.SecondOperand == second1 + + def test_adding_a_boolean_to_an_existing_operand_from_another_operand(self): + ifcopenshell.api.root.create_entity(self.file, ifc_class="IfcProject") + model = ifcopenshell.api.context.add_context(self.file, context_type="Model") + body = ifcopenshell.api.context.add_context( + self.file, context_type="Model", context_identifier="Body", target_view="MODEL_VIEW", parent=model + ) + builder = ifcopenshell.util.shape_builder.ShapeBuilder(self.file) + first1 = builder.sphere() + second1 = builder.block() + first2 = builder.sphere() + second2 = builder.block() + rep = builder.get_representation(body, [first1, first2, second1, second2]) + booleans = ifcopenshell.api.geometry.add_boolean(self.file, first1, [second1]) + booleans = ifcopenshell.api.geometry.add_boolean(self.file, first2, [second2]) + booleans = ifcopenshell.api.geometry.add_boolean(self.file, first1, [second2]) + + assert len(booleans) == 1 + assert len(rep.Items) == 2 + + assert len(self.file.get_inverse(first1)) == 1 + result = list(self.file.get_inverse(first1))[0] + assert result.FirstOperand == first1 + assert result.SecondOperand == second1 + result2 = list(self.file.get_inverse(result))[0] + assert result2.FirstOperand == result + # Second2 is now used twice. Reusing is OK (albeit confusing), so long as things don't get recursive. + assert result2.SecondOperand == second2 + + assert len(self.file.get_inverse(first2)) == 1 + result3 = list(self.file.get_inverse(first2))[0] + assert result3.FirstOperand == first2 + assert result3.SecondOperand == second2 + + def test_preventing_recursive_booleans(self): + ifcopenshell.api.root.create_entity(self.file, ifc_class="IfcProject") + model = ifcopenshell.api.context.add_context(self.file, context_type="Model") + body = ifcopenshell.api.context.add_context( + self.file, context_type="Model", context_identifier="Body", target_view="MODEL_VIEW", parent=model + ) + builder = ifcopenshell.util.shape_builder.ShapeBuilder(self.file) + first = builder.sphere() + second = builder.block() + rep = builder.get_representation(body, [first, second]) + ifcopenshell.api.geometry.add_boolean(self.file, first, [second]) + ifcopenshell.api.geometry.add_boolean(self.file, first, [second]) + assert len(rep.Items) == 1 + assert rep.Items[0].FirstOperand == first + assert rep.Items[0].SecondOperand == second + ifcopenshell.api.geometry.add_boolean(self.file, second, [second]) + ifcopenshell.api.geometry.add_boolean(self.file, second, [first]) + assert len(rep.Items) == 1 + assert rep.Items[0].FirstOperand == first + assert rep.Items[0].SecondOperand == second + assert len(self.file.by_type("IfcBooleanResult")) == 1 class TestAddBooleanIFC2X3(test.bootstrap.IFC2X3, TestAddBoolean):