From 89c039252bfb9f0f5c596d9c64c1c6d6d48446f6 Mon Sep 17 00:00:00 2001 From: Ryan Schultz Date: Fri, 10 Oct 2025 17:28:15 -0500 Subject: [PATCH 01/34] Closes ##7225 - Improve generate_freestyle_linework to robustly handle SVG output by falling back to the first generated linework file if the expected file is missing. --- src/bonsai/bonsai/bim/module/drawing/operator.py | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/src/bonsai/bonsai/bim/module/drawing/operator.py b/src/bonsai/bonsai/bim/module/drawing/operator.py index 18bf31363b..7c490c860f 100644 --- a/src/bonsai/bonsai/bim/module/drawing/operator.py +++ b/src/bonsai/bonsai/bim/module/drawing/operator.py @@ -17,6 +17,7 @@ # along with Bonsai. If not, see . import os +import glob import bpy import json import time @@ -794,11 +795,14 @@ class CreateDrawing(bpy.types.Operator): edge_bm.to_mesh(edge_mesh) edge_bm.free() - actual_path = svg_path[0:-4] + "0001.svg" - context.scene.render.filepath = svg_path[0:-4] - bpy.ops.render.render(write_still=False) + pattern = svg_path[0:-4] + "????.svg" + files = glob.glob(pattern) + if not files: + self.report({"ERROR"}, f"No Freestyle SVG found matching {pattern}") + return None - os.replace(actual_path, svg_path) + found_path = max(files, key=os.path.getctime) + os.replace(found_path, svg_path) bpy.data.objects.remove(edge_obj) bpy.data.meshes.remove(edge_mesh) From 23c60b38fda443f3b16caf24f12d1a01aaada794 Mon Sep 17 00:00:00 2001 From: Ryan Schultz Date: Fri, 10 Oct 2025 22:24:57 -0500 Subject: [PATCH 02/34] Reintroduce https://github.com/IfcOpenShell/IfcOpenShell/issues/7133 : Add hide_render synchronization to isolate_objects so hidden viewport objects are also hidden from render --- src/bonsai/bonsai/tool/blender.py | 18 ++++++++++++++++-- 1 file changed, 16 insertions(+), 2 deletions(-) diff --git a/src/bonsai/bonsai/tool/blender.py b/src/bonsai/bonsai/tool/blender.py index b9b3ae0d49..b59fba7837 100644 --- a/src/bonsai/bonsai/tool/blender.py +++ b/src/bonsai/bonsai/tool/blender.py @@ -1877,11 +1877,25 @@ class Blender(bonsai.core.tool.Blender): bpy.ops.object.hide_view_clear(select=False) bpy.ops.object.select_all(action="DESELECT") - for obj in objs: - obj.select_set(True) + + # Ensure objs_to_show exists (alias for clarity) + objs_to_show = objs + + # Show only these objects + for o in objs_to_show: + o.select_set(True) + o.hide_set(False) # Make visible in viewport + o.hide_render = False # Make visible in render + with bpy.context.temp_override(**override): bpy.ops.object.hide_view_set(unselected=True) + # Also hide all others from render + for o in bpy.context.view_layer.objects: + if o not in objs_to_show: + o.hide_render = True + + # Restore previous selection state bpy.ops.object.select_all(action="DESELECT") for name in previously_selected: obj = bpy.data.objects.get(name) From 9c1f636cd4327e947de5e8458c7f8f4bfd7d6992 Mon Sep 17 00:00:00 2001 From: Dion Moult Date: Sat, 11 Oct 2025 18:09:52 +1100 Subject: [PATCH 03/34] Revert "Reintroduce https://github.com/IfcOpenShell/IfcOpenShell/issues/7133 : Add hide_render synchronization to isolate_objects so hidden viewport objects are also hidden from render" This reverts commit 23c60b38fda443f3b16caf24f12d1a01aaada794. --- src/bonsai/bonsai/tool/blender.py | 18 ++---------------- 1 file changed, 2 insertions(+), 16 deletions(-) diff --git a/src/bonsai/bonsai/tool/blender.py b/src/bonsai/bonsai/tool/blender.py index b59fba7837..b9b3ae0d49 100644 --- a/src/bonsai/bonsai/tool/blender.py +++ b/src/bonsai/bonsai/tool/blender.py @@ -1877,25 +1877,11 @@ class Blender(bonsai.core.tool.Blender): bpy.ops.object.hide_view_clear(select=False) bpy.ops.object.select_all(action="DESELECT") - - # Ensure objs_to_show exists (alias for clarity) - objs_to_show = objs - - # Show only these objects - for o in objs_to_show: - o.select_set(True) - o.hide_set(False) # Make visible in viewport - o.hide_render = False # Make visible in render - + for obj in objs: + obj.select_set(True) with bpy.context.temp_override(**override): bpy.ops.object.hide_view_set(unselected=True) - # Also hide all others from render - for o in bpy.context.view_layer.objects: - if o not in objs_to_show: - o.hide_render = True - - # Restore previous selection state bpy.ops.object.select_all(action="DESELECT") for name in previously_selected: obj = bpy.data.objects.get(name) From 92ae79f64ed432d43e0b66ed25fa69d29db415c8 Mon Sep 17 00:00:00 2001 From: Dion Moult Date: Sat, 11 Oct 2025 18:48:46 +1100 Subject: [PATCH 04/34] See #7133. Sync viewport visibility with render visibility when generating underlays. --- .../bonsai/bim/module/drawing/operator.py | 2 ++ src/bonsai/bonsai/tool/blender.py | 18 ++++++++++++++++++ 2 files changed, 20 insertions(+) diff --git a/src/bonsai/bonsai/bim/module/drawing/operator.py b/src/bonsai/bonsai/bim/module/drawing/operator.py index 7c490c860f..60c4ac33e6 100644 --- a/src/bonsai/bonsai/bim/module/drawing/operator.py +++ b/src/bonsai/bonsai/bim/module/drawing/operator.py @@ -443,6 +443,8 @@ class CreateDrawing(bpy.types.Operator): context.scene.render.filepath = str(Path(svg_path).with_suffix(".png")) assert (drawing_style := self.cprops.get_active_drawing_style()) + tool.Blender.sync_render_visibility() + if drawing_style.render_type == "DEFAULT": bpy.ops.render.render(write_still=True) else: diff --git a/src/bonsai/bonsai/tool/blender.py b/src/bonsai/bonsai/tool/blender.py index b9b3ae0d49..b964d12ba6 100644 --- a/src/bonsai/bonsai/tool/blender.py +++ b/src/bonsai/bonsai/tool/blender.py @@ -1889,6 +1889,24 @@ class Blender(bonsai.core.tool.Blender): obj.select_set(True) bpy.context.view_layer.objects.active = previously_active + @classmethod + def sync_render_visibility(cls): + # Doing bpy.ops.object.hide_render_clear_all() or + # bpy.ops.object.isolate_type_render() is extremely slow. + # Hopefully this doesn't crash on Windows, it doesn't crash on Linux. + should_hides = [0 if obj.visible_get() else 1 for obj in bpy.data.objects] + should_hides = np.fromiter(should_hides, dtype=np.uint8, count=len(should_hides)) + bpy.data.objects.foreach_set("hide_render", should_hides) + return # Otherwise... + # for obj in bpy.data.objects: + # if not obj.data: + # continue + # # For speed, check equality prior to change to prevent needless updates + # if (is_visible := obj.visible_get()) and obj.hide_render is True: + # obj.hide_render = False + # elif not is_visible and obj.hide_render is False: + # obj.hide_render = True + @classmethod def hide_objects(cls, objs): previously_selected = {o.name for o in bpy.context.selected_objects} From 71d5239823930f300bc46528edab98a2064e0054 Mon Sep 17 00:00:00 2001 From: Dion Moult Date: Sat, 11 Oct 2025 19:02:04 +1100 Subject: [PATCH 05/34] Revert "Closes ##7225 - Improve generate_freestyle_linework to robustly handle SVG output by falling back to the first generated linework file if the expected file is missing." This reverts commit 89c039252bfb9f0f5c596d9c64c1c6d6d48446f6. --- src/bonsai/bonsai/bim/module/drawing/operator.py | 12 ++++-------- 1 file changed, 4 insertions(+), 8 deletions(-) diff --git a/src/bonsai/bonsai/bim/module/drawing/operator.py b/src/bonsai/bonsai/bim/module/drawing/operator.py index 60c4ac33e6..dba7d07ea2 100644 --- a/src/bonsai/bonsai/bim/module/drawing/operator.py +++ b/src/bonsai/bonsai/bim/module/drawing/operator.py @@ -17,7 +17,6 @@ # along with Bonsai. If not, see . import os -import glob import bpy import json import time @@ -797,14 +796,11 @@ class CreateDrawing(bpy.types.Operator): edge_bm.to_mesh(edge_mesh) edge_bm.free() - pattern = svg_path[0:-4] + "????.svg" - files = glob.glob(pattern) - if not files: - self.report({"ERROR"}, f"No Freestyle SVG found matching {pattern}") - return None + actual_path = svg_path[0:-4] + "0001.svg" + context.scene.render.filepath = svg_path[0:-4] + bpy.ops.render.render(write_still=False) - found_path = max(files, key=os.path.getctime) - os.replace(found_path, svg_path) + os.replace(actual_path, svg_path) bpy.data.objects.remove(edge_obj) bpy.data.meshes.remove(edge_mesh) From df0e8bba8c1c3d89a56e6377da72aa7c60f9a128 Mon Sep 17 00:00:00 2001 From: Dion Moult Date: Sat, 11 Oct 2025 19:15:50 +1100 Subject: [PATCH 06/34] Fix #7225. Handle freestyle linework regardless of generated SVG filename. --- .../bonsai/bim/module/drawing/operator.py | 3 ++- src/bonsai/bonsai/bim/module/light/data.py | 2 +- .../bonsai/bim/module/light/operator.py | 2 +- src/bonsai/bonsai/bim/module/light/prop.py | 2 +- src/bonsai/bonsai/tool/blender.py | 19 ++++++------------- 5 files changed, 11 insertions(+), 17 deletions(-) diff --git a/src/bonsai/bonsai/bim/module/drawing/operator.py b/src/bonsai/bonsai/bim/module/drawing/operator.py index dba7d07ea2..f33279cb72 100644 --- a/src/bonsai/bonsai/bim/module/drawing/operator.py +++ b/src/bonsai/bonsai/bim/module/drawing/operator.py @@ -796,8 +796,9 @@ class CreateDrawing(bpy.types.Operator): edge_bm.to_mesh(edge_mesh) edge_bm.free() - actual_path = svg_path[0:-4] + "0001.svg" + freestyle_svg_exporter = tool.Blender.get_addon("freestyle_svg_exporter") context.scene.render.filepath = svg_path[0:-4] + actual_path = freestyle_svg_exporter.create_path(bpy.context.scene) bpy.ops.render.render(write_still=False) os.replace(actual_path, svg_path) diff --git a/src/bonsai/bonsai/bim/module/light/data.py b/src/bonsai/bonsai/bim/module/light/data.py index 55bea350d2..abe9d9e87d 100644 --- a/src/bonsai/bonsai/bim/module/light/data.py +++ b/src/bonsai/bonsai/bim/module/light/data.py @@ -41,7 +41,7 @@ class SolarData: @classmethod def sun_position(cls): - return tool.Blender.get_sun_position_addon() + return tool.Blender.get_addon("sun_position") @classmethod def sites(cls): diff --git a/src/bonsai/bonsai/bim/module/light/operator.py b/src/bonsai/bonsai/bim/module/light/operator.py index f57ec3a4e0..4a8d993e0e 100644 --- a/src/bonsai/bonsai/bim/module/light/operator.py +++ b/src/bonsai/bonsai/bim/module/light/operator.py @@ -202,7 +202,7 @@ class RadianceRender(bpy.types.Operator): print(f"Camera position: {camera_position}") print(f"Camera direction: {camera_direction}") - # sun_position = tool.Blender.get_sun_position_addon() + # sun_position = tool.Blender.get_addon("sun_position") # azimuth, elevation = sun_position.sun_calc.get_sun_coordinates( # sun_pos_props.time, # sun_pos_props.latitude, diff --git a/src/bonsai/bonsai/bim/module/light/prop.py b/src/bonsai/bonsai/bim/module/light/prop.py index 54e5f6f73c..1cb36f82eb 100644 --- a/src/bonsai/bonsai/bim/module/light/prop.py +++ b/src/bonsai/bonsai/bim/module/light/prop.py @@ -40,7 +40,7 @@ from bpy.types import PropertyGroup from bonsai.bim.module.light.data import SolarData from bonsai.bim.module.light.decorator import SolarDecorator -sun_position = tool.Blender.get_sun_position_addon() +sun_position = tool.Blender.get_addon("sun_position") now = datetime.datetime.now() with open(os.path.join(os.path.dirname(__file__), "spectraldb.json"), "r") as f: diff --git a/src/bonsai/bonsai/tool/blender.py b/src/bonsai/bonsai/tool/blender.py index b964d12ba6..ee54a2a7fc 100644 --- a/src/bonsai/bonsai/tool/blender.py +++ b/src/bonsai/bonsai/tool/blender.py @@ -1506,28 +1506,21 @@ class Blender(bonsai.core.tool.Blender): return bpy.context.preferences.addons[blender_package_name].preferences @classmethod - def get_sun_position_addon(cls) -> Union[types.ModuleType, None]: - # Check if it's installed as legacy Blender addon. + def get_addon(cls, name: str) -> Union[types.ModuleType, None]: import importlib try: - sun_position = importlib.import_module("sun_position") + return importlib.import_module(name) # Legacy Blender addon except ImportError: - sun_position = None - - if sun_position: - return sun_position + pass for package_name in bpy.context.preferences.addons.keys(): - if package_name.endswith(".sun_position"): + if package_name.endswith(f".{name}"): try: - sun_position = importlib.import_module(package_name) - return sun_position + return importlib.import_module(package_name) except ModuleNotFoundError: pass - return sun_position - @classmethod def get_sun_props(cls) -> Union[SunPosProperties, None]: assert (scene := bpy.context.scene) @@ -1897,7 +1890,7 @@ class Blender(bonsai.core.tool.Blender): should_hides = [0 if obj.visible_get() else 1 for obj in bpy.data.objects] should_hides = np.fromiter(should_hides, dtype=np.uint8, count=len(should_hides)) bpy.data.objects.foreach_set("hide_render", should_hides) - return # Otherwise... + return # Otherwise... # for obj in bpy.data.objects: # if not obj.data: # continue From 0975eb455e0808e4518c13d58eff8324b5b58245 Mon Sep 17 00:00:00 2001 From: Dion Moult Date: Sat, 11 Oct 2025 20:37:28 +1100 Subject: [PATCH 07/34] All default drawing patterns now have a white background, not transparent. --- .../bonsai/bim/data/assets/patterns.svg | 37 ++++++++++++++++--- 1 file changed, 32 insertions(+), 5 deletions(-) diff --git a/src/bonsai/bonsai/bim/data/assets/patterns.svg b/src/bonsai/bonsai/bim/data/assets/patterns.svg index 11be0ee021..1f62673bda 100644 --- a/src/bonsai/bonsai/bim/data/assets/patterns.svg +++ b/src/bonsai/bonsai/bim/data/assets/patterns.svg @@ -1,85 +1,108 @@ + + + + + + + + + + + + + + + + + + + + + + + - + - + @@ -94,16 +117,19 @@ + + + @@ -112,22 +138,23 @@ - + + - + - + From b68e8dedd26d5b592cee26e7604b549257181228 Mon Sep 17 00:00:00 2001 From: Dion Moult Date: Sat, 11 Oct 2025 20:39:48 +1100 Subject: [PATCH 08/34] Fix #5675. Bug where separate islands of polygons still merged CSS classes incorrectly. --- .../bonsai/bim/module/drawing/operator.py | 34 +++++++++---------- 1 file changed, 16 insertions(+), 18 deletions(-) diff --git a/src/bonsai/bonsai/bim/module/drawing/operator.py b/src/bonsai/bonsai/bim/module/drawing/operator.py index f33279cb72..6e1c71cf69 100644 --- a/src/bonsai/bonsai/bim/module/drawing/operator.py +++ b/src/bonsai/bonsai/bim/module/drawing/operator.py @@ -1480,11 +1480,10 @@ class CreateDrawing(bpy.types.Operator): joined_paths.setdefault(hash_keys, []).append(el) for key, els in joined_paths.items(): - polygons = [] - classes = set() + queue = [] for el in els: - classes.update(el.attrib["class"].split()) + classes = set(el.attrib["class"].split()) classes.add(el.attrib["{http://www.ifcopenshell.org/ns}guid"]) is_closed_polygon = False for path in el.findall("{http://www.w3.org/2000/svg}path"): @@ -1499,31 +1498,30 @@ class CreateDrawing(bpy.types.Operator): coords.append(coords[0]) if len(coords) > 2 and coords[0] == coords[-1]: is_closed_polygon = True - polygons.append(shapely.Polygon(coords)) + queue.append((shapely.Polygon(coords), classes)) if is_closed_polygon: el.getparent().remove(el) - try: - merged_polygons = shapely.ops.unary_union(polygons) - except: - print("Warning. Portions of the merge failed. Please report a bug!", polygons) - merged_polygons = polygons + while queue: + polygon, polygon_classes = queue.pop() + for polygon2, polygon2_classes in queue[:]: + try: + merged_polygon = shapely.union(polygon, polygon2) + except: + print("Warning. Portions of the merge failed. Please report a bug!", polygon, polygon2) + continue + if type(merged_polygon) == shapely.Polygon: + polygon = merged_polygon + polygon_classes.update(polygon2_classes) + queue.remove((polygon2, polygon2_classes)) - if type(merged_polygons) == shapely.MultiPolygon: - merged_polygons = merged_polygons.geoms - elif type(merged_polygons) == shapely.Polygon: - merged_polygons = [merged_polygons] - else: - merged_polygons = [] - - for polygon in merged_polygons: g = etree.Element("g") path = etree.SubElement(g, "path") d = "M" + " L".join([",".join([str(o) for o in co]) for co in polygon.exterior.coords[0:-1]]) + " Z" for interior in polygon.interiors: d += " M" + " L".join([",".join([str(o) for o in co]) for co in interior.coords[0:-1]]) + " Z" path.attrib["d"] = d - g.set("class", " ".join(list(classes))) + g.set("class", " ".join(list(polygon_classes))) group.append(g) def drawing_to_model_co(self, x: float, y: float) -> Vector: From cb5c488cfd33e0addae640eac7b3fc25a3b24499 Mon Sep 17 00:00:00 2001 From: Dion Moult Date: Sat, 11 Oct 2025 21:09:41 +1100 Subject: [PATCH 09/34] Fix #7213. IfcTester now supports checking for USERDEFINED predefined types Can I just take this moment to complain about the overengineered complexity of how predefined types work in IFC. --- .../ifcopenshell/util/element.py | 34 +++++++++++ .../test/util/test_element.py | 59 +++++++++++++++++++ src/ifctester/ifctester/facet.py | 9 ++- src/ifctester/test/test_facet.py | 4 +- 4 files changed, 102 insertions(+), 4 deletions(-) diff --git a/src/ifcopenshell-python/ifcopenshell/util/element.py b/src/ifcopenshell-python/ifcopenshell/util/element.py index 48d65ae7ae..22665772da 100644 --- a/src/ifcopenshell-python/ifcopenshell/util/element.py +++ b/src/ifcopenshell-python/ifcopenshell/util/element.py @@ -565,6 +565,40 @@ def get_predefined_type(element: ifcopenshell.entity_instance) -> Union[str, Non return predefined_type +def is_userdefined_type(element: ifcopenshell.entity_instance) -> bool: + """Checks if the predefined type is userdefined + + :param element: The IFC Element entity + :return: True if userdefined + + Example: + + .. code:: python + + element = ifcopenshell.by_type("IfcWall")[0] + is_userdefined_type = ifcopenshell.util.element.is_userdefined_type(element) + """ + if element_type := get_type(element): + predefined_type = getattr(element_type, "PredefinedType", None) + if predefined_type == "USERDEFINED": + return True + elif not predefined_type: + predefined_type = getattr(element_type, "ElementType", ...) + if predefined_type == ...: + predefined_type = getattr(element_type, "ProcessType", None) + if predefined_type: + return True + if predefined_type and predefined_type != "NOTDEFINED": + return False + + predefined_type = getattr(element, "PredefinedType", None) + if predefined_type == "USERDEFINED": + return True + elif not predefined_type: + return bool(getattr(element, "ObjectType", None)) + return False + + def get_type(element: ifcopenshell.entity_instance) -> Union[ifcopenshell.entity_instance, None]: """Retrieves the construction type element of an element occurrence. diff --git a/src/ifcopenshell-python/test/util/test_element.py b/src/ifcopenshell-python/test/util/test_element.py index 1a1e28d58b..0113e68d2f 100644 --- a/src/ifcopenshell-python/test/util/test_element.py +++ b/src/ifcopenshell-python/test/util/test_element.py @@ -369,6 +369,65 @@ class TestGetPredefinedTypeIFC4(test.bootstrap.IFC4): assert subject.get_predefined_type(element_type) == "NOTDEFINED" +class TestIsUserdefinedTypeIFC4(test.bootstrap.IFC4): + def test_getting_a_predefined_element(self): + element = ifcopenshell.api.root.create_entity(self.file, ifc_class="IfcWall") + element.PredefinedType = "PARTITIONING" + assert not subject.is_userdefined_type(element) + + def test_getting_an_element_userdefined_type(self): + element = ifcopenshell.api.root.create_entity(self.file, ifc_class="IfcWall") + element.PredefinedType = "USERDEFINED" + element.ObjectType = "FOOBAR" + assert subject.is_userdefined_type(element) + + def test_getting_an_element_type_without_a_predefined_type_attribute(self): + element = ifcopenshell.api.root.create_entity(self.file, ifc_class="IfcAnnotation") + element.ObjectType = "FOOBAR" + assert subject.is_userdefined_type(element) + + def test_getting_an_inherited_predefined_type(self): + element = ifcopenshell.api.root.create_entity(self.file, ifc_class="IfcWall") + element_type = ifcopenshell.api.root.create_entity(self.file, ifc_class="IfcWallType") + ifcopenshell.api.type.assign_type(self.file, related_objects=[element], relating_type=element_type) + element_type.PredefinedType = "PARTITIONING" + assert not subject.is_userdefined_type(element) + + def test_getting_an_inherited_userdefined_type_for_an_element_type(self): + element = ifcopenshell.api.root.create_entity(self.file, ifc_class="IfcWall") + element_type = ifcopenshell.api.root.create_entity(self.file, ifc_class="IfcWallType") + ifcopenshell.api.type.assign_type(self.file, related_objects=[element], relating_type=element_type) + element_type.PredefinedType = "USERDEFINED" + element_type.ElementType = "FOOBAR" + assert subject.is_userdefined_type(element) + + def test_getting_an_overriden_predefined_type(self): + element = ifcopenshell.api.root.create_entity(self.file, ifc_class="IfcWall") + element_type = ifcopenshell.api.root.create_entity(self.file, ifc_class="IfcWallType") + ifcopenshell.api.type.assign_type(self.file, related_objects=[element], relating_type=element_type) + element_type.PredefinedType = "NOTDEFINED" + element.PredefinedType = "PARTITIONING" + assert not subject.is_userdefined_type(element) + + def test_getting_an_inherited_userdefined_type_for_a_process_type(self): + element = ifcopenshell.api.sequence.add_task(self.file) + element_type = ifcopenshell.api.root.create_entity(self.file, ifc_class="IfcTaskType") + ifcopenshell.api.type.assign_type(self.file, related_objects=[element], relating_type=element_type) + element_type.PredefinedType = "USERDEFINED" + element_type.ProcessType = "FOOBAR" + assert subject.is_userdefined_type(element) + + def test_getting_an_element_type_predefined_type(self): + element_type = ifcopenshell.api.root.create_entity(self.file, ifc_class="IfcWallType") + element_type.PredefinedType = "PARTITIONING" + assert not subject.is_userdefined_type(element_type) + + def test_getting_an_element_type_null_predefined_type(self): + element_type = ifcopenshell.api.root.create_entity(self.file, ifc_class="IfcWallType") + element_type.PredefinedType = "NOTDEFINED" + assert not subject.is_userdefined_type(element_type) + + class TestGetTypeIFC4(test.bootstrap.IFC4): def test_getting_the_type_of_a_product(self): element = ifcopenshell.api.root.create_entity(self.file, ifc_class="IfcWall") diff --git a/src/ifctester/ifctester/facet.py b/src/ifctester/ifctester/facet.py index 97c24c59a1..fa8ff43fb3 100644 --- a/src/ifctester/ifctester/facet.py +++ b/src/ifctester/ifctester/facet.py @@ -227,8 +227,13 @@ class Entity(Facet): reason = {"type": "NAME", "actual": inst.is_a().upper()} if is_pass and self.predefinedType: - predefined_type = ifcopenshell.util.element.get_predefined_type(inst) - is_pass = predefined_type == self.predefinedType + if self.predefinedType == "USERDEFINED": + is_pass = ifcopenshell.util.element.is_userdefined_type(inst) + if not is_pass: + predefined_type = ifcopenshell.util.element.get_predefined_type(inst) + else: + predefined_type = ifcopenshell.util.element.get_predefined_type(inst) + is_pass = predefined_type == self.predefinedType if not is_pass: reason = {"type": "PREDEFINEDTYPE", "actual": predefined_type} diff --git a/src/ifctester/test/test_facet.py b/src/ifctester/test/test_facet.py index 10d94b2f03..045b796330 100644 --- a/src/ifctester/test/test_facet.py +++ b/src/ifctester/test/test_facet.py @@ -172,10 +172,10 @@ class TestEntity: facet = Entity(name="IFCWALL", predefinedType="USERDEFINED") ifc = ifcopenshell.file() run( - "A predefined type must always specify a meaningful type, not USERDEFINED itself", + "A predefined type may specify USERDEFINED itself", facet=facet, inst=ifc.createIfcWall(PredefinedType="USERDEFINED", ObjectType="WALDO"), - expected=False, + expected=True, ) ifc = ifcopenshell.file() From feecb89c8ec0837cb0f07623d39f5923f829f629 Mon Sep 17 00:00:00 2001 From: Dion Moult Date: Sat, 11 Oct 2025 21:30:49 +1100 Subject: [PATCH 10/34] Fix #7212. IfcTester now supports IFC2X3 type element mapping. --- src/ifctester/ifctester/facet.py | 16 +++++++++++++++- src/ifctester/test/test_facet.py | 26 ++++++++++++++++++++++++++ 2 files changed, 41 insertions(+), 1 deletion(-) diff --git a/src/ifctester/ifctester/facet.py b/src/ifctester/ifctester/facet.py index fa8ff43fb3..6b5536299d 100644 --- a/src/ifctester/ifctester/facet.py +++ b/src/ifctester/ifctester/facet.py @@ -206,6 +206,12 @@ class Entity(Facet): except: # If the user has specified a class that doesn't exist in the version results = [] + if not self.name.endswith("TYPE"): + try: + for element_type in ifc_file.by_type(f"{self.name}Type"): + results.extend(ifcopenshell.util.element.get_types(element_type)) + except: + pass else: results = [] ifc_classes = [t for t in ifc_file.wrapped_data.types() if t.upper() == self.name] @@ -223,7 +229,15 @@ class Entity(Facet): is_pass = inst.is_a().upper() == self.name reason = None - if not is_pass: + if ( + not is_pass + and inst.file.schema == "IFC2X3" + and not self.name.endswith("TYPE") + and (element_type := ifcopenshell.util.element.get_type(inst)) + ): + is_pass = element_type.is_a().upper() == f"{self.name}TYPE" + reason = {"type": "NAME", "actual": element_type.is_a().upper()[:-4]} + elif not is_pass: reason = {"type": "NAME", "actual": inst.is_a().upper()} if is_pass and self.predefinedType: diff --git a/src/ifctester/test/test_facet.py b/src/ifctester/test/test_facet.py index 045b796330..167201979b 100644 --- a/src/ifctester/test/test_facet.py +++ b/src/ifctester/test/test_facet.py @@ -230,6 +230,32 @@ class TestEntity: wall3 = ifcopenshell.api.root.create_entity(ifc, ifc_class="IfcWall", predefined_type="BAZFOO") run("Restrictions an be specified for the predefined type 3/3", facet=facet, inst=wall3, expected=False) + def test_ifc2x3_occurrence_type_mapping(self): + set_facet("entity") + + ifc = ifcopenshell.file(schema="IFC2X3") + application = ifcopenshell.api.owner.add_application(ifc) + person = ifcopenshell.api.owner.add_person( + ifc, identification="LPARTEE", family_name="Partee", given_name="Leeable" + ) + organisation = ifcopenshell.api.owner.add_organisation( + ifc, identification="AWB", name="Architects Without Ballpens" + ) + user = ifcopenshell.api.owner.add_person_and_organisation(ifc, person=person, organisation=organisation) + ifcopenshell.api.owner.settings.get_user = lambda x: user + ifcopenshell.api.owner.settings.get_application = lambda x: application + + element = ifcopenshell.api.root.create_entity(ifc, "IfcFlowTerminal") + element_type = ifcopenshell.api.root.create_entity(ifc, "IfcAirTerminalType") + ifcopenshell.api.type.assign_type(ifc, related_objects=[element], relating_type=element_type) + facet = Entity(name="IFCAIRTERMINAL") + assert facet.filter(ifc) == [element] + run("In IFC2X3 the type class is checked instead 1/2", facet=facet, inst=element, expected=True) + + facet = Entity(name="IFCELECTRICAPPLIANCE") + assert facet.filter(ifc) == [] + run("In IFC2X3 the type class is checked instead 2/2", facet=facet, inst=element, expected=False) + def test_to_string_required_applicability(self): spec = ifctester.ids.Specification(name="Foo", minOccurs=1, maxOccurs="unbounded") facet = Entity(name="IFCWALL") From cc6ef0268f4c1e05d56f63b0fb2e30a3ad0da970 Mon Sep 17 00:00:00 2001 From: Ryan Schultz Date: Sat, 11 Oct 2025 09:52:55 -0500 Subject: [PATCH 11/34] Introduces a toggle to turn on/off material layers in printed svg. --- src/bonsai/bonsai/bim/module/drawing/operator.py | 11 +++++++---- src/bonsai/bonsai/bim/module/drawing/prop.py | 5 +++++ src/bonsai/bonsai/bim/module/drawing/ui.py | 3 ++- 3 files changed, 14 insertions(+), 5 deletions(-) diff --git a/src/bonsai/bonsai/bim/module/drawing/operator.py b/src/bonsai/bonsai/bim/module/drawing/operator.py index 6e1c71cf69..fd171484fa 100644 --- a/src/bonsai/bonsai/bim/module/drawing/operator.py +++ b/src/bonsai/bonsai/bim/module/drawing/operator.py @@ -627,7 +627,7 @@ class CreateDrawing(bpy.types.Operator): path.attrib["d"] = d group.append(g) - def generate_wall_layers(self, context: bpy.types.Context, root) -> None: + def generate_material_layers(self, context: bpy.types.Context, root) -> None: for el in root.findall(".//{http://www.w3.org/2000/svg}g[@{http://www.ifcopenshell.org/ns}guid]"): if "projection" in el.get("class", "").split(): continue @@ -842,7 +842,8 @@ class CreateDrawing(bpy.types.Operator): if tool.Drawing.is_camera_orthographic(): self.generate_bisect_linework(context, root) - self.generate_wall_layers(context, root) + if self.cprops.generate_material_layers: + self.generate_material_layers(context, root) self.merge_linework_and_add_metadata(root) self.move_elements_to_top(root) @@ -940,12 +941,14 @@ class CreateDrawing(bpy.types.Operator): if self.cprops.cut_mode == "BISECT": self.remove_cut_linework(root) self.generate_bisect_linework(context, root) - self.generate_wall_layers(context, root) + if self.cprops.generate_material_layers: + self.generate_material_layers(context, root) self.merge_linework_and_add_metadata(root) self.move_elements_to_top(root) elif self.cprops.cut_mode == "OPENCASCADE": self.move_projection_to_bottom(root) - self.generate_wall_layers(context, root) + if self.cprops.generate_material_layers: + self.generate_material_layers(context, root) self.merge_linework_and_add_metadata(root) self.move_elements_to_top(root) diff --git a/src/bonsai/bonsai/bim/module/drawing/prop.py b/src/bonsai/bonsai/bim/module/drawing/prop.py index fb5c05679a..0388f0387a 100644 --- a/src/bonsai/bonsai/bim/module/drawing/prop.py +++ b/src/bonsai/bonsai/bim/module/drawing/prop.py @@ -498,6 +498,11 @@ class BIMCameraProperties(PropertyGroup): name="Linework Mode", update=get_update_layer_callback("linework_mode", "LineworkMode"), ) + generate_material_layers: bpy.props.BoolProperty( + name="Generate Material Layers", + description="Generate material layer linework in drawings", + default=True + ) fill_mode: EnumProperty( items=[ ("NONE", "None", "Disable filling areas seen in projection"), diff --git a/src/bonsai/bonsai/bim/module/drawing/ui.py b/src/bonsai/bonsai/bim/module/drawing/ui.py index 905bc8e287..45e7acec97 100644 --- a/src/bonsai/bonsai/bim/module/drawing/ui.py +++ b/src/bonsai/bonsai/bim/module/drawing/ui.py @@ -99,12 +99,13 @@ class BIM_PT_camera(Panel): row = self.layout.row() row.prop(props, "linework_mode") + row = self.layout.row() + row.prop(props, "generate_material_layers") if props.linework_mode == "OPENCASCADE": row = self.layout.row() row.prop(props, "fill_mode") row = self.layout.row() row.prop(props, "cut_mode") - row = self.layout.row() row.prop(props, "width") row = self.layout.row() From dd22264877bdb5cbd268d33a46211afb1ebe36ab Mon Sep 17 00:00:00 2001 From: Bruno Postle Date: Sat, 11 Oct 2025 20:25:13 +0100 Subject: [PATCH 12/34] Fix sheet regeneration regression from d4f3280 Remove validate_sheet_files() check in regenerate_sheet() that prevented regenerating missing LAYOUT files. The validation was blocking the exact scenario that regeneration was designed to handle. This allows users to open a bare IFC file with configured drawings and sheets and have the SVG layouts automatically recreated with drawings placed at default positions. --- src/bonsai/bonsai/core/drawing.py | 4 ---- 1 file changed, 4 deletions(-) diff --git a/src/bonsai/bonsai/core/drawing.py b/src/bonsai/bonsai/core/drawing.py index 0345f4019a..d69147b13e 100644 --- a/src/bonsai/bonsai/core/drawing.py +++ b/src/bonsai/bonsai/core/drawing.py @@ -108,10 +108,6 @@ def add_sheet(ifc: type[tool.Ifc], drawing: type[tool.Drawing], titleblock: ifco def regenerate_sheet( drawing: type[tool.Drawing], sheet: ifcopenshell.entity_instance ) -> Union[list[tool.Drawing.SheetWarningType], None]: - warnings = drawing.validate_sheet_files(sheet) - if warnings: - return warnings - titleblock_uri = drawing.get_document_uri(sheet, "TITLEBLOCK") assert titleblock_uri From b68aca04ae5f093311d1ca2063d59e1108c304ec Mon Sep 17 00:00:00 2001 From: Ryan Schultz Date: Sat, 11 Oct 2025 17:41:41 -0500 Subject: [PATCH 13/34] added `EPset_Status.UserDefinedStatus` to default join_criteria --- src/bonsai/bonsai/bim/module/drawing/operator.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/bonsai/bonsai/bim/module/drawing/operator.py b/src/bonsai/bonsai/bim/module/drawing/operator.py index fd171484fa..e32b794498 100644 --- a/src/bonsai/bonsai/bim/module/drawing/operator.py +++ b/src/bonsai/bonsai/bim/module/drawing/operator.py @@ -1354,7 +1354,7 @@ class CreateDrawing(bpy.types.Operator): join_criteria = join_criteria.split(",") else: # Drawing convention states that same objects classes with the same material are merged when cut. - join_criteria = ["class", "material.Name", "/Pset_.*Common/.Status", "EPset_Status.Status", "Material.Name"] + join_criteria = ["class", "material.Name", "/Pset_.*Common/.Status", "EPset_Status.Status", "EPset_Status.UserDefinedStatus"] group = root.find("{http://www.w3.org/2000/svg}g") joined_paths = {} From 44cc12763ba429fe1cc8ece4a2db0318bc49566c Mon Sep 17 00:00:00 2001 From: Ryan Schultz Date: Sat, 11 Oct 2025 17:47:37 -0500 Subject: [PATCH 14/34] Allows the ability to activate an attached drawing_style regardless of whether a drawing has a underlay, or not. --- src/bonsai/bonsai/bim/module/drawing/operator.py | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/src/bonsai/bonsai/bim/module/drawing/operator.py b/src/bonsai/bonsai/bim/module/drawing/operator.py index e32b794498..e8384a1184 100644 --- a/src/bonsai/bonsai/bim/module/drawing/operator.py +++ b/src/bonsai/bonsai/bim/module/drawing/operator.py @@ -2322,9 +2322,8 @@ class ActivateDrawingBase(tool.Ifc.Operator): dprops.active_drawing_id = self.drawing dprops.drawing_styles.clear() - if ifcopenshell.util.element.get_pset(drawing, "EPset_Drawing", "HasUnderlay"): - bpy.ops.bim.reload_drawing_styles() - bpy.ops.bim.activate_drawing_style() + bpy.ops.bim.reload_drawing_styles() + bpy.ops.bim.activate_drawing_style() if tool.Drawing.is_camera_orthographic(): core.sync_references(tool.Ifc, tool.Collector, tool.Drawing, drawing=tool.Ifc.get().by_id(self.drawing)) From 471bd34fc6148c8e59f4643d424f88230c463a84 Mon Sep 17 00:00:00 2001 From: Dion Moult Date: Sun, 12 Oct 2025 21:00:55 +1100 Subject: [PATCH 15/34] Fix #7206. For convenience, deleted annotation references are marked as excluded and not regenerated. --- .../bonsai/bim/data/pset/EPset_Drawing.ifc | 6 +-- src/bonsai/bonsai/tool/drawing.py | 39 +++++++++++++++---- src/bonsai/bonsai/tool/geometry.py | 7 +++- 3 files changed, 39 insertions(+), 13 deletions(-) diff --git a/src/bonsai/bonsai/bim/data/pset/EPset_Drawing.ifc b/src/bonsai/bonsai/bim/data/pset/EPset_Drawing.ifc index a516b9765d..70d6049ad7 100644 --- a/src/bonsai/bonsai/bim/data/pset/EPset_Drawing.ifc +++ b/src/bonsai/bonsai/bim/data/pset/EPset_Drawing.ifc @@ -13,10 +13,10 @@ DATA; #6=IFCSIMPLEPROPERTYTEMPLATE('0AK5C2UpL4$eaac2LszAx$',$,'HasUnderlay','',.P_SINGLEVALUE.,'IfcBoolean',$,$,$,$,$,.READWRITE.); #7=IFCSIMPLEPROPERTYTEMPLATE('2j2ZEZR8X5tONm7kli5hM6',$,'HasLinework','',.P_SINGLEVALUE.,'IfcBoolean',$,$,$,$,$,.READWRITE.); #8=IFCSIMPLEPROPERTYTEMPLATE('1ttChRysH9UuEX2FeMj5Hu',$,'HasAnnotation','',.P_SINGLEVALUE.,'IfcBoolean',$,$,$,$,$,.READWRITE.); -#9=IFCSIMPLEPROPERTYTEMPLATE('2NPPxuABv1huDTVh32TFgw',$,'GlobalReferencing','',.P_SINGLEVALUE.,'IfcBoolean',$,$,$,$,$,.READWRITE.); +#9=IFCSIMPLEPROPERTYTEMPLATE('2NPPxuABv1huDTVh32TFgw',$,'GlobalReferencing','Whether or not this drawing can be referenced in other drawings.',.P_SINGLEVALUE.,'IfcBoolean',$,$,$,$,$,.READWRITE.); #10=IFCSIMPLEPROPERTYTEMPLATE('10hT_1zrzEbRRKMXYAWvtD',$,'Metadata','Comma separated list of selector expressions to evaluate for each drawing elementand add results to their ''class'' attribute.\X2\000A\X0\E.g. ''Name, id'' would add to ''class'' value similar to ''Name-Wall id-1220''.\X2\000A\X0\Then it can be used to applied css styles based on the resulting class.\X2\000A\X0\If attribute is not present on the element, then it won''t be added to it''s ''class''.',.P_SINGLEVALUE.,'IfcText',$,$,$,$,$,.READWRITE.); -#11=IFCSIMPLEPROPERTYTEMPLATE('3Z0BXPSG5CWgtI33ioV7aj',$,'Include','Selector expression to include ifc elements in the drawing',.P_SINGLEVALUE.,'IfcText',$,$,$,$,$,.READWRITE.); -#12=IFCSIMPLEPROPERTYTEMPLATE('1RVts_g3PAw98PJA2yL3bO',$,'Exclude','Selector expression to exclude ifc elements in the drawing',.P_SINGLEVALUE.,'IfcText',$,$,$,$,$,.READWRITE.); +#11=IFCSIMPLEPROPERTYTEMPLATE('3Z0BXPSG5CWgtI33ioV7aj',$,'Include','Selector expression to include elements in the drawing',.P_SINGLEVALUE.,'IfcText',$,$,$,$,$,.READWRITE.); +#12=IFCSIMPLEPROPERTYTEMPLATE('1RVts_g3PAw98PJA2yL3bO',$,'Exclude','Selector expression to exclude elements in the drawing',.P_SINGLEVALUE.,'IfcText',$,$,$,$,$,.READWRITE.); #13=IFCSIMPLEPROPERTYTEMPLATE('0c1$8NpYDEaBiJrj16jHIo',$,'Stylesheet','',.P_SINGLEVALUE.,'IfcText',$,$,$,$,$,.READWRITE.); #14=IFCSIMPLEPROPERTYTEMPLATE('3mRF52q81FQB$h4oTh7M45',$,'Markers','',.P_SINGLEVALUE.,'IfcText',$,$,$,$,$,.READWRITE.); #15=IFCSIMPLEPROPERTYTEMPLATE('1rhr_0N3LDtuORcEJP0KXM',$,'Symbols','',.P_SINGLEVALUE.,'IfcText',$,$,$,$,$,.READWRITE.); diff --git a/src/bonsai/bonsai/tool/drawing.py b/src/bonsai/bonsai/tool/drawing.py index 336b3fd0dd..ad650eb4cc 100644 --- a/src/bonsai/bonsai/tool/drawing.py +++ b/src/bonsai/bonsai/tool/drawing.py @@ -208,6 +208,25 @@ class Drawing(bonsai.core.tool.Drawing): return obj + @classmethod + def get_annotation_drawing(cls, element: ifcopenshell.entity_instance) -> ifcopenshell.entity_instance | None: + for rel in element.HasAssignments: + if rel.is_a("IfcRelAssignsToGroup") and rel.RelatingGroup.ObjectType == "DRAWING": + for e in rel.RelatedObjects: + if e.ObjectType == "DRAWING": + return e + + @classmethod + def exclude_annotation_from_drawing(cls, element: ifcopenshell.entity_instance, drawing: ifcopenshell.entity_instance) -> None: + pset = tool.Pset.get_element_pset(drawing, "EPset_Drawing") + if not pset: + pset = ifcopenshell.api.pset.add_pset(ifc_file, product=drawing, name="EPset_Drawing") + exclude = ifcopenshell.util.element.get_property_definition(pset, prop="Exclude") or "" + if exclude: + exclude += "+" + exclude += element.GlobalId + ifcopenshell.api.pset.edit_pset(tool.Ifc.get(), pset=pset, properties={"Exclude": exclude}) + @classmethod def ensure_annotation_in_drawing_plane( cls, obj: bpy.types.Object, camera: Optional[bpy.types.Object] = None @@ -218,11 +237,7 @@ class Drawing(bonsai.core.tool.Drawing): entity = tool.Ifc.get_entity(obj) if not entity: return - for rel in entity.HasAssignments: - if rel.is_a("IfcRelAssignsToGroup"): - for e in rel.RelatedObjects: - if e.ObjectType == "DRAWING": - return tool.Ifc.get_object(e) + return tool.Ifc.get_object(cls.get_annotation_drawing(entity)) if not camera: camera = get_camera_from_annotation_object(obj) or bpy.context.scene.camera @@ -1401,7 +1416,10 @@ class Drawing(bonsai.core.tool.Drawing): cls, drawing: ifcopenshell.entity_instance ) -> list[ifcopenshell.entity_instance]: elements = [] - existing_references = cls.get_group_elements(cls.get_drawing_group(drawing)) + existing_references = set(cls.get_group_elements(cls.get_drawing_group(drawing))) + if exclude := ifcopenshell.util.element.get_pset(drawing, "EPset_Drawing", "Exclude"): + existing_references.update(ifcopenshell.util.selector.filter_elements(tool.Ifc.get(), exclude)) + for element in tool.Ifc.get().by_type("IfcAnnotation"): if element in existing_references or element == drawing: continue @@ -1411,8 +1429,13 @@ class Drawing(bonsai.core.tool.Drawing): "GlobalReferencing", False ): elements.append(element) - for element in tool.Ifc.get().by_type("IfcGridAxis"): - elements.append(element) + for element in tool.Ifc.get().by_type("IfcGrid"): + if element in existing_references: + continue + for axis in element.UAxes + element.VAxes + (element.WAxes or tuple()): + if axis in existing_references: + continue + elements.append(element) target_view = tool.Drawing.get_drawing_target_view(drawing) if target_view in ("SECTION_VIEW", "ELEVATION_VIEW"): for element in tool.Ifc.get().by_type("IfcBuildingStorey"): diff --git a/src/bonsai/bonsai/tool/geometry.py b/src/bonsai/bonsai/tool/geometry.py index 8db4264783..a5625de761 100644 --- a/src/bonsai/bonsai/tool/geometry.py +++ b/src/bonsai/bonsai/tool/geometry.py @@ -233,8 +233,11 @@ class Geometry(bonsai.core.tool.Geometry): element = tool.Ifc.get_entity(obj) if not element: return - elif element.is_a("IfcAnnotation") and element.ObjectType == "DRAWING": - return bonsai.core.drawing.remove_drawing(tool.Ifc, tool.Drawing, drawing=element) + elif element.is_a("IfcAnnotation"): + if element.ObjectType == "DRAWING": + return bonsai.core.drawing.remove_drawing(tool.Ifc, tool.Drawing, drawing=element) + elif (referenced_element := tool.Drawing.get_annotation_element(element)) and (drawing := tool.Drawing.get_annotation_drawing(element)): + tool.Drawing.exclude_annotation_from_drawing(referenced_element, drawing) elif element.is_a("IfcRelSpaceBoundary"): ifcopenshell.api.boundary.remove_boundary(ifc_file, boundary=element) tool.Boundary.undecorate_boundary(obj) From d3a5071d9d3fa9d6da63c8eea04de4db3b1aa6a6 Mon Sep 17 00:00:00 2001 From: Dion Moult Date: Sun, 12 Oct 2025 21:07:13 +1100 Subject: [PATCH 16/34] Fix #7211. Explain to user why they can't remove the last grid axis. --- src/bonsai/bonsai/bim/module/geometry/operator.py | 11 ++++++++++- src/bonsai/bonsai/tool/geometry.py | 3 --- 2 files changed, 10 insertions(+), 4 deletions(-) diff --git a/src/bonsai/bonsai/bim/module/geometry/operator.py b/src/bonsai/bonsai/bim/module/geometry/operator.py index 401428cddb..a5218b3dfa 100644 --- a/src/bonsai/bonsai/bim/module/geometry/operator.py +++ b/src/bonsai/bonsai/bim/module/geometry/operator.py @@ -882,7 +882,16 @@ class OverrideDelete(bpy.types.Operator): continue if ifcopenshell.util.element.get_pset(element, "BBIM_Array"): self.report({"INFO"}, "Elements that are part of an array cannot be deleted.") - return {"FINISHED"} + continue + if element.is_a("IfcGridAxis"): + # Deleting the last W axis is OK + if ((grid := element.PartOfU) and len(grid[0].UAxes) == 1) or ( + (grid := element.PartOfV) and len(grid[0].VAxes) == 1 + ): + self.report( + {"INFO"}, "The last grid axis of a grid cannot be deleted. Delete the grid instead." + ) + continue tool.Geometry.delete_ifc_object(obj) elif tool.Geometry.is_representation_item(obj): tool.Geometry.delete_ifc_item(obj) diff --git a/src/bonsai/bonsai/tool/geometry.py b/src/bonsai/bonsai/tool/geometry.py index a5625de761..c31d1e0fbb 100644 --- a/src/bonsai/bonsai/tool/geometry.py +++ b/src/bonsai/bonsai/tool/geometry.py @@ -243,13 +243,10 @@ class Geometry(bonsai.core.tool.Geometry): tool.Boundary.undecorate_boundary(obj) return bpy.data.objects.remove(obj) elif element.is_a("IfcGridAxis"): - is_last_axis = False # Deleting the last W axis is OK if ((grid := element.PartOfU) and len(grid[0].UAxes) == 1) or ( (grid := element.PartOfV) and len(grid[0].VAxes) == 1 ): - is_last_axis = True - if is_last_axis: return ifcopenshell.api.grid.remove_grid_axis(ifc_file, axis=element) return bpy.data.objects.remove(obj) From 0073a4c0a2ec040e0a71bfe951adf8e2599f9bdb Mon Sep 17 00:00:00 2001 From: Dion Moult Date: Sun, 12 Oct 2025 21:21:27 +1100 Subject: [PATCH 17/34] Fix #7205. Regression when switching from drawing mode to model mode didn't hide drawings. --- src/bonsai/bonsai/bim/module/drawing/operator.py | 1 + src/bonsai/bonsai/tool/drawing.py | 7 +++++++ 2 files changed, 8 insertions(+) diff --git a/src/bonsai/bonsai/bim/module/drawing/operator.py b/src/bonsai/bonsai/bim/module/drawing/operator.py index e8384a1184..b1018ee046 100644 --- a/src/bonsai/bonsai/bim/module/drawing/operator.py +++ b/src/bonsai/bonsai/bim/module/drawing/operator.py @@ -2226,6 +2226,7 @@ class ActivateModel(bpy.types.Operator): ) tool.Blender.reset_object_visibility() + tool.Drawing.hide_all_drawing_collections() tool.Blender.update_viewport() bonsai.bim.handler.refresh_ui_data() diff --git a/src/bonsai/bonsai/tool/drawing.py b/src/bonsai/bonsai/tool/drawing.py index ad650eb4cc..c770a9fc8f 100644 --- a/src/bonsai/bonsai/tool/drawing.py +++ b/src/bonsai/bonsai/tool/drawing.py @@ -2591,3 +2591,10 @@ class Drawing(bonsai.core.tool.Drawing): ifcopenshell.api.document.remove_reference(tool.Ifc.get(), reference=reference) tool.Drawing.import_sheets() + + @classmethod + def hide_all_drawing_collections(cls) -> None: + for element in tool.Ifc.get().by_type("IfcAnnotation"): + if element.ObjectType == "DRAWING" and (obj := tool.Ifc.get_object(element)): + print('found element', element) + tool.Blender.get_layer_collection(obj.users_collection[0]).hide_viewport = True From 96d05300bbf89aea685915f8252e4e8e2f9faf9b Mon Sep 17 00:00:00 2001 From: Ryan Schultz Date: Sun, 12 Oct 2025 09:57:27 -0500 Subject: [PATCH 18/34] Closes #7229: Unhide object before mesh tessellation export to prevent "Cannot edit hidden object" error when collector.assign() hides the object. --- src/bonsai/bonsai/bim/module/root/operator.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/src/bonsai/bonsai/bim/module/root/operator.py b/src/bonsai/bonsai/bim/module/root/operator.py index 55ffa56a79..9044e0d271 100644 --- a/src/bonsai/bonsai/bim/module/root/operator.py +++ b/src/bonsai/bonsai/bim/module/root/operator.py @@ -326,6 +326,10 @@ class AssignClass(bpy.types.Operator, tool.Ifc.Operator): predefined_type=predefined_type, should_add_representation=False, ) + + #Unhide object before mesh tessellation export to prevent "Cannot edit hidden object" error when collector.assign() hides the object. + obj.hide_viewport = False + representation = tool.Geometry.export_mesh_to_tessellation(obj, ifc_context) ifcopenshell.api.geometry.assign_representation(tool.Ifc.get(), element, representation) bonsai.core.geometry.switch_representation( From 1dd477d382f4b3f6aad532b851c3beb9de20463c Mon Sep 17 00:00:00 2001 From: Ryan Schultz Date: Sun, 12 Oct 2025 12:17:57 -0500 Subject: [PATCH 19/34] Delete aggregate if all its parts are deleted. Prevents having orphaned aggregates, if you do a bulk delete with empties turned off. --- .../bonsai/bim/module/aggregate/operator.py | 29 ++++++++++- .../bonsai/bim/module/geometry/operator.py | 50 ++++++++++++++++++- 2 files changed, 76 insertions(+), 3 deletions(-) diff --git a/src/bonsai/bonsai/bim/module/aggregate/operator.py b/src/bonsai/bonsai/bim/module/aggregate/operator.py index 6e902e76f5..e2727b101a 100644 --- a/src/bonsai/bonsai/bim/module/aggregate/operator.py +++ b/src/bonsai/bonsai/bim/module/aggregate/operator.py @@ -94,6 +94,9 @@ class BIM_OT_aggregate_unassign_object(bpy.types.Operator, tool.Ifc.Operator): bl_options = {"REGISTER", "UNDO"} def _execute(self, context): + aggregates_to_check = set() + + # First pass: unassign all parts and track their aggregates for obj in tool.Blender.get_selected_objects(): element = tool.Ifc.get_entity(obj) if not element: @@ -101,6 +104,10 @@ class BIM_OT_aggregate_unassign_object(bpy.types.Operator, tool.Ifc.Operator): aggregate = ifcopenshell.util.element.get_aggregate(element) if not aggregate: continue + + # Track this aggregate for later checking + aggregates_to_check.add(aggregate) + core.unassign_object( tool.Ifc, tool.Aggregate, @@ -115,7 +122,27 @@ class BIM_OT_aggregate_unassign_object(bpy.types.Operator, tool.Ifc.Operator): if pset: pset = tool.Ifc.get().by_id(pset["id"]) ifcopenshell.api.pset.remove_pset(tool.Ifc.get(), product=element, pset=pset) - + + # Second pass: delete aggregates that now have no parts + deleted_aggregates = [] + for aggregate in aggregates_to_check: + related_objects = ifcopenshell.util.element.get_parts(aggregate) + if len(related_objects) == 0: + aggregate_name = aggregate.Name or f"{aggregate.is_a()} #{aggregate.id()}" + deleted_aggregates.append(aggregate_name) + + aggregate_obj = tool.Ifc.get_object(aggregate) + if aggregate_obj: + ifcopenshell.api.root.remove_product(tool.Ifc.get(), product=aggregate) + bpy.data.objects.remove(aggregate_obj, do_unlink=True) + + # Show info message if aggregates were deleted + if deleted_aggregates: + if len(deleted_aggregates) == 1: + self.report({'INFO'}, f"Aggregate '{deleted_aggregates[0]}' was deleted because it had no remaining parts") + else: + aggregate_list = ", ".join(f"'{name}'" for name in deleted_aggregates) + self.report({'INFO'}, f"Aggregates {aggregate_list} were deleted because they had no remaining parts") class BIM_OT_enable_editing_aggregate(bpy.types.Operator): """Enable editing aggregation relationship""" diff --git a/src/bonsai/bonsai/bim/module/geometry/operator.py b/src/bonsai/bonsai/bim/module/geometry/operator.py index a5218b3dfa..0d5e1bd170 100644 --- a/src/bonsai/bonsai/bim/module/geometry/operator.py +++ b/src/bonsai/bonsai/bim/module/geometry/operator.py @@ -787,7 +787,6 @@ def calc_delete_is_batch(ifc_file: ifcopenshell.file, context: bpy.types.Context is_batch = total_elements > 500000 and total_polygons > 2000 return is_batch - class OverrideDelete(bpy.types.Operator): bl_idname = "bim.override_object_delete" bl_label = "IFC Delete" @@ -858,6 +857,10 @@ class OverrideDelete(bpy.types.Operator): objects_to_remove = context.selected_objects self.process_arrays(context) + + # Track aggregates before deleting their parts + aggregates_to_check = self.track_aggregates(objects_to_remove) + clear_active_object = True for i, obj in enumerate(objects_to_remove, 1): @@ -898,6 +901,9 @@ class OverrideDelete(bpy.types.Operator): else: bpy.data.objects.remove(obj) + # Delete empty aggregates after deleting their parts + self.delete_empty_aggregates(aggregates_to_check) + for opening in tool.Model.get_model_props().openings: if opening.obj is not None and not tool.Ifc.get_entity(opening.obj): bpy.data.objects.remove(opening.obj) @@ -930,6 +936,47 @@ class OverrideDelete(bpy.types.Operator): data["old_file"].redo() tool.Ifc.set(data["new_file"]) + def track_aggregates(self, objects_to_remove): + """Track aggregates that contain objects being deleted""" + aggregates_to_check = set() + for obj in objects_to_remove: + if not tool.Blender.is_valid_data_block(obj): + continue + element = tool.Ifc.get_entity(obj) + if not element: + continue + aggregate = ifcopenshell.util.element.get_aggregate(element) + if aggregate: + aggregates_to_check.add(aggregate) + return aggregates_to_check + + def delete_empty_aggregates(self, aggregates_to_check): + """Delete aggregates that now have no parts""" + deleted_aggregates = [] + for aggregate in aggregates_to_check: + # Check if aggregate still exists (might have been deleted already) + try: + aggregate.id() + except: + continue + + related_objects = ifcopenshell.util.element.get_parts(aggregate) + if len(related_objects) == 0: + aggregate_name = aggregate.Name or f"{aggregate.is_a()} #{aggregate.id()}" + deleted_aggregates.append(aggregate_name) + + aggregate_obj = tool.Ifc.get_object(aggregate) + if aggregate_obj and tool.Blender.is_valid_data_block(aggregate_obj): + tool.Geometry.delete_ifc_object(aggregate_obj) + + # Show info message if aggregates were deleted + if deleted_aggregates: + if len(deleted_aggregates) == 1: + self.report({'INFO'}, f"Aggregate '{deleted_aggregates[0]}' was deleted because it had no remaining parts") + else: + aggregate_list = ", ".join(f"'{name}'" for name in deleted_aggregates) + self.report({'INFO'}, f"Aggregates {aggregate_list} were deleted because they had no remaining parts") + def process_arrays(self, context: bpy.types.Context) -> None: ifc_file = tool.Ifc.get() selected_objects = set(context.selected_objects) @@ -955,7 +1002,6 @@ class OverrideDelete(bpy.types.Operator): else: break # allows to remove only n last layers of an array - class SelectedIdsData(NamedTuple): objects: set[bpy.types.Object] collections: set[bpy.types.Collection] From f5f676cf517fa18ac8f8b58ccc420e25491431fd Mon Sep 17 00:00:00 2001 From: Ryan Schultz Date: Sun, 12 Oct 2025 12:46:49 -0500 Subject: [PATCH 20/34] black . --- .../bonsai/bim/module/aggregate/operator.py | 19 +++++++++++-------- 1 file changed, 11 insertions(+), 8 deletions(-) diff --git a/src/bonsai/bonsai/bim/module/aggregate/operator.py b/src/bonsai/bonsai/bim/module/aggregate/operator.py index e2727b101a..54147c9e34 100644 --- a/src/bonsai/bonsai/bim/module/aggregate/operator.py +++ b/src/bonsai/bonsai/bim/module/aggregate/operator.py @@ -95,7 +95,7 @@ class BIM_OT_aggregate_unassign_object(bpy.types.Operator, tool.Ifc.Operator): def _execute(self, context): aggregates_to_check = set() - + # First pass: unassign all parts and track their aggregates for obj in tool.Blender.get_selected_objects(): element = tool.Ifc.get_entity(obj) @@ -104,10 +104,10 @@ class BIM_OT_aggregate_unassign_object(bpy.types.Operator, tool.Ifc.Operator): aggregate = ifcopenshell.util.element.get_aggregate(element) if not aggregate: continue - + # Track this aggregate for later checking aggregates_to_check.add(aggregate) - + core.unassign_object( tool.Ifc, tool.Aggregate, @@ -122,7 +122,7 @@ class BIM_OT_aggregate_unassign_object(bpy.types.Operator, tool.Ifc.Operator): if pset: pset = tool.Ifc.get().by_id(pset["id"]) ifcopenshell.api.pset.remove_pset(tool.Ifc.get(), product=element, pset=pset) - + # Second pass: delete aggregates that now have no parts deleted_aggregates = [] for aggregate in aggregates_to_check: @@ -130,19 +130,22 @@ class BIM_OT_aggregate_unassign_object(bpy.types.Operator, tool.Ifc.Operator): if len(related_objects) == 0: aggregate_name = aggregate.Name or f"{aggregate.is_a()} #{aggregate.id()}" deleted_aggregates.append(aggregate_name) - + aggregate_obj = tool.Ifc.get_object(aggregate) if aggregate_obj: ifcopenshell.api.root.remove_product(tool.Ifc.get(), product=aggregate) bpy.data.objects.remove(aggregate_obj, do_unlink=True) - + # Show info message if aggregates were deleted if deleted_aggregates: if len(deleted_aggregates) == 1: - self.report({'INFO'}, f"Aggregate '{deleted_aggregates[0]}' was deleted because it had no remaining parts") + self.report( + {"INFO"}, f"Aggregate '{deleted_aggregates[0]}' was deleted because it had no remaining parts" + ) else: aggregate_list = ", ".join(f"'{name}'" for name in deleted_aggregates) - self.report({'INFO'}, f"Aggregates {aggregate_list} were deleted because they had no remaining parts") + self.report({"INFO"}, f"Aggregates {aggregate_list} were deleted because they had no remaining parts") + class BIM_OT_enable_editing_aggregate(bpy.types.Operator): """Enable editing aggregation relationship""" From 1b451d45cfc34a47b80b347ebcbb56260f0fe00b Mon Sep 17 00:00:00 2001 From: Ryan Schultz Date: Sun, 12 Oct 2025 13:51:14 -0500 Subject: [PATCH 21/34] Closes #7159: Fix AddReferenceToSheet operator poll checking wrong data_dir property (bim_props instead of prefs) --- src/bonsai/bonsai/bim/module/drawing/operator.py | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/src/bonsai/bonsai/bim/module/drawing/operator.py b/src/bonsai/bonsai/bim/module/drawing/operator.py index b1018ee046..bbc282f41e 100644 --- a/src/bonsai/bonsai/bim/module/drawing/operator.py +++ b/src/bonsai/bonsai/bim/module/drawing/operator.py @@ -2884,8 +2884,14 @@ class AddReferenceToSheet(bpy.types.Operator, tool.Ifc.Operator): if not props.references: cls.poll_message_set("No reference selected.") return False - bim_props = tool.Blender.get_bim_props() - return props.references and props.sheets and bim_props.data_dir + if not props.sheets: + cls.poll_message_set("No sheets available.") + return False + prefs = tool.Blender.get_addon_preferences() + if not prefs.data_dir: + cls.poll_message_set("BIM data directory not set.") + return False + return True def _execute(self, context): props = tool.Drawing.get_document_props() From 3343f3fb353add4e4038daf0e2d4a76dc07b762c Mon Sep 17 00:00:00 2001 From: Dion Moult Date: Mon, 13 Oct 2025 07:49:07 +1100 Subject: [PATCH 22/34] Revert "Closes #7229: Unhide object before mesh tessellation export to prevent "Cannot edit hidden object" error when collector.assign() hides the object." This reverts commit 96d05300bbf89aea685915f8252e4e8e2f9faf9b. --- src/bonsai/bonsai/bim/module/root/operator.py | 4 ---- 1 file changed, 4 deletions(-) diff --git a/src/bonsai/bonsai/bim/module/root/operator.py b/src/bonsai/bonsai/bim/module/root/operator.py index 9044e0d271..55ffa56a79 100644 --- a/src/bonsai/bonsai/bim/module/root/operator.py +++ b/src/bonsai/bonsai/bim/module/root/operator.py @@ -326,10 +326,6 @@ class AssignClass(bpy.types.Operator, tool.Ifc.Operator): predefined_type=predefined_type, should_add_representation=False, ) - - #Unhide object before mesh tessellation export to prevent "Cannot edit hidden object" error when collector.assign() hides the object. - obj.hide_viewport = False - representation = tool.Geometry.export_mesh_to_tessellation(obj, ifc_context) ifcopenshell.api.geometry.assign_representation(tool.Ifc.get(), element, representation) bonsai.core.geometry.switch_representation( From 6bba3ebeeb4897dfeac1cee7b5fdb06f6ed7c009 Mon Sep 17 00:00:00 2001 From: Dion Moult Date: Mon, 13 Oct 2025 08:02:06 +1100 Subject: [PATCH 23/34] See #7159. Make polls consistent in add foo to sheet operators. --- .../bonsai/bim/module/drawing/operator.py | 22 ++++++++++++------- 1 file changed, 14 insertions(+), 8 deletions(-) diff --git a/src/bonsai/bonsai/bim/module/drawing/operator.py b/src/bonsai/bonsai/bim/module/drawing/operator.py index bbc282f41e..cfb8253e82 100644 --- a/src/bonsai/bonsai/bim/module/drawing/operator.py +++ b/src/bonsai/bonsai/bim/module/drawing/operator.py @@ -1866,13 +1866,15 @@ class AddDrawingToSheet(bpy.types.Operator, tool.Ifc.Operator): @classmethod def poll(cls, context): props = tool.Drawing.get_document_props() - # Won't be visible in UI anyway. - prefs = tool.Blender.get_addon_preferences() - if not props.sheets or not prefs.data_dir: - return False if not tool.Drawing.get_active_drawing_item(): cls.poll_message_set("No drawing selected.") return False + if not props.sheets: + cls.poll_message_set("No sheets available.") + return False + if not tool.Blender.get_user_data_dir(): + cls.poll_message_set("BIM data directory not set.") + return False return True def _execute(self, context): @@ -2816,8 +2818,13 @@ class AddScheduleToSheet(bpy.types.Operator, tool.Ifc.Operator): if not props.schedules: cls.poll_message_set("No schedule selected.") return False - prefs = tool.Blender.get_addon_preferences() - return props.schedules and props.sheets and prefs.data_dir + if not props.sheets: + cls.poll_message_set("No sheets available.") + return False + if not tool.Blender.get_user_data_dir(): + cls.poll_message_set("BIM data directory not set.") + return False + return True def _execute(self, context): props = tool.Drawing.get_document_props() @@ -2887,8 +2894,7 @@ class AddReferenceToSheet(bpy.types.Operator, tool.Ifc.Operator): if not props.sheets: cls.poll_message_set("No sheets available.") return False - prefs = tool.Blender.get_addon_preferences() - if not prefs.data_dir: + if not tool.Blender.get_user_data_dir(): cls.poll_message_set("BIM data directory not set.") return False return True From fb15435296af0dec5b9b52dd432f23f5759f8973 Mon Sep 17 00:00:00 2001 From: Dion Moult Date: Mon, 13 Oct 2025 08:08:10 +1100 Subject: [PATCH 24/34] See #7229. Simpler solution to ensure representation isn't affected by visibility until assign class can be cleaned up more. --- src/bonsai/bonsai/bim/module/root/operator.py | 7 ++----- 1 file changed, 2 insertions(+), 5 deletions(-) diff --git a/src/bonsai/bonsai/bim/module/root/operator.py b/src/bonsai/bonsai/bim/module/root/operator.py index 55ffa56a79..a5bf766ae2 100644 --- a/src/bonsai/bonsai/bim/module/root/operator.py +++ b/src/bonsai/bonsai/bim/module/root/operator.py @@ -317,6 +317,7 @@ class AssignClass(bpy.types.Operator, tool.Ifc.Operator): f"Mesh '{obj.data.name}' has loose geometry, loose geometry will be ignored to save mesh to IFC as a tessellation.", ) + representation = tool.Geometry.export_mesh_to_tessellation(obj, ifc_context) element = core.assign_class( tool.Ifc, tool.Collector, @@ -326,13 +327,9 @@ class AssignClass(bpy.types.Operator, tool.Ifc.Operator): predefined_type=predefined_type, should_add_representation=False, ) - representation = tool.Geometry.export_mesh_to_tessellation(obj, ifc_context) ifcopenshell.api.geometry.assign_representation(tool.Ifc.get(), element, representation) bonsai.core.geometry.switch_representation( - tool.Ifc, - tool.Geometry, - obj=obj, - representation=representation, + tool.Ifc, tool.Geometry, obj=obj, representation=representation ) else: From 155827528a9336cf4d1ef5ea1eaea812126f93a3 Mon Sep 17 00:00:00 2001 From: Dion Moult Date: Mon, 13 Oct 2025 08:08:35 +1100 Subject: [PATCH 25/34] Visibility during collector assignment now depends on the visibility mode set in the UI. --- src/bonsai/bonsai/tool/collector.py | 13 ++++++++++--- 1 file changed, 10 insertions(+), 3 deletions(-) diff --git a/src/bonsai/bonsai/tool/collector.py b/src/bonsai/bonsai/tool/collector.py index 34f471ada4..65d5ae49bd 100644 --- a/src/bonsai/bonsai/tool/collector.py +++ b/src/bonsai/bonsai/tool/collector.py @@ -51,9 +51,13 @@ class Collector(bonsai.core.tool.Collector): if tool.Geometry.is_locked(element): tool.Geometry.lock_object(obj) element = (element.PartOfU or element.PartOfV or element.PartOfW)[0] + if not tool.Spatial.get_grid_props().is_visible: + obj.hide_viewport = True elif element.is_a("IfcGrid"): if tool.Geometry.is_locked(element): tool.Geometry.lock_object(obj) + if not tool.Spatial.get_grid_props().is_visible: + obj.hide_viewport = True if element.is_a("IfcProject"): if tool.Geometry.is_locked(element): @@ -69,7 +73,8 @@ class Collector(bonsai.core.tool.Collector): tool.Geometry.lock_object(obj) collection = cls._create_project_child_collection("IfcSpace") cls.link_collection_object_safe(collection, obj) - obj.hide_viewport = True + if not tool.Spatial.get_spatial_props().is_visible: + obj.hide_viewport = True elif element.is_a("IfcStructuralItem"): collection = cls._create_project_child_collection("IfcStructuralItem") cls.link_collection_object_safe(collection, obj) @@ -93,7 +98,8 @@ class Collector(bonsai.core.tool.Collector): cls.link_collection_object_safe(collection, obj) project_obj = tool.Ifc.get_object(tool.Ifc.get().by_type("IfcProject")[0]) cls.link_collection_child_safe(tool.Blender.get_object_bim_props(project_obj).collection, collection) - obj.hide_viewport = True + if not tool.Spatial.get_spatial_props().is_visible: + obj.hide_viewport = True elif ( tool.Ifc.get_schema() != "IFC2X3" and element.is_a("IfcSpatialElement") @@ -105,7 +111,8 @@ class Collector(bonsai.core.tool.Collector): cls.link_collection_object_safe(collection, obj) project_obj = tool.Ifc.get_object(tool.Ifc.get().by_type("IfcProject")[0]) cls.link_collection_child_safe(tool.Blender.get_object_bim_props(project_obj).collection, collection) - obj.hide_viewport = True + if not tool.Spatial.get_spatial_props().is_visible: + obj.hide_viewport = True elif element.is_a("IfcAnnotation") and element.ObjectType == "DRAWING": if collection := cls._create_own_collection(obj): cls.link_collection_object_safe(collection, obj) From 65237cab655512152437a2043ba4c81a110d8c5c Mon Sep 17 00:00:00 2001 From: Dion Moult Date: Mon, 13 Oct 2025 08:44:47 +1100 Subject: [PATCH 26/34] Fix #7080. Potential bug with merging styles that didn't replace elements of the same class. --- src/ifcpatch/ifcpatch/recipes/MergeStyles.py | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/src/ifcpatch/ifcpatch/recipes/MergeStyles.py b/src/ifcpatch/ifcpatch/recipes/MergeStyles.py index cb2780a360..5c1fa2f4fa 100644 --- a/src/ifcpatch/ifcpatch/recipes/MergeStyles.py +++ b/src/ifcpatch/ifcpatch/recipes/MergeStyles.py @@ -44,11 +44,12 @@ class Patcher(ifcpatch.BasePatcher): uniques = {} i = 0 for element in self.file.by_type(ifc_class): - data = "-".join([str(a) for a in element]) - if unique := uniques.get(data, None): + ifc_class = element.is_a() + key = "-".join([str(a) for a in element]) + if unique := uniques.get(ifc_class, {}).get(key, None): ifcopenshell.util.element.replace_element(element, unique) self.file.remove(element) i += 1 else: - uniques[data] = element + uniques.setdefault(ifc_class, {})[key] = element print(f"Replaced {i} {ifc_class}") From 8014980b8863770bd7c18c83635c6fe16cb908a9 Mon Sep 17 00:00:00 2001 From: Dion Moult Date: Mon, 13 Oct 2025 08:52:59 +1100 Subject: [PATCH 27/34] Fix #7076. Lock representation items scale to prevent user confusion. --- src/bonsai/bonsai/bim/module/geometry/operator.py | 1 + 1 file changed, 1 insertion(+) diff --git a/src/bonsai/bonsai/bim/module/geometry/operator.py b/src/bonsai/bonsai/bim/module/geometry/operator.py index 0d5e1bd170..578dc8b4e9 100644 --- a/src/bonsai/bonsai/bim/module/geometry/operator.py +++ b/src/bonsai/bonsai/bim/module/geometry/operator.py @@ -3028,6 +3028,7 @@ class ImportRepresentationItems(bpy.types.Operator, tool.Ifc.Operator): item_mesh = bpy.data.meshes.new("tmp") tool.Ifc.link(item, item_mesh) item_obj = bpy.data.objects.new("tmp", item_mesh) + tool.Geometry.lock_scale(item_obj) tool.Geometry.name_item_object(item_obj, item) item_obj.matrix_world = obj.matrix_world bpy.context.collection.objects.link(item_obj) From 322a2179e51a9ec4802fe9e35039c7e3e29f9678 Mon Sep 17 00:00:00 2001 From: Ryan Schultz Date: Mon, 13 Oct 2025 11:25:36 -0500 Subject: [PATCH 28/34] Merge entities, if match on name or identification attribute only. --- .../bonsai/bim/module/debug/operator.py | 25 ++++++- src/bonsai/bonsai/bim/module/project/ui.py | 3 +- src/bonsai/bonsai/tool/debug.py | 73 ++++++++++++++----- 3 files changed, 80 insertions(+), 21 deletions(-) diff --git a/src/bonsai/bonsai/bim/module/debug/operator.py b/src/bonsai/bonsai/bim/module/debug/operator.py index 3b527b198b..3ef8dfc953 100644 --- a/src/bonsai/bonsai/bim/module/debug/operator.py +++ b/src/bonsai/bonsai/bim/module/debug/operator.py @@ -818,7 +818,7 @@ class PurgeUnusedObjects(bpy.types.Operator, tool.Ifc.Operator): class MergeIdenticalObjects(bpy.types.Operator, tool.Ifc.Operator): bl_idname = "bim.merge_identical_objects" bl_label = "Merge Identical Objects" - bl_description = "For materials currently only IfcMaterials are supported" + bl_description = "Merge identical IFC entities (that match all attributes). Hold Shift to merge by name/identification attribute only" bl_options = {"REGISTER", "UNDO"} object_type: bpy.props.EnumProperty( # pyright: ignore[reportRedeclaration] @@ -826,18 +826,36 @@ class MergeIdenticalObjects(bpy.types.Operator, tool.Ifc.Operator): items=((s, s.capitalize(), "") for s in get_args(tool.Debug.PurgeMergeObjectType)), ) + by_name_or_identification_only: bpy.props.BoolProperty( + name="By Name/Identification Only", + description="Merge based only on Name or Identification attribute, ignoring other properties", + default=False, + ) + if TYPE_CHECKING: object_type: tool.Debug.PurgeMergeObjectType + def invoke(self, context, event): + # Check if shift key is pressed + if event.shift: + self.by_name_or_identification_only = True + else: + self.by_name_or_identification_only = False + + return self.execute(context) + def _execute(self, context): object_type: str = self.object_type if object_type in ("PROFILE", "TYPE"): self.report({"ERROR"}, f"Unsupported object type {object_type}.") return {"CANCELLED"} - merged_data = tool.Debug.merge_identical_objects(object_type) + merged_data = tool.Debug.merge_identical_objects( + object_type, by_name_or_identification_only=self.by_name_or_identification_only + ) plural_object_type = f"{object_type.lower().replace('_', ' ')}s" if merged_data: + merge_mode = " by name/identification" if self.by_name_or_identification_only else "" for element_type, element_names in merged_data.items(): print(f"- {element_type}:") for name in element_names: @@ -846,7 +864,8 @@ class MergeIdenticalObjects(bpy.types.Operator, tool.Ifc.Operator): merged = sum(len(v) for v in merged_data.values()) msg = " See system console for details." if merged else "" - self.report({"INFO"}, f"{merged} identical {plural_object_type} were merged.{msg}") + merge_mode = " (by name/identification)" if self.by_name_or_identification_only else "" + self.report({"INFO"}, f"{merged} identical {plural_object_type} were merged{merge_mode}.{msg}") if merged == 0: return diff --git a/src/bonsai/bonsai/bim/module/project/ui.py b/src/bonsai/bonsai/bim/module/project/ui.py index 75d114e204..03b4222008 100644 --- a/src/bonsai/bonsai/bim/module/project/ui.py +++ b/src/bonsai/bonsai/bim/module/project/ui.py @@ -653,4 +653,5 @@ class BIM_PT_purge(Panel): row = layout.row(align=True) row.label(text=f"{object_type.replace('_', ' ').capitalize()}:") row.operator("bim.purge_unused_objects", text="Purge Unused").object_type = object_type - row.operator("bim.merge_identical_objects", text="Merge Identical").object_type = object_type + merge_op = row.operator("bim.merge_identical_objects", text="Merge Identical") + merge_op.object_type = object_type diff --git a/src/bonsai/bonsai/tool/debug.py b/src/bonsai/bonsai/tool/debug.py index 079892a9b5..179263f0d1 100644 --- a/src/bonsai/bonsai/tool/debug.py +++ b/src/bonsai/bonsai/tool/debug.py @@ -33,7 +33,7 @@ import bonsai.tool as tool from bonsai.bim.ifc import IfcStore from mathutils import Vector from collections import defaultdict -from typing import Literal, TYPE_CHECKING, assert_never +from typing import Literal, TYPE_CHECKING, assert_never, Union from collections.abc import Iterable if TYPE_CHECKING: @@ -136,10 +136,17 @@ class Debug(bonsai.core.tool.Debug): "PERSON", "PERSON_AND_ORGANIZATION", ], + by_name_or_identification_only: bool = False, ) -> dict[str, list[str]]: """Merge identical objects. Note that Styles UI (or other UI) should be updated manually after using this method. + + Args: + object_type: The type of object to merge + by_name_or_identification_only: If True, merge based only on Name attribute (or equivalent identifier). + For PERSON, uses Identification. For APPLICATION, uses ApplicationFullName. + For PERSON_AND_ORGANIZATION, uses combination of person and organization identifiers. """ def get_hash(element: ifcopenshell.entity_instance) -> int: @@ -152,6 +159,25 @@ class Debug(bonsai.core.tool.Debug): data["TheOrganization"] = element.TheOrganization.id() return hash(json.dumps(data, sort_keys=True)) + def get_name_key(element: ifcopenshell.entity_instance) -> str: + """Get key based on name/identifier attribute for the given object type""" + if object_type == "STYLE": + return element.Name if element.Name else "" + elif object_type == "MATERIAL": + return element.Name if element.Name else "" + elif object_type == "ORGANIZATION": + return element.Name if element.Name else "" + elif object_type == "APPLICATION": + return element.ApplicationFullName if element.ApplicationFullName else "" + elif object_type == "PERSON": + return element.Identification if element.Identification else "" + elif object_type == "PERSON_AND_ORGANIZATION": + person_id = element.ThePerson.Identification if element.ThePerson.Identification else "" + org_name = element.TheOrganization.Name if element.TheOrganization.Name else "" + return f"{person_id}|{org_name}" + else: + assert_never(object_type) + ifc_file = tool.Ifc.get() merged_element_types: dict[str, list[str]] = {} @@ -179,22 +205,35 @@ class Debug(bonsai.core.tool.Debug): for element_type in element_types: elements = ifc_file.by_type(element_type, include_subtypes=False) - # Calculate hashes. - hash_to_elements: defaultdict[int, list[ifcopenshell.entity_instance]] = defaultdict(list) - for element in elements: - # Except for styles, ignore unnamed elements as they may be not safe to merge - merge_optional_names = ("STYLE", "PERSON") - not_optional_name = ("APPLICATION", "ORGANIZATION") - has_no_name = ("PERSON_AND_ORGANIZATION",) - if ( - object_type not in merge_optional_names - and object_type not in not_optional_name - and object_type not in has_no_name - and not element.Name - ): - continue - element_hash = get_hash(element) - hash_to_elements[element_hash].append(element) + # Calculate hashes or name keys. + hash_to_elements: defaultdict[Union[int, str], list[ifcopenshell.entity_instance]] + + if by_name_or_identification_only: + # Group by name/identifier only + hash_to_elements = defaultdict(list) + for element in elements: + name_key = get_name_key(element) + # Skip elements without a valid identifier + if not name_key: + continue + hash_to_elements[name_key].append(element) + else: + # Group by full hash + hash_to_elements = defaultdict(list) + for element in elements: + # Except for styles, ignore unnamed elements as they may be not safe to merge + merge_optional_names = ("STYLE", "PERSON") + not_optional_name = ("APPLICATION", "ORGANIZATION") + has_no_name = ("PERSON_AND_ORGANIZATION",) + if ( + object_type not in merge_optional_names + and object_type not in not_optional_name + and object_type not in has_no_name + and not element.Name + ): + continue + element_hash = get_hash(element) + hash_to_elements[element_hash].append(element) merged_elements_names: list[str] = [] # Merge elements. From b0ae0a2c82bc1b2d53791fce0f78164f3ddf20e7 Mon Sep 17 00:00:00 2001 From: Ryan Schultz Date: Tue, 14 Oct 2025 11:29:44 -0500 Subject: [PATCH 29/34] extend https://github.com/IfcOpenShell/IfcOpenShell/commit/322a2179e51a9ec4802fe9e35039c7e3e29f9678: Merges names with number suffix, as well (ex: foo, foo.001, foo.002) --- .../bonsai/bim/module/debug/operator.py | 7 ++- src/bonsai/bonsai/tool/debug.py | 50 ++++++++++++++++++- 2 files changed, 55 insertions(+), 2 deletions(-) diff --git a/src/bonsai/bonsai/bim/module/debug/operator.py b/src/bonsai/bonsai/bim/module/debug/operator.py index 3ef8dfc953..0820d5e3d6 100644 --- a/src/bonsai/bonsai/bim/module/debug/operator.py +++ b/src/bonsai/bonsai/bim/module/debug/operator.py @@ -818,7 +818,12 @@ class PurgeUnusedObjects(bpy.types.Operator, tool.Ifc.Operator): class MergeIdenticalObjects(bpy.types.Operator, tool.Ifc.Operator): bl_idname = "bim.merge_identical_objects" bl_label = "Merge Identical Objects" - bl_description = "Merge identical IFC entities (that match all attributes). Hold Shift to merge by name/identification attribute only" + bl_description = ( + "Merge identical IFC objects (that match all attributes).\n" + "\n" + "SHIFT + CLICK to merge by name/identification attribute only.\n" + "Merges names with number suffix, as well (ex: foo, foo.001, foo.002)\n" + ) bl_options = {"REGISTER", "UNDO"} object_type: bpy.props.EnumProperty( # pyright: ignore[reportRedeclaration] diff --git a/src/bonsai/bonsai/tool/debug.py b/src/bonsai/bonsai/tool/debug.py index 179263f0d1..35e877e734 100644 --- a/src/bonsai/bonsai/tool/debug.py +++ b/src/bonsai/bonsai/tool/debug.py @@ -18,6 +18,7 @@ from __future__ import annotations import os +import re import json import bmesh import bpy @@ -145,10 +146,21 @@ class Debug(bonsai.core.tool.Debug): Args: object_type: The type of object to merge by_name_or_identification_only: If True, merge based only on Name attribute (or equivalent identifier). + Strips .XXX suffix patterns (e.g., 'foo.001' matches 'foo', 'foo.002'). For PERSON, uses Identification. For APPLICATION, uses ApplicationFullName. For PERSON_AND_ORGANIZATION, uses combination of person and organization identifiers. """ + def normalize_name(name: str) -> str: + """Remove .XXX suffix pattern from names (e.g., 'foo.001' -> 'foo')""" + if not name: + return "" + # Match pattern: name ending with .digits + match = re.match(r"^(.+)\.\d+$", name) + if match: + return match.group(1) + return name + def get_hash(element: ifcopenshell.entity_instance) -> int: data = element.get_info_2(include_identifier=False, recursive=True) if object_type == "APPLICATION": @@ -161,6 +173,30 @@ class Debug(bonsai.core.tool.Debug): def get_name_key(element: ifcopenshell.entity_instance) -> str: """Get key based on name/identifier attribute for the given object type""" + if object_type == "STYLE": + name = element.Name if element.Name else "" + return normalize_name(name) + elif object_type == "MATERIAL": + name = element.Name if element.Name else "" + return normalize_name(name) + elif object_type == "ORGANIZATION": + name = element.Name if element.Name else "" + return normalize_name(name) + elif object_type == "APPLICATION": + name = element.ApplicationFullName if element.ApplicationFullName else "" + return normalize_name(name) + elif object_type == "PERSON": + ident = element.Identification if element.Identification else "" + return normalize_name(ident) + elif object_type == "PERSON_AND_ORGANIZATION": + person_id = element.ThePerson.Identification if element.ThePerson.Identification else "" + org_name = element.TheOrganization.Name if element.TheOrganization.Name else "" + return f"{normalize_name(person_id)}|{normalize_name(org_name)}" + else: + assert_never(object_type) + + def get_element_name(element: ifcopenshell.entity_instance) -> str: + """Get the actual name/identifier from element for sorting purposes""" if object_type == "STYLE": return element.Name if element.Name else "" elif object_type == "MATERIAL": @@ -209,7 +245,7 @@ class Debug(bonsai.core.tool.Debug): hash_to_elements: defaultdict[Union[int, str], list[ifcopenshell.entity_instance]] if by_name_or_identification_only: - # Group by name/identifier only + # Group by name/identifier only (with .XXX suffix normalization) hash_to_elements = defaultdict(list) for element in elements: name_key = get_name_key(element) @@ -217,6 +253,18 @@ class Debug(bonsai.core.tool.Debug): if not name_key: continue hash_to_elements[name_key].append(element) + + # Sort elements within each group to keep the one without suffix (or lowest suffix) + for name_key in hash_to_elements: + # Sort by: 1) prefer names without .XXX suffix, 2) then by original name + def sort_key(el): + name = get_element_name(el) + # Check if name has .XXX suffix + has_suffix = bool(re.match(r"^.+\.\d+$", name)) + # Return tuple: (has_suffix, name) - sort by no suffix first, then alphabetically + return (has_suffix, name) + + hash_to_elements[name_key].sort(key=sort_key) else: # Group by full hash hash_to_elements = defaultdict(list) From f52aafdd6ade8590cc7a474d9d859498aaf0e0d8 Mon Sep 17 00:00:00 2001 From: Ryan Schultz Date: Tue, 14 Oct 2025 21:53:22 -0500 Subject: [PATCH 30/34] closes #7249: for stairs, change the first/last tread lengths to less than the typical tread run. And even go to zero, whereby removing the tread altogether. --- src/bonsai/bonsai/bim/module/model/prop.py | 39 +++- src/bonsai/bonsai/bim/module/model/stair.py | 6 +- src/bonsai/bonsai/bim/module/model/ui.py | 25 +- src/bonsai/bonsai/tool/model.py | 96 +++++--- src/bonsai/test/tool/test_model.py | 238 ++++++++++++++++++++ 5 files changed, 361 insertions(+), 43 deletions(-) diff --git a/src/bonsai/bonsai/bim/module/model/prop.py b/src/bonsai/bonsai/bim/module/model/prop.py index 7245dfb443..dece203282 100644 --- a/src/bonsai/bonsai/bim/module/model/prop.py +++ b/src/bonsai/bonsai/bim/module/model/prop.py @@ -373,7 +373,12 @@ class BIMStairProperties(PropertyGroup): if self.stair_type != "WOOD/STEEL" and self.nosing_length < 0: self["nosing_length"] = 0 - non_si_units_props = ("is_editing", "number_of_treads", "has_top_nib", "stair_type") + def update_custom_tread_lock(self, context: bpy.types.Context) -> None: + """When lock is enabled, sync custom treads with tread_run""" + if self.custom_tread_lock: + self["custom_first_last_tread_run"] = (self.tread_run, self.tread_run) + + non_si_units_props = ("is_editing", "number_of_treads", "has_top_nib", "stair_type", "custom_tread_lock") is_editing: bpy.props.BoolProperty(default=False) width: bpy.props.FloatProperty(name="Width", default=1.2, soft_min=0.01, subtype="DISTANCE") @@ -407,6 +412,12 @@ class BIMStairProperties(PropertyGroup): default="CONCRETE", update=validate_nosing_value, ) + custom_tread_lock: bpy.props.BoolProperty( + name="Lock First/Last Treads to Tread Run", + description="When enabled, first and last treads automatically use the Tread Run value", + default=True, + update=update_custom_tread_lock, + ) custom_first_last_tread_run: bpy.props.FloatVectorProperty( name="Custom First / Last Treads Widths", description='Specify custom first / last treads widths, different from the general "Tread Run". Leave 0 to disable.', @@ -442,6 +453,7 @@ class BIMStairProperties(PropertyGroup): top_slab_depth: float has_top_nib: bool stair_type: str + custom_tread_lock: bool custom_first_last_tread_run: tuple[float, float] nosing_length: float nosing_depth: float @@ -480,17 +492,38 @@ class BIMStairProperties(PropertyGroup): } stair_kwargs.update(generic_props) - # defined here to appear last in UI - stair_kwargs["custom_first_last_tread_run"] = self.custom_first_last_tread_run + # If locked, use tread_run for both first and last treads + if self.custom_tread_lock: + stair_kwargs["custom_first_last_tread_run"] = (self.tread_run, self.tread_run) + else: + stair_kwargs["custom_first_last_tread_run"] = self.custom_first_last_tread_run if not convert_to_project_units: return stair_kwargs stair_kwargs = tool.Model.convert_data_to_project_units(stair_kwargs, self.non_si_units_props) return stair_kwargs + + def get_props_kwargs_for_ifc_export(self, convert_to_project_units=False, stair_type=None): + """Get props including custom_tread_lock for saving to IFC""" + stair_kwargs = self.get_props_kwargs(convert_to_project_units, stair_type) + # Add the lock state for IFC storage (after getting base kwargs to avoid passing to generate function) + stair_kwargs["custom_tread_lock"] = self.custom_tread_lock + return stair_kwargs def set_props_kwargs_from_ifc_data(self, kwargs): kwargs = tool.Model.convert_data_to_si_units(kwargs, self.non_si_units_props) + + # Determine lock state based on whether custom treads match tread_run + # If custom_tread_lock wasn't saved (old files), infer it from the data + if "custom_tread_lock" not in kwargs: + custom_treads = kwargs.get("custom_first_last_tread_run", (0.0, 0.0)) + tread_run = kwargs.get("tread_run", 0.3) + # Lock is off if either custom tread differs from tread_run and is not 0 + kwargs["custom_tread_lock"] = not any( + ct != 0.0 and ct != tread_run for ct in custom_treads + ) + for prop_name in kwargs: setattr(self, prop_name, kwargs[prop_name]) diff --git a/src/bonsai/bonsai/bim/module/model/stair.py b/src/bonsai/bonsai/bim/module/model/stair.py index d2b5184c76..71bfa97a9a 100644 --- a/src/bonsai/bonsai/bim/module/model/stair.py +++ b/src/bonsai/bonsai/bim/module/model/stair.py @@ -188,7 +188,8 @@ class AddStair(bpy.types.Operator, tool.Ifc.Operator): props = tool.Model.get_stair_props(obj) ifc_file = tool.Ifc.get() - stair_data = props.get_props_kwargs(convert_to_project_units=True) + # Use the special method that includes custom_tread_lock for IFC storage + stair_data = props.get_props_kwargs_for_ifc_export(convert_to_project_units=True) pset = tool.Pset.get_element_pset(element, "BBIM_Stair") if not pset: pset = ifcopenshell.api.pset.add_pset(ifc_file, product=element, name="BBIM_Stair") @@ -241,7 +242,8 @@ class FinishEditingStair(bpy.types.Operator, tool.Ifc.Operator): assert element props = tool.Model.get_stair_props(obj) - data = props.get_props_kwargs(convert_to_project_units=True) + # Use the special method that includes custom_tread_lock for IFC storage + data = props.get_props_kwargs_for_ifc_export(convert_to_project_units=True) props.is_editing = False regenerate_stair_mesh(obj) tool.Model.add_body_representation(obj) diff --git a/src/bonsai/bonsai/bim/module/model/ui.py b/src/bonsai/bonsai/bim/module/model/ui.py index 0b17f6d9fb..b62672230c 100644 --- a/src/bonsai/bonsai/bim/module/model/ui.py +++ b/src/bonsai/bonsai/bim/module/model/ui.py @@ -302,13 +302,36 @@ class BIM_PT_stair(bpy.types.Panel): row.operator("bim.cancel_editing_stair", icon="CANCEL", text="") row = self.layout.row(align=True) for prop_name in props.get_props_kwargs(): + # Skip custom_tread_lock as it's handled with custom_first_last_tread_run + if prop_name == "custom_tread_lock": + continue + prop_value = getattr(props, prop_name) - if isinstance(prop_value, Iterable) and not isinstance(prop_value, str): + + # Special handling for custom_first_last_tread_run + if prop_name == "custom_first_last_tread_run": + # Draw the lock toggle + row_lock = self.layout.row(align=True) + lock_text = "Lock First/Last Treads" if not props.custom_tread_lock else "Unlock First/Last Treads" + row_lock.prop( + props, + "custom_tread_lock", + text=lock_text, + icon="LOCKED" if props.custom_tread_lock else "UNLOCKED", + ) + + # Only show the custom values input if unlocked + if not props.custom_tread_lock: + prop_readable_name = props.bl_rna.properties[prop_name].name + self.layout.label(text=f"{prop_readable_name}:") + self.layout.prop(props, prop_name, text="") + elif isinstance(prop_value, Iterable) and not isinstance(prop_value, str): prop_readable_name = props.bl_rna.properties[prop_name].name self.layout.label(text=f"{prop_readable_name}:") self.layout.prop(props, prop_name, text="") else: self.layout.prop(props, prop_name) + if prop_name == "height": # Weak but we just want to insert this inside props drawing row_length = self.layout.row(align=True) row_length.prop(props, "total_length_target") diff --git a/src/bonsai/bonsai/tool/model.py b/src/bonsai/bonsai/tool/model.py index 10181c5d93..bdf09ceacd 100644 --- a/src/bonsai/bonsai/tool/model.py +++ b/src/bonsai/bonsai/tool/model.py @@ -1399,7 +1399,6 @@ class Model(bonsai.core.tool.Model): number_of_risers = number_of_treads + 1 tread_rise = height / number_of_risers - custom_tread_run = any(run != 0 for run in custom_first_last_tread_run) nosing_overlap = max(nosing_length, 0) nosing_tread_gap = -min(nosing_length, 0) nosing_overlap_offset = -V_(nosing_overlap, 0) @@ -1430,19 +1429,26 @@ class Model(bonsai.core.tool.Model): default_tread_offset = Vector([tread_run, tread_rise]) def get_tread_data(i): - if custom_tread_run: - current_tread_run = None - if i == 0: - current_tread_run = custom_first_last_tread_run[0] - elif i == number_of_risers - 1: - current_tread_run = custom_first_last_tread_run[1] + # Check if this is first or last tread with custom run + current_tread_run = None + if i == 0 and custom_first_last_tread_run[0] is not None: + current_tread_run = custom_first_last_tread_run[0] + elif i == number_of_risers - 1 and custom_first_last_tread_run[1] is not None: + current_tread_run = custom_first_last_tread_run[1] - if current_tread_run: - tread_offset = default_tread_offset.copy() - tread_offset.x = current_tread_run - tread_verts = deepcopy(default_tread_verts) - tread_verts[-1].x = current_tread_run - return tread_offset, tread_verts + if current_tread_run is not None: + tread_offset = default_tread_offset.copy() + tread_offset.x = current_tread_run + + # Handle zero-width treads + if current_tread_run == 0: + # For zero width, just return vertical offset with no horizontal tread + return tread_offset, () + + tread_verts = deepcopy(default_tread_verts) + tread_verts[-1].x = current_tread_run + return tread_offset, tread_verts + return default_tread_offset, default_tread_verts # treads @@ -1450,9 +1456,13 @@ class Model(bonsai.core.tool.Model): for i in range(number_of_risers): last_vert_i = len(vertices) - 1 tread_offset, tread_verts = get_tread_data(i) - current_tread_verts = [v + current_offset for v in tread_verts] - edges.extend(default_tread_edges + last_vert_i) - vertices.extend(current_tread_verts) + + # Skip adding vertices/edges for zero-width treads + if tread_verts: + current_tread_verts = [v + current_offset for v in tread_verts] + edges.extend(default_tread_edges + last_vert_i) + vertices.extend(current_tread_verts) + current_offset += tread_offset if stair_type == "WOOD/STEEL": @@ -1467,35 +1477,47 @@ class Model(bonsai.core.tool.Model): default_tread_offset = V_(tread_run + nosing_tread_gap, tread_rise) def get_tread_data(i): - if custom_tread_run: - current_tread_run = None - if i == 0 and custom_first_last_tread_run[0] != 0: - current_tread_run = custom_first_last_tread_run[0] - elif i == number_of_risers - 1 and custom_first_last_tread_run[1] != 0: - current_tread_run = custom_first_last_tread_run[1] + # Check if this is first or last tread with custom run + current_tread_run = None + if i == 0 and custom_first_last_tread_run[0] is not None: + current_tread_run = custom_first_last_tread_run[0] + elif i == number_of_risers - 1 and custom_first_last_tread_run[1] is not None: + current_tread_run = custom_first_last_tread_run[1] - if current_tread_run: - tread_offset = default_tread_offset.copy() - tread_offset.x = current_tread_run + nosing_tread_gap - tread_verts = get_tread_verts(size=V_(current_tread_run + nosing_overlap, tread_depth)) - return tread_offset, tread_verts + if current_tread_run is not None: + tread_offset = default_tread_offset.copy() + tread_offset.x = current_tread_run + nosing_tread_gap + + # Handle zero-width treads + if current_tread_run == 0: + return tread_offset, () + + tread_verts = get_tread_verts(size=V_(current_tread_run + nosing_overlap, tread_depth)) + return tread_offset, tread_verts + return default_tread_offset, default_tread_verts # each tread is a separate shape cur_offset = V_(0, 0) + tread_index = 0 for i in range(number_of_risers): tread_offset, tread_verts = get_tread_data(i) - cur_trade_shape = [v + cur_offset + nosing_overlap_offset for v in tread_verts] - vertices.extend(cur_trade_shape) + + # Skip adding vertices/edges for zero-width treads + if tread_verts: + cur_trade_shape = [v + cur_offset + nosing_overlap_offset for v in tread_verts] + vertices.extend(cur_trade_shape) - cur_vertex = i * 4 - verts_to_add = ( - (cur_vertex, cur_vertex + 1), - (cur_vertex + 1, cur_vertex + 2), - (cur_vertex + 2, cur_vertex + 3), - (cur_vertex + 3, cur_vertex), - ) - edges.extend(verts_to_add) + cur_vertex = tread_index * 4 + verts_to_add = ( + (cur_vertex, cur_vertex + 1), + (cur_vertex + 1, cur_vertex + 2), + (cur_vertex + 2, cur_vertex + 3), + (cur_vertex + 3, cur_vertex), + ) + edges.extend(verts_to_add) + tread_index += 1 + cur_offset += tread_offset elif stair_type == "GENERIC": diff --git a/src/bonsai/test/tool/test_model.py b/src/bonsai/test/tool/test_model.py index 35d5c6bc57..04fdd881a6 100644 --- a/src/bonsai/test/tool/test_model.py +++ b/src/bonsai/test/tool/test_model.py @@ -176,6 +176,27 @@ class TestStairCalculatedParams(NewFile): calculated_data["Length"] += -0.2 + 0.1 self.compare_data(pset_data, calculated_data) + # zero-width first tread + pset_data = pset_data_base.copy() + calculated_data = calculated_data_base.copy() + pset_data["custom_first_last_tread_run"] = (0.0, 0.0) + calculated_data["Length"] = 0.9 # Only 3 treads at 0.3 each + self.compare_data(pset_data, calculated_data) + + # zero-width last tread + pset_data = pset_data_base.copy() + calculated_data = calculated_data_base.copy() + pset_data["custom_first_last_tread_run"] = (0.3, 0.0) + calculated_data["Length"] = 0.9 # Only 3 treads at 0.3 each + self.compare_data(pset_data, calculated_data) + + # both first and last treads zero-width + pset_data = pset_data_base.copy() + calculated_data = calculated_data_base.copy() + pset_data["custom_first_last_tread_run"] = (0.0, 0.0) + calculated_data["Length"] = 0.6 # Only 2 middle treads at 0.3 each + self.compare_data(pset_data, calculated_data) + # overlap affects stair length only by first tread pset_data = pset_data_base.copy() calculated_data = calculated_data_base.copy() @@ -296,6 +317,94 @@ class TestGenerateStair2DProfile(NewFile): generated_profile = subject.generate_stair_2d_profile(**kwargs) self.compare_data(generated_profile, expected_profile) + def test_create_concrete_stair_zero_width_first_tread(self): + """Test concrete stair with zero-width first tread""" + kwargs = { + "base_slab_depth": 0.25, + "has_top_nib": False, + "height": 1.0, + "number_of_treads": 3, + "stair_type": "CONCRETE", + "top_slab_depth": 0.25, + "tread_depth": 0.25, + "tread_run": 0.3, + "width": 1.2, + "custom_first_last_tread_run": (0.0, 0.0), + } + verts_data = ( + V(0.0, 0, 0.0), + # First tread skipped - goes straight to second tread + V(0.0, 0, 0.5), + V(0.3, 0, 0.5), + V(0.3, 0, 0.75), + V(0.6, 0, 0.75), + V(0.6, 0, 1.0), + V(0.9, 0, 1.0), + V(0.9, 0, 0.67457), + V(0.0, 0, -0.25), + ) + edges_data = ( + (0, 1), + (1, 2), + (2, 3), + (3, 4), + (4, 5), + (5, 6), + (6, 7), + (8, 0), + (7, 8), + ) + edges_data = [e[::-1] for e in edges_data] + faces_data = () + expected_profile = (verts_data, edges_data, faces_data) + generated_profile = subject.generate_stair_2d_profile(**kwargs) + self.compare_data(generated_profile, expected_profile) + + def test_create_concrete_stair_zero_width_last_tread(self): + """Test concrete stair with zero-width last tread""" + kwargs = { + "base_slab_depth": 0.25, + "has_top_nib": False, + "height": 1.0, + "number_of_treads": 3, + "stair_type": "CONCRETE", + "top_slab_depth": 0.25, + "tread_depth": 0.25, + "tread_run": 0.3, + "width": 1.2, + "custom_first_last_tread_run": (0.0, 0.0), + } + verts_data = ( + V(0.0, 0, 0.0), + V(0.0, 0, 0.25), + V(0.3, 0, 0.25), + V(0.3, 0, 0.5), + V(0.6, 0, 0.5), + V(0.6, 0, 0.75), + V(0.9, 0, 0.75), + # Last tread skipped + V(0.9, 0, 0.67457), + V(0.1, 0, -0.25), + V(0.0, 0, -0.25), + ) + edges_data = ( + (0, 1), + (1, 2), + (2, 3), + (3, 4), + (4, 5), + (5, 6), + (6, 7), + (9, 0), + (8, 9), + (7, 8), + ) + edges_data = [e[::-1] for e in edges_data] + faces_data = () + expected_profile = (verts_data, edges_data, faces_data) + generated_profile = subject.generate_stair_2d_profile(**kwargs) + self.compare_data(generated_profile, expected_profile) + def test_create_wood_steel_stair(self): kwargs = { "height": 1.0, @@ -348,6 +457,100 @@ class TestGenerateStair2DProfile(NewFile): generated_profile = subject.generate_stair_2d_profile(**kwargs) self.compare_data(generated_profile, expected_profile) + def test_create_wood_steel_stair_zero_width_first_tread(self): + """Test wood/steel stair with zero-width first tread""" + kwargs = { + "height": 1.0, + "number_of_treads": 3, + "stair_type": "WOOD/STEEL", + "tread_depth": 0.25, + "tread_run": 0.3, + "width": 1.2, + "custom_first_last_tread_run": (0.0, 0.0), + } + verts_data = ( + # First tread skipped - start at second tread + V(0.0, 0, 0.25), + V(0.3, 0, 0.25), + V(0.3, 0, 0.5), + V(0.0, 0, 0.5), + V(0.3, 0, 0.5), + V(0.6, 0, 0.5), + V(0.6, 0, 0.75), + V(0.3, 0, 0.75), + V(0.6, 0, 0.75), + V(0.9, 0, 0.75), + V(0.9, 0, 1.0), + V(0.6, 0, 1.0), + ) + edges_data = ( + (0, 1), + (1, 2), + (2, 3), + (3, 0), + (4, 5), + (5, 6), + (6, 7), + (7, 4), + (8, 9), + (9, 10), + (10, 11), + (11, 8), + ) + + faces_data = () + + expected_profile = (verts_data, edges_data, faces_data) + generated_profile = subject.generate_stair_2d_profile(**kwargs) + self.compare_data(generated_profile, expected_profile) + + def test_create_wood_steel_stair_zero_width_last_tread(self): + """Test wood/steel stair with zero-width last tread""" + kwargs = { + "height": 1.0, + "number_of_treads": 3, + "stair_type": "WOOD/STEEL", + "tread_depth": 0.25, + "tread_run": 0.3, + "width": 1.2, + "custom_first_last_tread_run": (0.0, 0.0), + } + verts_data = ( + V(0.0, 0, 0.0), + V(0.3, 0, 0.0), + V(0.3, 0, 0.25), + V(0.0, 0, 0.25), + V(0.3, 0, 0.25), + V(0.6, 0, 0.25), + V(0.6, 0, 0.5), + V(0.3, 0, 0.5), + V(0.6, 0, 0.5), + V(0.9, 0, 0.5), + V(0.9, 0, 0.75), + V(0.6, 0, 0.75), + # Last tread skipped + ) + edges_data = ( + (0, 1), + (1, 2), + (2, 3), + (3, 0), + (4, 5), + (5, 6), + (6, 7), + (7, 4), + (8, 9), + (9, 10), + (10, 11), + (11, 8), + ) + + faces_data = () + + expected_profile = (verts_data, edges_data, faces_data) + generated_profile = subject.generate_stair_2d_profile(**kwargs) + self.compare_data(generated_profile, expected_profile) + def test_create_generic_stair(self): kwargs = {"height": 1.0, "number_of_treads": 3, "stair_type": "GENERIC", "tread_run": 0.3, "width": 1.2} verts_data = ( @@ -381,6 +584,41 @@ class TestGenerateStair2DProfile(NewFile): generated_profile = subject.generate_stair_2d_profile(**kwargs) self.compare_data(generated_profile, expected_profile) + def test_create_generic_stair_zero_width_treads(self): + """Test generic stair with zero-width first and last treads""" + kwargs = { + "height": 1.0, + "number_of_treads": 3, + "stair_type": "GENERIC", + "tread_run": 0.3, + "width": 1.2, + "custom_first_last_tread_run": (0.0, 0.0), + } + verts_data = ( + V(0.0, 0, 0.0), + # First tread skipped + V(0.0, 0, 0.5), + V(0.3, 0, 0.5), + V(0.3, 0, 0.75), + V(0.6, 0, 0.75), + # Last tread skipped + V(0.6, 0, 0.0), + ) + edges_data = ( + (0, 1), + (1, 2), + (2, 3), + (3, 4), + (4, 5), + (5, 0), + ) + edges_data = [e[::-1] for e in edges_data] + + faces_data = () + expected_profile = (verts_data, edges_data, faces_data) + generated_profile = subject.generate_stair_2d_profile(**kwargs) + self.compare_data(generated_profile, expected_profile) + class TestUsingArrays(NewFile): def setup_array(self, add_second_layer=False, sync_children=False): From ead849625dbbd95af1759d4dc55bce5a6201460f Mon Sep 17 00:00:00 2001 From: Ryan Schultz Date: Thu, 16 Oct 2025 21:10:43 -0500 Subject: [PATCH 31/34] Automatically select annotations after creation to make it easier to move them in bulk. --- src/bonsai/bonsai/bim/module/drawing/workspace.py | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/src/bonsai/bonsai/bim/module/drawing/workspace.py b/src/bonsai/bonsai/bim/module/drawing/workspace.py index bf2f026358..901c67342c 100644 --- a/src/bonsai/bonsai/bim/module/drawing/workspace.py +++ b/src/bonsai/bonsai/bim/module/drawing/workspace.py @@ -299,6 +299,8 @@ class Hotkey(bpy.types.Operator, tool.Ifc.Operator): return related_objects = bpy.context.selected_objects + created_objects = [] + for related_object in related_objects: obj = core.add_annotation( tool.Ifc, @@ -312,6 +314,14 @@ class Hotkey(bpy.types.Operator, tool.Ifc.Operator): enable_editing=False, ) tool.Drawing.setup_annotation_object(obj, object_type, related_object) + created_objects.append(obj) + + # Select the created annotation objects + bpy.ops.object.select_all(action='DESELECT') + for obj in created_objects: + obj.select_set(True) + if created_objects: + bpy.context.view_layer.objects.active = created_objects[-1] def hotkey_S_A(self): if bpy.ops.bim.add_annotation.poll(): From 5885924bb7d3a2a9dfc25a33a5281271127eac4f Mon Sep 17 00:00:00 2001 From: Ryan Schultz Date: Sat, 18 Oct 2025 12:47:35 -0500 Subject: [PATCH 32/34] Fix #5709: The RCP will not be denied! :) --- src/bonsai/bonsai/bim/export_ifc.py | 11 ++++++++++- src/bonsai/bonsai/bim/module/drawing/operator.py | 13 +++++++++++-- src/bonsai/bonsai/bim/module/geometry/__init__.py | 6 ++++-- 3 files changed, 25 insertions(+), 5 deletions(-) diff --git a/src/bonsai/bonsai/bim/export_ifc.py b/src/bonsai/bonsai/bim/export_ifc.py index 5fe5a1df97..d920bda9a0 100644 --- a/src/bonsai/bonsai/bim/export_ifc.py +++ b/src/bonsai/bonsai/bim/export_ifc.py @@ -104,7 +104,16 @@ class IfcExporter: def sync_object_placement(self, obj: bpy.types.Object) -> Union[ifcopenshell.entity_instance, None]: element = self.file.by_id(tool.Blender.get_object_bim_props(obj).ifc_definition_id) - if tool.Geometry.is_scaled(obj): + # Handle camera scales specially + if obj.type == "CAMERA": + # Check if this is a reflected ceiling plan camera + camera = tool.Ifc.get_entity(obj) + if ifcopenshell.util.element.get_pset(camera, "EPset_Drawing", "TargetView") == "REFLECTED_PLAN_VIEW": + # Ensure reflected ceiling cameras have the correct scale + if obj.scale != (-1, -1, -1): + obj.scale = (-1, -1, -1) + # Skip all other scale handling for cameras + elif tool.Geometry.is_scaled(obj): bpy.ops.bim.update_representation(obj=obj.name) # update_representation might not apply scale if the object has openings # reset it, so let user know that the scale wasn't saved. diff --git a/src/bonsai/bonsai/bim/module/drawing/operator.py b/src/bonsai/bonsai/bim/module/drawing/operator.py index cfb8253e82..52598ac6b1 100644 --- a/src/bonsai/bonsai/bim/module/drawing/operator.py +++ b/src/bonsai/bonsai/bim/module/drawing/operator.py @@ -2339,10 +2339,19 @@ class ActivateDrawingBase(tool.Ifc.Operator): camera = context.scene.camera assert camera camera_props = tool.Drawing.get_camera_props(camera) + # Check if this is a reflected ceiling camera and preserve its scale + camera_element = tool.Ifc.get_entity(camera) + is_reflected = False + if camera_element: + is_reflected = ifcopenshell.util.element.get_pset(camera_element, "EPset_Drawing", "TargetView") == "REFLECTED_PLAN_VIEW" + if is_reflected and camera.scale != (-1, -1, -1): + camera.scale = (-1, -1, -1) + if camera_props.update_representation(camera.matrix_world): bpy.ops.bim.update_representation(obj=camera.name, ifc_representation_class="") - # See 6452 and 6478. - # bpy.ops.bim.refresh_clipping_planes("INVOKE_DEFAULT") + # Restore the scale after update if needed + if is_reflected: + camera.scale = (-1, -1, -1) return {"FINISHED"} diff --git a/src/bonsai/bonsai/bim/module/geometry/__init__.py b/src/bonsai/bonsai/bim/module/geometry/__init__.py index bba979af30..385df0c749 100644 --- a/src/bonsai/bonsai/bim/module/geometry/__init__.py +++ b/src/bonsai/bonsai/bim/module/geometry/__init__.py @@ -109,8 +109,10 @@ def block_scale(scene: bpy.types.Scene) -> None: if obj.type == "CAMERA": camera = tool.Ifc.get_entity(obj) if ifcopenshell.util.element.get_pset(camera, "EPset_Drawing", "TargetView") == "REFLECTED_PLAN_VIEW": - obj.scale = (-1, -1, -1) - obj.rotation_euler = (0.0, 0.0, math.radians(180)) + # Only update if scale isn't already (-1, -1, -1) + if obj.scale != (-1, -1, -1): + obj.scale = (-1, -1, -1) + obj.rotation_euler = (0.0, 0.0, math.radians(180)) else: if obj.scale != (1, 1, 1): obj.scale = (1, 1, 1) From 486651e0b7486a1d1177901a302a64f06c093c1e Mon Sep 17 00:00:00 2001 From: Ryan Schultz Date: Sat, 18 Oct 2025 13:43:34 -0500 Subject: [PATCH 33/34] additional tweak to the preceding commit. --- src/bonsai/bonsai/bim/export_ifc.py | 2 ++ src/bonsai/bonsai/bim/module/drawing/operator.py | 2 ++ 2 files changed, 4 insertions(+) diff --git a/src/bonsai/bonsai/bim/export_ifc.py b/src/bonsai/bonsai/bim/export_ifc.py index d920bda9a0..4f9692b49c 100644 --- a/src/bonsai/bonsai/bim/export_ifc.py +++ b/src/bonsai/bonsai/bim/export_ifc.py @@ -36,6 +36,7 @@ from bonsai.bim.ifc import IfcStore from mathutils import Vector from typing import Union from logging import Logger +from math import radians class IfcExporter: @@ -112,6 +113,7 @@ class IfcExporter: # Ensure reflected ceiling cameras have the correct scale if obj.scale != (-1, -1, -1): obj.scale = (-1, -1, -1) + obj.rotation_euler = (0.0, 0.0, radians(180)) # Skip all other scale handling for cameras elif tool.Geometry.is_scaled(obj): bpy.ops.bim.update_representation(obj=obj.name) diff --git a/src/bonsai/bonsai/bim/module/drawing/operator.py b/src/bonsai/bonsai/bim/module/drawing/operator.py index 52598ac6b1..4bc3067de8 100644 --- a/src/bonsai/bonsai/bim/module/drawing/operator.py +++ b/src/bonsai/bonsai/bim/module/drawing/operator.py @@ -2346,12 +2346,14 @@ class ActivateDrawingBase(tool.Ifc.Operator): is_reflected = ifcopenshell.util.element.get_pset(camera_element, "EPset_Drawing", "TargetView") == "REFLECTED_PLAN_VIEW" if is_reflected and camera.scale != (-1, -1, -1): camera.scale = (-1, -1, -1) + camera.rotation_euler = (0.0, 0.0, radians(180)) if camera_props.update_representation(camera.matrix_world): bpy.ops.bim.update_representation(obj=camera.name, ifc_representation_class="") # Restore the scale after update if needed if is_reflected: camera.scale = (-1, -1, -1) + camera.rotation_euler = (0.0, 0.0, radians(180)) return {"FINISHED"} From f05b0d946d72a9abfde69bcd8ed08f80a33de149 Mon Sep 17 00:00:00 2001 From: Thomas Krijnen Date: Sun, 19 Oct 2025 13:20:58 +0200 Subject: [PATCH 34/34] if -> ifdef #7174 --- src/ifcparse/IfcEntityInstanceData.h | 2 +- src/ifcparse/rocksdb_map_adapter.h | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/src/ifcparse/IfcEntityInstanceData.h b/src/ifcparse/IfcEntityInstanceData.h index bf1f4789f6..dc97e7f0c8 100644 --- a/src/ifcparse/IfcEntityInstanceData.h +++ b/src/ifcparse/IfcEntityInstanceData.h @@ -165,7 +165,7 @@ namespace IfcParse { } } -#if IFOPSH_WITH_ROCKSDB +#ifdef IFOPSH_WITH_ROCKSDB namespace impl { diff --git a/src/ifcparse/rocksdb_map_adapter.h b/src/ifcparse/rocksdb_map_adapter.h index a6254356c6..da2346789f 100644 --- a/src/ifcparse/rocksdb_map_adapter.h +++ b/src/ifcparse/rocksdb_map_adapter.h @@ -274,7 +274,7 @@ public: } iterator& operator++() { -#if IFOPSH_WITH_ROCKSDB +#ifdef IFOPSH_WITH_ROCKSDB if (it_) { it_->Next(); check_valid();