diff --git a/src/bonsai/bonsai/bim/handler.py b/src/bonsai/bonsai/bim/handler.py index b9194e84ed..c7114373bf 100644 --- a/src/bonsai/bonsai/bim/handler.py +++ b/src/bonsai/bonsai/bim/handler.py @@ -33,6 +33,7 @@ from bonsai.bim.module.model.workspace import LIST_OF_TOOLS, TOOLS_TO_CLASSES_MA from bonsai.bim.module.aggregate.decorator import AggregateDecorator from bonsai.bim.module.georeference.decorator import GeoreferenceDecorator from bonsai.bim.module.model.decorator import WallAxisDecorator, SlabDirectionDecorator +from bonsai.bim.module.nest.decorator import NestDecorator from mathutils import Vector from math import cos, degrees from typing import Union, Callable @@ -323,11 +324,14 @@ def load_post(scene): # Bonsai overlays georeference_props = bpy.context.scene.BIMGeoreferenceProperties aggregate_props = bpy.context.scene.BIMAggregateProperties + nest_props = bpy.context.scene.BIMNestProperties model_props = bpy.context.scene.BIMModelProperties - if aggregate_props.aggregate_decorator: - AggregateDecorator.install(bpy.context) if georeference_props.should_visualise: GeoreferenceDecorator.install(bpy.context) + if aggregate_props.aggregate_decorator: + AggregateDecorator.install(bpy.context) + if nest_props.nest_decorator: + NestDecorator.install(bpy.context) if model_props.show_wall_axis: WallAxisDecorator.install(bpy.context) if model_props.show_slab_direction: diff --git a/src/bonsai/bonsai/bim/module/aggregate/decorator.py b/src/bonsai/bonsai/bim/module/aggregate/decorator.py index 1fced74817..a7f9f41658 100644 --- a/src/bonsai/bonsai/bim/module/aggregate/decorator.py +++ b/src/bonsai/bonsai/bim/module/aggregate/decorator.py @@ -167,6 +167,8 @@ class AggregateDecorator: theme = context.preferences.themes.items()[0][1] selected_object_color = (*theme.view_3d.object_active, 1) + self.shader = gpu.shader.from_builtin("UNIFORM_COLOR") + self.shader.bind() self.line_shader = gpu.shader.from_builtin("POLYLINE_UNIFORM_COLOR") self.line_shader.bind() # POLYLINE_UNIFORM_COLOR specific uniforms diff --git a/src/bonsai/bonsai/bim/module/geometry/operator.py b/src/bonsai/bonsai/bim/module/geometry/operator.py index bb281abb14..f174921e4d 100644 --- a/src/bonsai/bonsai/bim/module/geometry/operator.py +++ b/src/bonsai/bonsai/bim/module/geometry/operator.py @@ -3037,6 +3037,23 @@ class OverrideMove(bpy.types.Operator): return {"FINISHED"} def _execute(self, context): + # Get filling objects + selection = [] + for obj in context.selected_objects: + element = tool.Ifc.get_entity(obj) + if not element: + continue + selection.append(obj) + for rel in getattr(element, "HasOpenings", []) or []: + opening = rel.RelatedOpeningElement + for rel2 in getattr(opening, "HasFillings", []) or []: + filling = rel2.RelatedBuildingElement + selection.append(tool.Ifc.get_object(filling)) + for obj in selection: + obj.select_set(True) + self.new_active_obj = obj + + # Get aggregates props = context.scene.BIMAggregateProperties not_editing_objs = [o.obj for o in props.not_editing_objects] @@ -3046,6 +3063,8 @@ class OverrideMove(bpy.types.Operator): if obj in not_editing_objs: obj.select_set(False) continue + if obj == props.editing_aggregate: + continue element = tool.Ifc.get_entity(obj) if not element or not element.is_a("IfcElement"): continue @@ -3079,6 +3098,8 @@ class OverrideMove(bpy.types.Operator): if obj in not_editing_objs: obj.select_set(False) continue + if obj == props.editing_nest: + continue element = tool.Ifc.get_entity(obj) if not element or not element.is_a("IfcElement"): continue diff --git a/src/bonsai/bonsai/bim/module/model/__init__.py b/src/bonsai/bonsai/bim/module/model/__init__.py index ae009991db..adf9e462bb 100644 --- a/src/bonsai/bonsai/bim/module/model/__init__.py +++ b/src/bonsai/bonsai/bim/module/model/__init__.py @@ -88,6 +88,7 @@ classes = ( opening.HideOpenings, opening.PurgeUnusedOpenings, opening.RecalculateFill, + opening.RemoveBoolean, opening.RemoveBooleans, opening.ShowBooleans, opening.ShowOpenings, diff --git a/src/bonsai/bonsai/bim/module/model/opening.py b/src/bonsai/bonsai/bim/module/model/opening.py index 263b84c92f..5a07eff3f7 100644 --- a/src/bonsai/bonsai/bim/module/model/opening.py +++ b/src/bonsai/bonsai/bim/module/model/opening.py @@ -1144,6 +1144,27 @@ class PurgeUnusedOpenings(Operator, tool.Ifc.Operator): return {"FINISHED"} +class RemoveBoolean(Operator, tool.Ifc.Operator): + bl_idname = "bim.remove_boolean" + bl_label = "Remove Boolean" + bl_options = {"REGISTER", "UNDO"} + bl_description = "Removes the actively selected boolean" + + @classmethod + def poll(cls, context): + props = context.scene.BIMBooleanProperties + return props.active_boolean + + def _execute(self, context): + props = context.scene.BIMBooleanProperties + ifcopenshell.api.geometry.remove_boolean( + tool.Ifc.get(), tool.Ifc.get().by_id(props.active_boolean.ifc_definition_id) + ) + bpy.ops.bim.enable_editing_booleans() + rep_obj = bpy.context.scene.BIMGeometryProperties.representation_obj + tool.Geometry.reload_representation(rep_obj) + + # TODO: merge with ProfileDecorator? class DecorationsHandler: installed = None diff --git a/src/bonsai/bonsai/bim/module/model/workspace.py b/src/bonsai/bonsai/bim/module/model/workspace.py index 1be2e91176..9dfcd0f8a4 100644 --- a/src/bonsai/bonsai/bim/module/model/workspace.py +++ b/src/bonsai/bonsai/bim/module/model/workspace.py @@ -590,6 +590,11 @@ class EditObjectUI: row = cls.layout.row(align=True) op = row.operator("bim.disable_aggregate_mode", text="", icon="X") op = row.operator("bim.toggle_aggregate_mode_local_view", text="", icon="ZOOM_SELECTED") + if context.scene.BIMNestProperties.in_nest_mode: + layout.label(text=f"Nest Mode", icon="EMPTY_AXIS") + row = cls.layout.row(align=True) + op = row.operator("bim.disable_nest_mode", text="", icon="X") + op = row.operator("bim.toggle_nest_mode_local_view", text="", icon="ZOOM_SELECTED") text = format_ifc_camel_case(AuthoringData.data["active_class"]) layout.label(text=f"{text} Edit Tools:", icon="RESTRICT_SELECT_OFF") diff --git a/src/bonsai/bonsai/bim/module/nest/__init__.py b/src/bonsai/bonsai/bim/module/nest/__init__.py index fc111fb818..883fd00186 100644 --- a/src/bonsai/bonsai/bim/module/nest/__init__.py +++ b/src/bonsai/bonsai/bim/module/nest/__init__.py @@ -26,6 +26,8 @@ classes = ( operator.BIM_OT_select_components, operator.BIM_OT_select_nest, operator.BIM_OT_nest_unassign_object, + operator.BIM_OT_disable_nest_mode, + operator.BIM_OT_toggle_nest_mode_local_view, prop.BIMObjectNestProperties, prop.Objects, prop.BIMNestProperties, diff --git a/src/bonsai/bonsai/bim/module/nest/decorator.py b/src/bonsai/bonsai/bim/module/nest/decorator.py index 7628219215..3dd85bf09b 100644 --- a/src/bonsai/bonsai/bim/module/nest/decorator.py +++ b/src/bonsai/bonsai/bim/module/nest/decorator.py @@ -86,7 +86,7 @@ class NestDecorator: if cls.is_installed: cls.uninstall() handler = cls() - cls.handlers.append(SpaceView3D.draw_handler_add(handler.draw_aggregate, (context,), "WINDOW", "POST_VIEW")) + cls.handlers.append(SpaceView3D.draw_handler_add(handler.draw_nest, (context,), "WINDOW", "POST_VIEW")) cls.is_installed = True @classmethod @@ -153,8 +153,8 @@ class NestDecorator: shader.uniform_float("color", color) batch.draw(shader) - def draw_aggregate(self, context): - if context.scene.BIMAggregateProperties.in_aggregate_mode: + def draw_nest(self, context): + if context.scene.BIMNestProperties.in_nest_mode: return self.addon_prefs = tool.Blender.get_addon_preferences() decorator_color_special = self.addon_prefs.decorator_color_special @@ -165,11 +165,13 @@ class NestDecorator: theme = context.preferences.themes.items()[0][1] selected_object_color = (*theme.view_3d.object_active, 1) + self.shader = gpu.shader.from_builtin("UNIFORM_COLOR") + self.shader.bind() self.line_shader = gpu.shader.from_builtin("POLYLINE_UNIFORM_COLOR") self.line_shader.bind() # POLYLINE_UNIFORM_COLOR specific uniforms self.line_shader.uniform_float("viewportSize", (context.region.width, context.region.height)) - aggregates = [] + nests = [] if not (selected_objects := context.selected_objects): return for obj in selected_objects: @@ -177,35 +179,39 @@ class NestDecorator: if not element or not element.is_a("IfcElement"): return - parts = ifcopenshell.util.element.get_parts(element) - if parts: - aggregates.append(obj) + components = ifcopenshell.util.element.get_components(element) + if components: + nests.append(obj) continue - aggregate = ifcopenshell.util.element.get_aggregate(element) - if aggregate: - aggregates.append(tool.Ifc.get_object(aggregate)) + nest = ifcopenshell.util.element.get_nest(element) + if nest: + nests.append(tool.Ifc.get_object(nest)) - aggregates = set(aggregates) - for aggregate in aggregates: + nests = set(nests) + for nest in nests: self.line_shader.uniform_float("lineWidth", 1.0) color = decorator_color_unselected - if aggregate in selected_objects: + if nest in selected_objects: color = selected_object_color - size = aggregate.empty_display_size - location = aggregate.location - line_x = (location - Vector((size, 0.0, 0.0)), location + Vector((size, 0.0, 0.0))) - self.draw_batch("LINES", line_x, color, [(0, 1)]) - line_y = (location - Vector((0.0, size, 0.0)), location + Vector((0.0, size, 0.0))) - self.draw_batch("LINES", line_y, color, [(0, 1)]) - line_z = (location - Vector((0.0, 0.0, size)), location + Vector((0.0, 0.0, size))) - self.draw_batch("LINES", line_z, color, [(0, 1)]) - if context.scene.BIMAggregateProperties.in_aggregate_mode: - return - parts = ifcopenshell.util.element.get_parts(tool.Ifc.get_entity(aggregate)) - parts_objs = [tool.Ifc.get_object(p) for p in parts] + size = nest.empty_display_size + location = nest.location + if nest.type == "EMPTY": + line_x = (location - Vector((size, 0.0, 0.0)), location + Vector((size, 0.0, 0.0))) + self.draw_batch("LINES", line_x, color, [(0, 1)]) + line_y = (location - Vector((0.0, size, 0.0)), location + Vector((0.0, size, 0.0))) + self.draw_batch("LINES", line_y, color, [(0, 1)]) + line_z = (location - Vector((0.0, 0.0, size)), location + Vector((0.0, 0.0, size))) + self.draw_batch("LINES", line_z, color, [(0, 1)]) + else: + self.draw_batch("POINTS", [location], color) + # if context.scene.BIMNestProperties.in_aggregate_mode: + # return + components = ifcopenshell.util.element.get_components(tool.Ifc.get_entity(nest)) + components_objs = [tool.Ifc.get_object(p) for p in components] + components_objs.append(nest) - indices, edges = create_bounding_box(parts_objs) + indices, edges = create_bounding_box(components_objs) self.line_shader.uniform_float("lineWidth", 0.5) self.draw_batch("LINES", indices, color, edges) line = (Vector(indices[0]), location) diff --git a/src/bonsai/bonsai/bim/module/nest/operator.py b/src/bonsai/bonsai/bim/module/nest/operator.py index 96f7930bcd..00b1f15f33 100644 --- a/src/bonsai/bonsai/bim/module/nest/operator.py +++ b/src/bonsai/bonsai/bim/module/nest/operator.py @@ -140,3 +140,33 @@ class BIM_OT_select_nest(bpy.types.Operator): nest_obj.select_set(True) bpy.context.view_layer.objects.active = nest_obj return {"FINISHED"} + + +class BIM_OT_disable_nest_mode(bpy.types.Operator): + bl_idname = "bim.disable_nest_mode" + bl_label = "Disable Nest Mode" + bl_options = {"REGISTER", "UNDO"} + + def execute(self, context): + bpy.ops.object.select_all(action="DESELECT") + core.disable_nest_mode(tool.Nest) + return {"FINISHED"} + + +class BIM_OT_toggle_nest_mode_local_view(bpy.types.Operator): + bl_idname = "bim.toggle_nest_mode_local_view" + bl_label = "Toggle Nest Mode Local View" + bl_options = {"REGISTER", "UNDO"} + + def execute(self, context): + props = context.scene.BIMNestProperties + objs = [o.obj for o in props.editing_objects] + if props.in_nest_mode: + if context.space_data.local_view: + bpy.ops.view3d.localview() + else: + for obj in objs: + obj.select_set(True) + bpy.ops.view3d.localview() + + return {"FINISHED"} diff --git a/src/bonsai/bonsai/bim/module/void/prop.py b/src/bonsai/bonsai/bim/module/void/prop.py index cdcbc61d6f..e6cf8d21f0 100644 --- a/src/bonsai/bonsai/bim/module/void/prop.py +++ b/src/bonsai/bonsai/bim/module/void/prop.py @@ -45,3 +45,8 @@ class BIMBooleanProperties(PropertyGroup): name="Operator", default="DIFFERENCE", ) + + @property + def active_boolean(self): + if self.booleans and self.active_boolean_index < len(self.booleans): + return self.booleans[self.active_boolean_index] diff --git a/src/bonsai/bonsai/bim/module/void/ui.py b/src/bonsai/bonsai/bim/module/void/ui.py index d540471904..0f924e9ca2 100644 --- a/src/bonsai/bonsai/bim/module/void/ui.py +++ b/src/bonsai/bonsai/bim/module/void/ui.py @@ -176,6 +176,10 @@ class BIM_PT_booleans(Panel): row.prop(props, "operator", text="") row.operator("bim.add_boolean", text="", icon="ADD") + row = layout.row(align=True) + row.alignment = "RIGHT" + row.operator("bim.remove_boolean", text="", icon="X") + self.layout.template_list("BIM_UL_booleans", "", props, "booleans", props, "active_boolean_index") diff --git a/src/bonsai/bonsai/bim/ui.py b/src/bonsai/bonsai/bim/ui.py index 42742fb1de..890e022684 100644 --- a/src/bonsai/bonsai/bim/ui.py +++ b/src/bonsai/bonsai/bim/ui.py @@ -1209,8 +1209,9 @@ class BIM_PT_decorators_overlay(Panel): view = context.space_data overlay = view.overlay - geo_props = bpy.context.scene.BIMGeoreferenceProperties - agg_props = bpy.context.scene.BIMAggregateProperties + georeference_props = bpy.context.scene.BIMGeoreferenceProperties + aggregate_props = bpy.context.scene.BIMAggregateProperties + nest_props = bpy.context.scene.BIMNestProperties model_props = bpy.context.scene.BIMModelProperties display_all = overlay.show_overlays @@ -1218,10 +1219,12 @@ class BIM_PT_decorators_overlay(Panel): col.active = display_all row = col.row(align=True) - row.prop(geo_props, "should_visualise", text="Georeference") - row.prop(geo_props, "visualization_scale", text="Size", slider=True) + row.prop(georeference_props, "should_visualise", text="Georeference") + row.prop(georeference_props, "visualization_scale", text="Size", slider=True) row = col.row(align=True) - row.prop(agg_props, "aggregate_decorator", text="Aggregate") + row.prop(aggregate_props, "aggregate_decorator", text="Aggregate") + row = col.row(align=True) + row.prop(nest_props, "nest_decorator", text="Nest") row = col.row(align=True) row.prop(model_props, "show_wall_axis", text="Wall Axis") row = col.row(align=True) diff --git a/src/bonsai/bonsai/tool/aggregate.py b/src/bonsai/bonsai/tool/aggregate.py index e78641276f..4ad7a95425 100644 --- a/src/bonsai/bonsai/tool/aggregate.py +++ b/src/bonsai/bonsai/tool/aggregate.py @@ -145,3 +145,4 @@ class Aggregate(bonsai.core.tool.Aggregate): props.in_aggregate_mode = False props.not_editing_objects.clear() props.editing_objects.clear() + props.editing_aggregate = None diff --git a/src/bonsai/bonsai/tool/geometry.py b/src/bonsai/bonsai/tool/geometry.py index fc6874e2dd..069ada8f13 100644 --- a/src/bonsai/bonsai/tool/geometry.py +++ b/src/bonsai/bonsai/tool/geometry.py @@ -1742,6 +1742,8 @@ class Geometry(bonsai.core.tool.Geometry): cls.unlock_object(props.representation_obj) tool.Blender.set_active_object(props.representation_obj) cls.sync_item_positions() + representation = cls.get_active_representation(props.representation_obj) + ifcopenshell.api.geometry.validate_type(tool.Ifc.get(), representation) props.is_changing_mode = True if props.mode != "OBJECT": props.mode = "OBJECT" diff --git a/src/bonsai/bonsai/tool/nest.py b/src/bonsai/bonsai/tool/nest.py index 99b87cf912..a4f0dc20ec 100644 --- a/src/bonsai/bonsai/tool/nest.py +++ b/src/bonsai/bonsai/tool/nest.py @@ -120,3 +120,4 @@ class Nest(bonsai.core.tool.Nest): props.in_nest_mode = False props.not_editing_objects.clear() props.editing_objects.clear() + props.editing_nest = None diff --git a/src/ifcopenshell-python/ifcopenshell/api/geometry/__init__.py b/src/ifcopenshell-python/ifcopenshell/api/geometry/__init__.py index 87bc9e607d..62f4765de6 100644 --- a/src/ifcopenshell-python/ifcopenshell/api/geometry/__init__.py +++ b/src/ifcopenshell-python/ifcopenshell/api/geometry/__init__.py @@ -62,6 +62,7 @@ from .map_representation import map_representation from .remove_boolean import remove_boolean from .remove_representation import remove_representation from .unassign_representation import unassign_representation +from .validate_type import validate_type wrap_usecases(__path__, __name__) @@ -88,4 +89,5 @@ __all__ = [ "remove_boolean", "remove_representation", "unassign_representation", + "validate_type", ] diff --git a/src/ifcopenshell-python/ifcopenshell/api/geometry/add_boolean.py b/src/ifcopenshell-python/ifcopenshell/api/geometry/add_boolean.py index 31e5f9646e..f5d0a31456 100644 --- a/src/ifcopenshell-python/ifcopenshell/api/geometry/add_boolean.py +++ b/src/ifcopenshell-python/ifcopenshell/api/geometry/add_boolean.py @@ -18,17 +18,60 @@ from __future__ import annotations import ifcopenshell.util.element +from typing import Literal def add_boolean( file: ifcopenshell.file, first_item: ifcopenshell.entity_instance, second_items: list[ifcopenshell.entity_instance], - operator: str = "DIFFERENCE", -) -> set[ifcopenshell.entity_instance]: + operator: Literal["DIFFERENCE", "INTERSECTION", "UNION"] = "DIFFERENCE", +) -> list[ifcopenshell.entity_instance]: + """Adds a boolean operation to two or more representation items + + If an IfcBooleanOperand is part of the top level items in an + IfcShapeRepresentation, it will be removed from that level whilst being + added to the IfcBooleanResult. This is because it is generally intuitive + that an item is either participating in a boolean operation, or being an + item in its own right, but not both. + + However, if an IfcBooleanOperand is part of another boolean operation + already, it will not be removed from the existing operation. A new + operation will be created, and therefore it will participate in two + operations. + + This function protects against recursive booleans. + + After a boolean operation is made, since the items of + IfcShapeRepresentation may be modified, it is not guaranteed that the + RepresentationType is still valid. After performing all your booleans, it + is recommended to run :func:`ifcopenshell.api.geometry.validate_csg` to + ensure correctness. + + :param first_item: The IfcBooleanOperand that the operation is performed upon + :param second_items: The IfcBooleanOperands that the operation will be + performed with, in the order given of the list. + :param operator: The type of boolean operation to perform + :return: A list of newly created IfcBooleanResult in the order of boolean + operations (based on the order of second items). If nothing was + created, the list will be empty. + """ + + def is_operand(item): + return ( + item.is_a("IfcBooleanResult") + or item.is_a("IfcCsgPrimitive3D") + or item.is_a("IfcHalfSpaceSolid") + or item.is_a("IfcSolidModel") + or item.is_a("IfcTessellatedFaceSet") + ) + + if not is_operand(first_item): + return [] + original_first_item = first_item - if first_item in second_items: - second_items.remove(first_item) + + second_items = [i for i in second_items if i != first_item and is_operand(i)] while True: is_part_of_boolean = False @@ -45,20 +88,16 @@ def add_boolean( break if not second_items: - return + return [] # 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") - ] + [i for i in file.get_inverse(first_item) if i.is_a("IfcShapeRepresentation") or i.is_a("IfcBooleanResult")] ) first = first_item - booleans = set() + booleans = [] for second_item in second_items: for inverse in file.get_inverse(second_item): if inverse.is_a("IfcShapeRepresentation"): @@ -67,8 +106,19 @@ def add_boolean( 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) + if ( + operator == "DIFFERENCE" + and second_item.is_a("IfcHalfSpaceSolid") + and ( + first.is_a("IfcSweptAreaSolid") + or first.is_a("IfcSweptDiskSolid") + or first.is_a("IfcBooleanClippingResult") + ) + ): + first = file.create_entity("IfcBooleanClippingResult", operator, first, second_item) + else: + first = file.create_entity("IfcBooleanResult", operator, first, second_item) + booleans.append(first) for inverse in to_replace: ifcopenshell.util.element.replace_attribute(inverse, first_item, first) diff --git a/src/ifcopenshell-python/ifcopenshell/api/geometry/remove_boolean.py b/src/ifcopenshell-python/ifcopenshell/api/geometry/remove_boolean.py index c24bcdbc1b..bf844838c3 100644 --- a/src/ifcopenshell-python/ifcopenshell/api/geometry/remove_boolean.py +++ b/src/ifcopenshell-python/ifcopenshell/api/geometry/remove_boolean.py @@ -20,40 +20,42 @@ import ifcopenshell.util.element def remove_boolean(file: ifcopenshell.file, item: ifcopenshell.entity_instance) -> None: - usecase = Usecase() - usecase.file = file - usecase.settings = {"item": item} - return usecase.execute() + """Removes a boolean operation without deleting the operands + The first operand will replace the boolean result itself, and the second + operand will be reset as a top level representation item. -class Usecase: - def execute(self): - item = None - for inverse in self.file.get_inverse(self.settings["item"]): + This may affect the Items of IfcShapeRepresentation, so it is recommended + to run :func:`ifcopenshell.api.geometry.validate_type` after all boolean + modifications are complete. + + :param item: This may either be an IfcBooleanResult or an + IfcRepresentationItem that is participating in one or more boolean + results (in which case all are removed). + """ + if not item.is_a("IfcBooleanResult"): + for inverse in file.get_inverse(item): if inverse.is_a("IfcBooleanResult"): - item = inverse - break + remove_boolean(file, inverse) + return - representation = self.get_representation(item) + representations = [] + queue = list(file.get_inverse(item)) + while queue: + inverse = queue.pop() + if inverse.is_a("IfcShapeRepresentation"): + representations.append(inverse) + elif inverse.is_a("IfcBooleanResult"): + queue.extend(file.get_inverse(inverse)) + elif inverse.is_a("IfcCsgSolid"): + queue.extend(file.get_inverse(inverse)) - first_operand = item.FirstOperand - second_operand = item.SecondOperand - for inverse in self.file.get_inverse(item): - ifcopenshell.util.element.replace_attribute(inverse, item, first_operand) - self.file.remove(item) - ifcopenshell.util.element.remove_deep2(self.file, second_operand) + first = item.FirstOperand + second = item.SecondOperand + for inverse in file.get_inverse(item): + ifcopenshell.util.element.replace_attribute(inverse, item, first) - item_classes = {i.is_a() for i in representation.Items} - if "IfcBooleanResult" in item_classes: - representation.RepresentationType = "CSG" - elif "IfcBooleanClippingResult" in item_classes: - representation.RepresentationType = "Clipping" - else: - representation.RepresentationType = "SweptSolid" + for representation in set(representations): + representation.Items = list(representation.Items) + [second] - def get_representation(self, item): - for inverse in self.file.get_inverse(item): - if inverse.is_a("IfcShapeRepresentation"): - return inverse - elif inverse.is_a("IfcRepresentationItem"): - return self.get_representation(inverse) + file.remove(item) diff --git a/src/ifcopenshell-python/ifcopenshell/api/geometry/validate_type.py b/src/ifcopenshell-python/ifcopenshell/api/geometry/validate_type.py new file mode 100644 index 0000000000..6be8686732 --- /dev/null +++ b/src/ifcopenshell-python/ifcopenshell/api/geometry/validate_type.py @@ -0,0 +1,87 @@ +# IfcOpenShell - IFC toolkit and geometry engine +# Copyright (C) 2025 Dion Moult +# +# This file is part of IfcOpenShell. +# +# IfcOpenShell is free software: you can redistribute it and/or modify +# it under the terms of the GNU Lesser General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# IfcOpenShell is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU Lesser General Public License for more details. +# +# You should have received a copy of the GNU Lesser General Public License +# along with IfcOpenShell. If not, see . + +import ifcopenshell.api.geometry +import ifcopenshell.util.representation + + +def validate_type( + file: ifcopenshell.file, + representation: ifcopenshell.entity_instance, + preferred_item: ifcopenshell.entity_instance | None = None, +) -> bool: + """Validates the RepresentationType of an IfcShapeRepresentation + + A shape representation has to identify its geometry using the + RepresentationType attribute. For example, if it holds tessellated + geometry, it should store "Tessellation" as its RepresentationType. + + This function checks whether or not the RepresentationType is valid. This + is a wrapper around :func:`ifcopenshell.util.representation.guess_type`. It + will then set RepresentationType to the most appropriate value, or return + False otherwise. In addition, it also attempts to reconcile otherwise + invalid CSG geometry by unioning all remaining top level items to existing + boolean results. + + :param representation: The IfcShapeRepresentation with Items + :param preferred_item: If the type is expected to be a CSG, this will be + the preferred item to union all remaining items to. If no preferred + item is provided, the first boolean result will be chosen. + :return: True if the representation type was set and it is a valid + combination, or False otherwise. + """ + def is_operand(item): + return ( + item.is_a("IfcBooleanResult") + or item.is_a("IfcCsgPrimitive3D") + or item.is_a("IfcHalfSpaceSolid") + or item.is_a("IfcSolidModel") + or item.is_a("IfcTessellatedFaceSet") + ) + + has_boolean = False + remaining_items = [] + for item in representation.Items: + if item.is_a("IfcBooleanResult"): + has_boolean = True + if item != preferred_item and is_operand(item): + remaining_items.append(item) + + if not has_boolean: + result = ifcopenshell.util.representation.guess_type(representation.Items) + if result: + representation.RepresentationType = result + return True + return False + + if not preferred_item: + # Prioritise an existing boolean result + for i in remaining_items: + if i.is_a("IfcBooleanResult"): + preferred_item = i + break + if not preferred_item and remaining_items: + preferred_item = remaining_items[0] + + if remaining_items: + ifcopenshell.api.geometry.add_boolean(file, preferred_item, remaining_items, "UNION") + + representation.RepresentationType = ifcopenshell.util.representation.guess_type(representation.Items) + if representation.RepresentationType == "CSG": + return True + return False 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 584099fc62..ba8385fa3e 100644 --- a/src/ifcopenshell-python/test/api/geometry/test_add_boolean.py +++ b/src/ifcopenshell-python/test/api/geometry/test_add_boolean.py @@ -59,7 +59,6 @@ class TestAddBoolean(test.bootstrap.IFC4): 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 diff --git a/src/ifcopenshell-python/test/api/geometry/test_remove_boolean.py b/src/ifcopenshell-python/test/api/geometry/test_remove_boolean.py new file mode 100644 index 0000000000..c77e8a080a --- /dev/null +++ b/src/ifcopenshell-python/test/api/geometry/test_remove_boolean.py @@ -0,0 +1,87 @@ +# IfcOpenShell - IFC toolkit and geometry engine +# Copyright (C) 2023 Dion Moult +# +# This file is part of IfcOpenShell. +# +# IfcOpenShell is free software: you can redistribute it and/or modify +# it under the terms of the GNU Lesser General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# IfcOpenShell is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU Lesser General Public License for more details. +# +# You should have received a copy of the GNU Lesser General Public License +# along with IfcOpenShell. If not, see . + +import test.bootstrap +import ifcopenshell.api.root +import ifcopenshell.api.context +import ifcopenshell.api.geometry + + +class TestRemoveBoolean(test.bootstrap.IFC4): + def test_removing_a_single_top_level_boolean(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]) + + booleans = ifcopenshell.api.geometry.add_boolean(self.file, first, [second]) + ifcopenshell.api.geometry.remove_boolean(self.file, booleans[0]) + assert set(rep.Items) == {first, second} + assert not self.file.by_type("IfcBooleanResult") + + + def test_removing_a_top_level_nested_boolean(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]) + + ifcopenshell.api.geometry.add_boolean(self.file, first, [second1, second2]) + ifcopenshell.api.geometry.remove_boolean(self.file, second2) + assert len(rep.Items) == 2 + assert second2 in rep.Items + boolean = self.file.by_type("IfcBooleanResult")[0] + assert boolean in rep.Items + assert boolean.FirstOperand == first + assert boolean.SecondOperand == second1 + + def test_removing_a_nested_boolean(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]) + + ifcopenshell.api.geometry.add_boolean(self.file, first, [second1, second2]) + ifcopenshell.api.geometry.remove_boolean(self.file, second1) + assert len(rep.Items) == 2 + assert second1 in rep.Items + boolean = self.file.by_type("IfcBooleanResult")[0] + assert boolean in rep.Items + assert boolean.FirstOperand == first + assert boolean.SecondOperand == second2 + + +class TestRemoveBooleanIFC2X3(test.bootstrap.IFC2X3, TestRemoveBoolean): + pass diff --git a/src/ifcopenshell-python/test/api/geometry/test_validate_type.py b/src/ifcopenshell-python/test/api/geometry/test_validate_type.py new file mode 100644 index 0000000000..6eed24efcb --- /dev/null +++ b/src/ifcopenshell-python/test/api/geometry/test_validate_type.py @@ -0,0 +1,105 @@ +# IfcOpenShell - IFC toolkit and geometry engine +# Copyright (C) 2023 Dion Moult +# +# This file is part of IfcOpenShell. +# +# IfcOpenShell is free software: you can redistribute it and/or modify +# it under the terms of the GNU Lesser General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# IfcOpenShell is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU Lesser General Public License for more details. +# +# You should have received a copy of the GNU Lesser General Public License +# along with IfcOpenShell. If not, see . + +import test.bootstrap +import ifcopenshell.api.root +import ifcopenshell.api.context +import ifcopenshell.api.geometry + + +class TestValidateType(test.bootstrap.IFC4): + def test_validating_a_non_csg_representation(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) + rep = builder.get_representation(body, [builder.rectangle()]) + assert ifcopenshell.api.geometry.validate_type(self.file, rep) is True + assert rep.RepresentationType == "Curve2D" + + def test_failing_a_non_csg_representation(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) + rep = builder.get_representation(body, [builder.rectangle(), builder.block()]) + assert ifcopenshell.api.geometry.validate_type(self.file, rep) is False + assert rep.RepresentationType is None + + def test_validating_a_correct_representation(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]) + assert ifcopenshell.api.geometry.validate_type(self.file, rep) is True + assert rep.RepresentationType == "CSG" + + 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 + ) + builder = ifcopenshell.util.shape_builder.ShapeBuilder(self.file) + first = builder.sphere() + second1 = builder.block() + second2 = builder.block() + second3 = builder.block() + rep = builder.get_representation(body, [first, second1, second2, second3]) + + booleans = ifcopenshell.api.geometry.add_boolean(self.file, first, [second1]) + assert len(booleans) == 1 + assert len(rep.Items) == 3 + assert ifcopenshell.api.geometry.validate_type(self.file, rep) is True + assert len(rep.Items) == 1 + assert rep.RepresentationType == "CSG" + assert rep.Items[0].Operator == "UNION" + + def test_failing_validation_on_unreconcilable_types(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.rectangle() + rep = builder.get_representation(body, [first, second1, second2]) + + booleans = ifcopenshell.api.geometry.add_boolean(self.file, first, [second1]) + assert len(booleans) == 1 + assert len(rep.Items) == 2 + assert ifcopenshell.api.geometry.validate_type(self.file, rep) is False + assert len(rep.Items) == 2 + assert rep.RepresentationType is None + + +class TestValidateTypeIFC2X3(test.bootstrap.IFC2X3, TestValidateType): + pass