mirror of
https://github.com/IfcOpenShell/IfcOpenShell.git
synced 2026-08-09 17:31:45 +00:00
See #5972. Add tests for adding boolean and check against recursive booleans and invalid boolean items.
This commit is contained in:
@@ -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)
|
||||
|
||||
@@ -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):
|
||||
|
||||
Reference in New Issue
Block a user