See #5972. Add tests for adding boolean and check against recursive booleans and invalid boolean items.

This commit is contained in:
Dion Moult
2025-01-18 21:13:37 +11:00
parent 35734f4b67
commit a12427e44a
4 changed files with 177 additions and 189 deletions
+5 -35
View File
@@ -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)
+16
View File
@@ -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)
@@ -17,147 +17,60 @@
# along with IfcOpenShell. If not, see <http://www.gnu.org/licenses/>.
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
@@ -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):