From e40644c4f66588480826a1ca5addf5c204c13fcb Mon Sep 17 00:00:00 2001 From: Dion Moult Date: Sun, 19 Jan 2025 01:08:47 +1100 Subject: [PATCH] See #5972. You can now remove boolean operations in the stack without deleting operands. --- .../bonsai/bim/module/model/__init__.py | 1 + src/bonsai/bonsai/bim/module/model/opening.py | 21 +++++ src/bonsai/bonsai/bim/module/void/prop.py | 5 ++ src/bonsai/bonsai/bim/module/void/ui.py | 4 + .../api/geometry/remove_boolean.py | 62 ++++++------- .../test/api/geometry/test_remove_boolean.py | 87 +++++++++++++++++++ 6 files changed, 150 insertions(+), 30 deletions(-) create mode 100644 src/ifcopenshell-python/test/api/geometry/test_remove_boolean.py 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/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/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/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