From 99b162f712bdb7d387d38624c574643bcf30c6fc Mon Sep 17 00:00:00 2001 From: Ryan Schultz Date: Wed, 31 Dec 2025 07:43:34 -0600 Subject: [PATCH 01/49] Fix #7519: Hide depth attribute in Item Mode for elements with material layer sets When editing representation items for elements with IfcMaterialLayerSetUsage (LAYER2/LAYER3), the depth attribute is now hidden from the UI as it should not be modified at the item level for these parametric elements. The check is performed by accessing the parent element through the representation_obj property in geometry props and checking its material usage type. --- src/bonsai/bonsai/bim/module/model/workspace.py | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/src/bonsai/bonsai/bim/module/model/workspace.py b/src/bonsai/bonsai/bim/module/model/workspace.py index 30acee46e3..08ac200359 100644 --- a/src/bonsai/bonsai/bim/module/model/workspace.py +++ b/src/bonsai/bonsai/bim/module/model/workspace.py @@ -399,6 +399,16 @@ class EditItemUI: assert obj mesh_props = tool.Geometry.get_mesh_props(obj.data) + + # Get the parent element from representation_obj to check for layer set usage + has_layer_set_usage = False + props = tool.Geometry.get_geometry_props() + if props.representation_obj: + parent_element = tool.Ifc.get_entity(props.representation_obj) + if parent_element: + material_usage = tool.Model.get_usage_type(parent_element) + has_layer_set_usage = material_usage in ("LAYER2", "LAYER3") + if AuthoringData.data["is_representation_item_swept_solid"]: # TODO: support EndSweptArea for IfcRevolvedAreaSolidTapered, # will need to add second attribute for this. @@ -412,8 +422,12 @@ class EditItemUI: op.profile_id = int(mesh_props.item_profile) for item_attribute in mesh_props.item_attributes: + # Skip depth attribute for objects with layer set usage + if has_layer_set_usage and item_attribute.name.lower() == "depth": + continue row = cls.layout.row() draw_attribute(item_attribute, cls.layout) + if len(mesh_props.item_attributes) or AuthoringData.data["is_representation_item_swept_solid"]: row = cls.layout.row() row.operator("bim.update_item_attributes", icon="FILE_REFRESH", text="") From 93de429e03312435f94f80b75847b4ddd6f3a8ff Mon Sep 17 00:00:00 2001 From: Ryan Schultz Date: Wed, 31 Dec 2025 07:52:19 -0600 Subject: [PATCH 02/49] Previous commit should only apply to LAYER3 objects, since extrusion depth still works for LAYER2 objects. --- src/bonsai/bonsai/bim/module/model/workspace.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/bonsai/bonsai/bim/module/model/workspace.py b/src/bonsai/bonsai/bim/module/model/workspace.py index 08ac200359..be02f3f4c8 100644 --- a/src/bonsai/bonsai/bim/module/model/workspace.py +++ b/src/bonsai/bonsai/bim/module/model/workspace.py @@ -407,7 +407,7 @@ class EditItemUI: parent_element = tool.Ifc.get_entity(props.representation_obj) if parent_element: material_usage = tool.Model.get_usage_type(parent_element) - has_layer_set_usage = material_usage in ("LAYER2", "LAYER3") + has_layer_set_usage = material_usage == "LAYER3" if AuthoringData.data["is_representation_item_swept_solid"]: # TODO: support EndSweptArea for IfcRevolvedAreaSolidTapered, @@ -422,7 +422,7 @@ class EditItemUI: op.profile_id = int(mesh_props.item_profile) for item_attribute in mesh_props.item_attributes: - # Skip depth attribute for objects with layer set usage + # Skip depth attribute for LAYER3 objects with layer set usage if has_layer_set_usage and item_attribute.name.lower() == "depth": continue row = cls.layout.row() From 9bac66a01baae521338e0b6845f6ce63aebad854 Mon Sep 17 00:00:00 2001 From: Bruno Postle Date: Sat, 3 Jan 2026 17:53:59 +0000 Subject: [PATCH 03/49] Fix for python 3.14 "In some versions of Python, instances of classes may have an __annotations__ attribute. However, this is not supported functionality. If you need the annotations of an instance, you can use type() to access its class" https://docs.python.org/3/howto/annotations.html --- src/bonsai/bonsai/bim/helper.py | 6 +++++- src/bonsai/bonsai/tool/blender.py | 6 +++++- 2 files changed, 10 insertions(+), 2 deletions(-) diff --git a/src/bonsai/bonsai/bim/helper.py b/src/bonsai/bonsai/bim/helper.py index 3b3dd502e4..e76b512de3 100644 --- a/src/bonsai/bonsai/bim/helper.py +++ b/src/bonsai/bonsai/bim/helper.py @@ -428,7 +428,11 @@ def get_enum_items( else: annotations_data = data - prop = annotations_data.__annotations__[prop_name] + try: + annotations = annotations_data.__annotations__ + except AttributeError: + annotations = type(annotations_data).__annotations__ + prop = annotations[prop_name] items = prop.keywords.get("items") if items is None: return diff --git a/src/bonsai/bonsai/tool/blender.py b/src/bonsai/bonsai/tool/blender.py index 97b161a11b..09bfcf29cc 100644 --- a/src/bonsai/bonsai/tool/blender.py +++ b/src/bonsai/bonsai/tool/blender.py @@ -742,7 +742,11 @@ class Blender(bonsai.core.tool.Blender): # Yes, accessing items through annotations is a bit hacky # but it's the only way to get the dynamic enum items # besides providing them to get_enum_safe explicitly. - prop_keywords = props.__annotations__[prop_name].keywords + try: + annotations = props.__annotations__ + except AttributeError: + annotations = type(props).__annotations__ + prop_keywords = annotations[prop_name].keywords items = prop_keywords.get("items") if items is None: return None From c20ed44a0f8dc668d2a7bc64c144c0ba64c3ca55 Mon Sep 17 00:00:00 2001 From: Bruno Postle Date: Sat, 3 Jan 2026 21:58:05 +0000 Subject: [PATCH 04/49] More python 3.14 fixes, see 9bac66a --- src/bonsai/bonsai/bim/ui.py | 18 +++++++++++++++--- src/bonsai/bonsai/tool/snap.py | 12 ++++++++++-- src/bonsai/test/bim/test_feature.py | 12 ++++++++++-- 3 files changed, 35 insertions(+), 7 deletions(-) diff --git a/src/bonsai/bonsai/bim/ui.py b/src/bonsai/bonsai/bim/ui.py index e9ab0b446c..82d387e53d 100644 --- a/src/bonsai/bonsai/bim/ui.py +++ b/src/bonsai/bonsai/bim/ui.py @@ -837,7 +837,11 @@ class BIM_ADDON_preferences(bpy.types.AddonPreferences): gizmo_prop_names = {p.attr_name for p in GizmoDoorEdition.dimension_gizmo_props} # Add special gizmos not in dimension_gizmo_props gizmo_prop_names.update(("swing_arc", "flip_arc")) - for prop in door_gizmos.__annotations__: + try: + annotations = door_gizmos.__annotations__ + except AttributeError: + annotations = type(door_gizmos).__annotations__ + for prop in annotations: if prop in gizmo_prop_names: layout.prop(door_gizmos, prop) @@ -846,7 +850,11 @@ class BIM_ADDON_preferences(bpy.types.AddonPreferences): window_gizmos = self.gizmos.window gizmo_prop_names = {p.attr_name for p in GizmoWindowEdition.dimension_gizmo_props} - for prop in window_gizmos.__annotations__: + try: + annotations = window_gizmos.__annotations__ + except AttributeError: + annotations = type(window_gizmos).__annotations__ + for prop in annotations: if prop in gizmo_prop_names: layout.prop(window_gizmos, prop) @@ -857,7 +865,11 @@ class BIM_ADDON_preferences(bpy.types.AddonPreferences): gizmo_prop_names = {p.attr_name for p in GizmoStairEdition.dimension_gizmo_props} # Add special gizmos not in dimension_gizmo_props special_gizmo_names = {"lock", "plus", "minus", "cycle"} - for prop in stair_gizmos.__annotations__: + try: + annotations = stair_gizmos.__annotations__ + except AttributeError: + annotations = type(stair_gizmos).__annotations__ + for prop in annotations: if prop in gizmo_prop_names or prop in special_gizmo_names: layout.prop(stair_gizmos, prop) diff --git a/src/bonsai/bonsai/tool/snap.py b/src/bonsai/bonsai/tool/snap.py index 04ec6c538d..0e6fe06f9e 100644 --- a/src/bonsai/bonsai/tool/snap.py +++ b/src/bonsai/bonsai/tool/snap.py @@ -553,7 +553,11 @@ class Snap(bonsai.core.tool.Snap): def filter_snapping_points_by_type(snapping_points): options = ["Plane", "Axis"] props = tool.Snap.get_snap_props() - for prop in props.__annotations__.keys(): + try: + annotations = props.__annotations__ + except AttributeError: + annotations = type(props).__annotations__ + for prop in annotations.keys(): if getattr(props, prop): options.append(props.rna_type.properties[prop].name) @@ -563,7 +567,11 @@ class Snap(bonsai.core.tool.Snap): def filter_snapping_points_by_group(detected_snaps): options = ["Wireframe", "Axis", "Plane"] props = tool.Snap.get_snap_groups() - for prop in props.__annotations__.keys(): + try: + annotations = props.__annotations__ + except AttributeError: + annotations = type(props).__annotations__ + for prop in annotations.keys(): if getattr(props, prop): options.append(props.rna_type.properties[prop].name) filtered_groups = [group for group in detected_snaps if group["group"] in options] diff --git a/src/bonsai/test/bim/test_feature.py b/src/bonsai/test/bim/test_feature.py index fa5ccb36d0..64d7e7cb27 100644 --- a/src/bonsai/test/bim/test_feature.py +++ b/src/bonsai/test/bim/test_feature.py @@ -96,7 +96,11 @@ class PanelSpy: def __getattr__(self, attr: str) -> PanelSpy | Any: self.spied_attr = attr - if annotation := self.blender_panel.__annotations__.get(attr, None): + try: + annotations = self.blender_panel.__annotations__ + except AttributeError: + annotations = type(self.blender_panel).__annotations__ + if annotation := annotations.get(attr, None): return annotation.keywords.get("default", None) # An operator property if attr == "layout": return self @@ -136,7 +140,11 @@ class PanelSpy: prop_type = props.bl_rna.properties[name].type enum_items = [] if prop_type == "ENUM": - prop_keywords = props.__annotations__[name].keywords + try: + annotations = props.__annotations__ + except AttributeError: + annotations = type(props).__annotations__ + prop_keywords = annotations[name].keywords items = prop_keywords.get("items") if items is not None: if isinstance(items, (list, tuple)): From 56861b9403c7b63d005feb3fe9bbd980c8d0e3cf Mon Sep 17 00:00:00 2001 From: Ryan Schultz Date: Sat, 3 Jan 2026 16:04:58 -0600 Subject: [PATCH 05/49] fix #7153: Include IfcSpatialElementTypes in `purge_unused_objects(object_type='TYPE')` --- src/bonsai/bonsai/tool/type.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/bonsai/bonsai/tool/type.py b/src/bonsai/bonsai/tool/type.py index dab53ee14f..6c98e8bb59 100644 --- a/src/bonsai/bonsai/tool/type.py +++ b/src/bonsai/bonsai/tool/type.py @@ -74,7 +74,7 @@ class Type(bonsai.core.tool.Type): def get_model_types(cls) -> list[ifcopenshell.entity_instance]: ifc_file = tool.Ifc.get() types = ifc_file.by_type("IfcElementType") - # exclude IfcSpatialElementType + types += ifc_file.by_type("IfcSpatialElementType") types += ifc_file.by_type("IfcTypeProduct", include_subtypes=False) if not tool.Ifc.get_schema().startswith("IFC4X3"): types += ifc_file.by_type("IfcWindowStyle") From 4874f7770b9c0ffa30022855cc611fe6f3c7d9fd Mon Sep 17 00:00:00 2001 From: Ryan Schultz Date: Sat, 3 Jan 2026 16:52:10 -0600 Subject: [PATCH 06/49] Closes #3764: When switching an instance to a different beam type, preserve the CardinalPoint. --- src/bonsai/bonsai/core/type.py | 23 ++++++++++++++++++++++- 1 file changed, 22 insertions(+), 1 deletion(-) diff --git a/src/bonsai/bonsai/core/type.py b/src/bonsai/bonsai/core/type.py index 226f00c461..7926c53ff1 100644 --- a/src/bonsai/bonsai/core/type.py +++ b/src/bonsai/bonsai/core/type.py @@ -19,6 +19,7 @@ from __future__ import annotations import bonsai.core.geometry from typing import TYPE_CHECKING, Optional +import ifcopenshell.util.element if TYPE_CHECKING: import bpy @@ -32,14 +33,34 @@ def assign_type( element: ifcopenshell.entity_instance, type: ifcopenshell.entity_instance, ) -> None: + + + # Get the instance's current CardinalPoint before type assignment + instance_cardinal_point = None + instance_material = ifcopenshell.util.element.get_material(element) + if instance_material and instance_material.is_a("IfcMaterialProfileSetUsage"): + instance_cardinal_point = instance_material.CardinalPoint + ifc.run("type.assign_type", related_objects=[element], relating_type=type) obj = ifc.get_object(element) + if type_tool.has_material_usage(element): - pass # for now, representation regeneration handled by API listeners + # Restore the instance's CardinalPoint to the new material usage + if instance_cardinal_point is not None: + new_instance_material = ifcopenshell.util.element.get_material(element) + if new_instance_material and new_instance_material.is_a("IfcMaterialProfileSetUsage"): + if new_instance_material.CardinalPoint != instance_cardinal_point: + new_instance_material.CardinalPoint = instance_cardinal_point + + # Force representation regeneration + from bonsai.bim.module.model.profile import DumbProfileRecalculator + DumbProfileRecalculator().recalculate([obj]) + # for now, representation regeneration handled by API listeners else: type_data = type_tool.get_object_data(ifc.get_object(type)) if type_data: type_tool.change_object_data(obj, type_data, is_global=False) + type_tool.disable_editing(obj) From cb081910833758e7d3c224ee2ac7bf094fc97309 Mon Sep 17 00:00:00 2001 From: Ryan Schultz Date: Sat, 3 Jan 2026 18:10:21 -0600 Subject: [PATCH 07/49] close #3451: support comma-separated stylesheet paths Enable multiple CSS files in stylesheet_path using comma separation. Files are loaded in order with natural CSS cascading behavior. Example: "base.css, overrides.css" --- .../bonsai/bim/module/drawing/svgwriter.py | 16 +++++++++------- 1 file changed, 9 insertions(+), 7 deletions(-) diff --git a/src/bonsai/bonsai/bim/module/drawing/svgwriter.py b/src/bonsai/bonsai/bim/module/drawing/svgwriter.py index 2d8c4e60cf..d7ddd0b9f7 100644 --- a/src/bonsai/bonsai/bim/module/drawing/svgwriter.py +++ b/src/bonsai/bonsai/bim/module/drawing/svgwriter.py @@ -257,14 +257,16 @@ class SvgWriter: self.height = self.raw_height * self.svg_scale def add_stylesheet(self): - path = self.resource_paths["Stylesheet"] - if not path: + paths = self.resource_paths["Stylesheet"] + if not paths: return - if not os.path.exists(path): - print(f"WARNING. Couldn't find stylesheet for the drawing by the path: {path}") - return - with open(path, "r") as stylesheet: - self.svg.defs.add(self.svg.style(stylesheet.read())) + path_list = [p.strip() for p in paths.split(',')] + for path in path_list: + if not os.path.exists(path): + print(f"WARNING. Couldn't find stylesheet for the drawing by the path: {path}") + continue + with open(path, "r") as stylesheet: + self.svg.defs.add(self.svg.style(stylesheet.read())) def add_markers(self): path = self.resource_paths["Markers"] From db623f4e734217ba6dbe0e5f802d1da91580197f Mon Sep 17 00:00:00 2001 From: Bruno Postle Date: Sun, 4 Jan 2026 10:12:16 +0000 Subject: [PATCH 08/49] api.project.append_asset Material Layer orphan fix Fixes typo introduced in b4740b6 where `element in MATERIAL_SETS` should have been `element.is_a() in MATERIAL_SETS`. This resulted in deduplication of layersets, but not of the layers themselves. --- .../ifcopenshell/api/project/append_asset.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/ifcopenshell-python/ifcopenshell/api/project/append_asset.py b/src/ifcopenshell-python/ifcopenshell/api/project/append_asset.py index c3f55d6795..630e346130 100644 --- a/src/ifcopenshell-python/ifcopenshell/api/project/append_asset.py +++ b/src/ifcopenshell-python/ifcopenshell/api/project/append_asset.py @@ -264,7 +264,7 @@ class Usecase: name = element.Name return next((e for e in self.file.by_type("IfcMaterial") if e.Name == name), None) - elif element in MATERIAL_SETS: + elif element.is_a() in MATERIAL_SETS: ifc_class = element.is_a() name_attr = "LayerSetName" if ifc_class == "IfcMaterialLayerSet" else "Name" material_set_name = getattr(element, name_attr) From a17b0604e1a9653a6af25236eb88c6c80ff7dfed Mon Sep 17 00:00:00 2001 From: Bruno Postle Date: Sun, 4 Jan 2026 11:36:05 +0000 Subject: [PATCH 09/49] api.project.append_asset deduplicate material sets When appending a wall type and a slab type in turn, if their material layer sets have the same name then the slab type would have a wall construction. Now the material sets are compared before reusing an existing material set. --- .../ifcopenshell/api/project/append_asset.py | 70 +++++++++++++++++-- 1 file changed, 63 insertions(+), 7 deletions(-) diff --git a/src/ifcopenshell-python/ifcopenshell/api/project/append_asset.py b/src/ifcopenshell-python/ifcopenshell/api/project/append_asset.py index 630e346130..893b32cc02 100644 --- a/src/ifcopenshell-python/ifcopenshell/api/project/append_asset.py +++ b/src/ifcopenshell-python/ifcopenshell/api/project/append_asset.py @@ -243,6 +243,59 @@ class Usecase: except RuntimeError: return None + def material_sets_are_equal(self, set1: ifcopenshell.entity_instance, set2: ifcopenshell.entity_instance) -> bool: + """Check if two material sets are structurally equivalent.""" + if set1.is_a() != set2.is_a(): + return False + + ifc_class = set1.is_a() + + if ifc_class == "IfcMaterialLayerSet": + layers1 = set1.MaterialLayers or [] + layers2 = set2.MaterialLayers or [] + if len(layers1) != len(layers2): + return False + for l1, l2 in zip(layers1, layers2): + if (l1.Material is None) != (l2.Material is None): + return False + if l1.Material and l1.Material.Name != l2.Material.Name: + return False + if l1.LayerThickness != l2.LayerThickness: + return False + + elif ifc_class == "IfcMaterialConstituentSet": + constituents1 = set1.MaterialConstituents or [] + constituents2 = set2.MaterialConstituents or [] + if len(constituents1) != len(constituents2): + return False + for c1, c2 in zip(constituents1, constituents2): + if (c1.Material is None) != (c2.Material is None): + return False + if c1.Material and c1.Material.Name != c2.Material.Name: + return False + if c1.Name != c2.Name: + return False + + elif ifc_class == "IfcMaterialProfileSet": + profiles1 = set1.MaterialProfiles or [] + profiles2 = set2.MaterialProfiles or [] + if len(profiles1) != len(profiles2): + return False + for p1, p2 in zip(profiles1, profiles2): + if (p1.Material is None) != (p2.Material is None): + return False + if p1.Material and p1.Material.Name != p2.Material.Name: + return False + if (p1.Profile is None) != (p2.Profile is None): + return False + if p1.Profile: + profile_name1 = getattr(p1.Profile, "ProfileName", None) + profile_name2 = getattr(p2.Profile, "ProfileName", None) + if profile_name1 != profile_name2: + return False + + return True + def get_existing_element(self, element: ifcopenshell.entity_instance) -> Union[ifcopenshell.entity_instance, None]: """Get existing element for a library element. @@ -270,7 +323,11 @@ class Usecase: material_set_name = getattr(element, name_attr) if material_set_name is None: return - return next((e for e in self.file.by_type(ifc_class) if getattr(e, name_attr) == material_set_name), None) + for candidate in self.file.by_type(ifc_class): + if getattr(candidate, name_attr) == material_set_name: + if self.material_sets_are_equal(element, candidate): + return candidate + return None elif element.is_a("IfcProfileDef"): profile_name = element.ProfileName @@ -665,12 +722,11 @@ class Usecase: name_attr = "LayerSetName" if ifc_class == "IfcMaterialLayerSet" else "Name" material_set_name = getattr(element, name_attr) if material_set_name is not None: - existing_material_set = next( - (e for e in ifc_file.by_type(ifc_class) if getattr(e, name_attr) == material_set_name), None - ) - if existing_material_set is not None: - reuse_identities[element_identity] = existing_material_set - return existing_material_set + for candidate in ifc_file.by_type(ifc_class): + if getattr(candidate, name_attr) == material_set_name: + if self.material_sets_are_equal(element, candidate): + reuse_identities[element_identity] = candidate + return candidate elif element.is_a("IfcPresentationStyle"): style_name = element.Name From 831c3190cc5f06dbb53b0faa8876112d28990fdf Mon Sep 17 00:00:00 2001 From: Ryan Schultz Date: Sun, 4 Jan 2026 13:15:39 -0600 Subject: [PATCH 10/49] Fix #7531: Add BBIM_MaterialLayer pset for custom offset persistence and UI improvements This commit introduces a new BBIM_MaterialLayer property set to persist custom material layer offset settings in IFC files, along with significant UI improvements for material editing. Features Added: - New BBIM_MaterialLayer pset with properties: - UseCustomOffset (bool): Toggle for custom offset - CustomOffset (float): Offset value in SI units - CustomWallReference (str): Wall reference point (EXTERIOR/CENTER/INTERIOR) - CustomSlabReference (str): Slab reference point (TOP/MIDDLE/BOTTOM) Tool Updates (tool.py): - Added save_custom_offset_to_pset(): Saves custom offset from UI props to pset - Added load_custom_offset_from_pset(): Loads custom offset from pset to UI props - Updated get_material_layer_custom_offset(): Reads from pset when props unavailable Operator Updates (operator.py): - EnableEditingAssignedMaterial: Loads custom offset from pset on edit start - EditAssignedMaterial: Saves custom offset to pset on edit completion - Fixed KeyError for CardinalPoint in material constituent sets Data Layer (data.py): - Added bbim_material_layer_pset() to ObjectMaterialData for caching pset data - Improves performance by avoiding repeated IFC queries during UI drawing UI Improvements (ui.py): - Added custom offset display in both editable and read-only material UIs - Added OffsetFromReferenceLine display in read-only UI - Implemented dynamic headers based on material type (Layers/Profiles/Constituents) - Improved visual hierarchy with consistent boxing and indentation - Aligned editable and read-only UI layouts for consistency - Fixed layer set boundary labels (Top/Bottom for slabs, Interior/Exterior for walls) - Reorganized "Add Material" section into material layers box Bug Fixes: - Fixed format_distance() to correctly handle negative imperial values (e.g., -0.5' now displays as "-0' - 6"" instead of "0' - -6"") This allows users to set custom material layer offsets that persist in the IFC file and remain available across sessions, with clear visual feedback in both editing and viewing modes. --- .../bonsai/bim/module/drawing/helper.py | 10 +- src/bonsai/bonsai/bim/module/material/data.py | 33 ++++ .../bonsai/bim/module/material/operator.py | 10 +- src/bonsai/bonsai/bim/module/material/ui.py | 182 +++++++++++++----- src/bonsai/bonsai/tool/model.py | 122 ++++++++++-- 5 files changed, 287 insertions(+), 70 deletions(-) diff --git a/src/bonsai/bonsai/bim/module/drawing/helper.py b/src/bonsai/bonsai/bim/module/drawing/helper.py index 01a78aad11..4d99d6c91b 100644 --- a/src/bonsai/bonsai/bim/module/drawing/helper.py +++ b/src/bonsai/bonsai/bim/module/drawing/helper.py @@ -313,15 +313,13 @@ def format_distance( if not feet and not add_inches: tx_dist += str(feet) + "'" - # Add "0' - " when we have inches but no feet - # But only add " - " separator if we actually have inches to show if not feet and add_inches: - tx_dist += "0' - " + if value < 0: + tx_dist += "-0' - " + else: + tx_dist += "0' - " elif feet and add_inches: tx_dist += " - " - - if not feet and value < 0: - tx_dist += "-" if add_inches: if feet == 0 and inches == 0 and not frac: # Special case: exactly zero, show "0" diff --git a/src/bonsai/bonsai/bim/module/material/data.py b/src/bonsai/bonsai/bim/module/material/data.py index efe79c1731..3df1506fdc 100644 --- a/src/bonsai/bonsai/bim/module/material/data.py +++ b/src/bonsai/bonsai/bim/module/material/data.py @@ -176,6 +176,7 @@ class ObjectMaterialData: cls.data["active_material_constituents"] = cls.active_material_constituents() # after material_name and type_material cls.data["is_type_material_overridden"] = cls.is_type_material_overridden() + cls.data["bbim_material_layer_pset"] = cls.bbim_material_layer_pset() cls.is_loaded = True @@ -426,3 +427,35 @@ class ObjectMaterialData: # so we check occurrence material explicitly occurrence_material = ifcopenshell.util.element.get_material(cls.element, should_inherit=False) return bool(occurrence_material) + + @classmethod + def bbim_material_layer_pset(cls) -> Union[dict[str, Any], None]: + """Load BBIM_MaterialLayer pset data for display in UI.""" + if not cls.element: + return None + + pset_data = ifcopenshell.util.element.get_pset(cls.element, "BBIM_MaterialLayer") + if not pset_data or not pset_data.get("UseCustomOffset", False): + return None + + # Keep offset in SI units - format_distance will handle conversion + custom_offset_si = pset_data.get("CustomOffset", 0.0) + + # Get the appropriate reference based on usage type + usage_type = tool.Model.get_usage_type(cls.element) + custom_reference = None + reference_label = None + + if usage_type == "LAYER2": + custom_reference = pset_data.get("CustomWallReference", "") + reference_label = "Wall Reference" + elif usage_type == "LAYER3": + custom_reference = pset_data.get("CustomSlabReference", "") + reference_label = "Slab Reference" + + return { + "use_custom_offset": pset_data.get("UseCustomOffset", False), + "custom_offset": custom_offset_si, # Store in SI units + "custom_reference": custom_reference, + "reference_label": reference_label, + } \ No newline at end of file diff --git a/src/bonsai/bonsai/bim/module/material/operator.py b/src/bonsai/bonsai/bim/module/material/operator.py index eef35d519c..e8243ccf1e 100644 --- a/src/bonsai/bonsai/bim/module/material/operator.py +++ b/src/bonsai/bonsai/bim/module/material/operator.py @@ -509,6 +509,10 @@ class EnableEditingAssignedMaterial(bpy.types.Operator): bonsai.bim.helper.import_attributes(material[0], props.material_set_attributes) else: bonsai.bim.helper.import_attributes(material, props.material_set_attributes) + + # Load custom offset from BBIM_MaterialLayer pset + tool.Model.load_custom_offset_from_pset(element, obj) + return {"FINISHED"} def import_attributes_callback( @@ -621,13 +625,17 @@ class EditAssignedMaterial(bpy.types.Operator, tool.Ifc.Operator): obj_material_usage.ReferenceExtent = material.ReferenceExtent layer_sets_to_regenerate.add(obj_material_usage.ForLayerSet) + + # Save custom offset to BBIM_MaterialLayer pset + tool.Model.save_custom_offset_to_pset(obj_element, obj) for layer_set in layer_sets_to_regenerate: wall.DumbWallPlaner().regenerate_from_layer_set(layer_set) slab.DumbSlabPlaner().regenerate_from_layer_set(layer_set) if material_set_usage.is_a("IfcMaterialProfileSetUsage"): - attributes["CardinalPoint"] = int(attributes["CardinalPoint"]) + if "CardinalPoint" in attributes: + attributes["CardinalPoint"] = int(attributes["CardinalPoint"]) ifcopenshell.api.material.edit_profile_usage( self.file, usage=material_set_usage, diff --git a/src/bonsai/bonsai/bim/module/material/ui.py b/src/bonsai/bonsai/bim/module/material/ui.py index 5c1b7d05b2..969661885a 100644 --- a/src/bonsai/bonsai/bim/module/material/ui.py +++ b/src/bonsai/bonsai/bim/module/material/ui.py @@ -20,6 +20,8 @@ from __future__ import annotations import bonsai.bim.helper import bonsai.tool as tool import bpy +import ifcopenshell.util.element +import ifcopenshell.util.unit from bpy.types import Panel, UIList from bonsai.bim.helper import draw_attributes from bonsai.bim.helper import prop_with_search @@ -228,31 +230,47 @@ class BIM_PT_object_material(Panel): self.draw_read_only_set_ui() def draw_editable_set_ui(self): - bonsai.bim.helper.draw_attributes(self.props.material_set_attributes, self.layout) - bonsai.bim.helper.draw_attributes(self.props.material_set_usage_attributes, self.layout) + # Material Set Attributes Section + row = self.layout.row(align=True) + box = row.box() + + bonsai.bim.helper.draw_attributes(self.props.material_set_attributes, box) + bonsai.bim.helper.draw_attributes(self.props.material_set_usage_attributes, box) + # Custom Offset Section self.draw_custom_offset() - if ObjectMaterialData.data["set_item_name"] == "profile" and not self.mprops.profiles: - row = self.layout.row(align=True) - row.label(text="No Profiles Available") - row.operator("bim.add_profile_def", icon="ADD", text="") - else: - layout = self.layout - layout.separator() - layout.separator() - row = self.layout.row(align=True) - if ObjectMaterialData.data["set_item_name"] == "profile": - prop_with_search(row, self.mprops, "profiles", icon="ITALIC", text="") - prop_with_search(row, self.props, "material", icon="MATERIAL", text="") - op = row.operator(f"bim.add_{ObjectMaterialData.data['set_item_name']}", icon="ADD", text="") - setattr(op, f"{ObjectMaterialData.data['set_item_name']}_set", ObjectMaterialData.data["set"]["id"]) + + # Dynamic header based on material set type + set_item_name = ObjectMaterialData.data["set_item_name"] + header_map = { + "layer": "Material Layers", + "profile": "Material Profiles", + "constituent": "Material Constituents", + "list_item": "Material List Items" + } + header_text = header_map.get(set_item_name, "Material Items") + self.layout.label(text=header_text) total_items = len(ObjectMaterialData.data["set_items"]) - layout = self.layout - box = layout.box() + row = self.layout.row(align=True) + box = row.box() + + # Add Material Section (at the top of this box) + if ObjectMaterialData.data["set_item_name"] == "profile" and not self.mprops.profiles: + box_row = box.row(align=True) + box_row.label(text="No Profiles Available") + box_row.operator("bim.add_profile_def", icon="ADD", text="") + else: + box_row = box.row(align=True) + if ObjectMaterialData.data["set_item_name"] == "profile": + prop_with_search(box_row, self.mprops, "profiles", icon="ITALIC", text="") + prop_with_search(box_row, self.props, "material", icon="MATERIAL", text="") + op = box_row.operator(f"bim.add_{ObjectMaterialData.data['set_item_name']}", icon="ADD", text="") + setattr(op, f"{ObjectMaterialData.data['set_item_name']}_set", ObjectMaterialData.data["set"]["id"]) + active_object = bpy.context.active_object - self.layerset_bounds(box, active_object, location="Top_Exterior") + self.layerset_bounds(box, active_object, location="Top_Interior") if not ObjectMaterialData.data["set_items"]: row = box.row() @@ -269,7 +287,7 @@ class BIM_PT_object_material(Panel): else: self.draw_read_only_set_item_ui(box, set_item) - self.layerset_bounds(box, active_object, location="Bottom_Interior") + self.layerset_bounds(box, active_object, location="Bottom_Exterior") def draw_editable_set_item_profile_ui(self, box, set_item): # box = self.layout.box() @@ -335,29 +353,98 @@ class BIM_PT_object_material(Panel): setattr(op, f"{ObjectMaterialData.data['set_item_name']}_index", set_item["index"]) def draw_read_only_set_ui(self): + # Material Set Information Section + row = self.layout.row(align=True) + box = row.box() + if ObjectMaterialData.data["material_class"] != "IfcMaterialList": - row = self.layout.row(align=True) + box_row = box.row(align=True) set_name = ObjectMaterialData.data["set"]["name"] - row.label(text="Name") - row.label(text=set_name) + box_row.label(text="Name") + box_row.label(text=set_name) if value := ObjectMaterialData.data["set"]["description"]: - row = self.layout.row(align=True) - row.label(text="Description") - row.label(text=value) + box_row = box.row(align=True) + box_row.label(text="Description") + box_row.label(text=value) if ObjectMaterialData.data["material_class"] == "IfcMaterialProfileSetUsage": if value := ObjectMaterialData.data["set_usage"].get("cardinal_point"): - row = self.layout.row(align=True) - row.label(text="Cardinal Point") - row.label(text=value) + box_row = box.row(align=True) + box_row.label(text="Cardinal Point") + box_row.label(text=value) if ObjectMaterialData.data["total_thickness"]: - row = self.layout.row(align=True) - row.label(text="Total Thickness*") - row.label(text=ObjectMaterialData.data["total_thickness"]) + box_row = box.row(align=True) + box_row.label(text="Total Thickness*") + box_row.label(text=ObjectMaterialData.data["total_thickness"]) - box = self.layout.box() + # Display OffsetFromReferenceLine for layer sets + if "Layer" in ObjectMaterialData.data["material_class"]: + obj = bpy.context.active_object + if obj: + element = tool.Ifc.get_entity(obj) + if element: + material = ifcopenshell.util.element.get_material(element) + if material and material.is_a("IfcMaterialLayerSetUsage"): + offset_value = material.OffsetFromReferenceLine + # Format the offset value + unit_system = bpy.context.scene.unit_settings.system + prefs = tool.Blender.get_addon_preferences() + precision = None + if unit_system == "IMPERIAL": + precision = prefs.doc.imperial_precision + from bonsai.bim.module.drawing.helper import format_distance + formatted_offset = format_distance( + offset_value, precision=precision, suppress_zero_inches=True, in_unit_length=True + ) + box_row = box.row(align=True) + box_row.label(text="Offset From Reference Line") + box_row.label(text=formatted_offset) + + # BBIM_MaterialLayer Pset Section + if pset_data := ObjectMaterialData.data.get("bbim_material_layer_pset"): + self.layout.label(text="BBIM_MaterialLayer Pset") + + row = self.layout.row(align=True) + box = row.box() + + # Custom Offset value - format using format_distance + unit_system = bpy.context.scene.unit_settings.system + prefs = tool.Blender.get_addon_preferences() + precision = None + if unit_system == "IMPERIAL": + precision = prefs.doc.imperial_precision + from bonsai.bim.module.drawing.helper import format_distance + formatted_custom_offset = format_distance( + pset_data['custom_offset'], precision=precision, suppress_zero_inches=True, in_unit_length=True + ) + box_row = box.row(align=True) + box_row.label(text="Custom Offset") + box_row.label(text=formatted_custom_offset) + + # Reference (if exists) + if pset_data["custom_reference"]: + box_row = box.row(align=True) + box_row.label(text=pset_data["reference_label"]) + box_row.label(text=pset_data["custom_reference"]) + + # Dynamic header based on material set type + set_item_name = ObjectMaterialData.data.get("set_item_name") + if set_item_name: + header_map = { + "layer": "Material Layers", + "profile": "Material Profiles", + "constituent": "Material Constituents", + "list_item": "Material List Items" + } + header_text = header_map.get(set_item_name, "Material Items") + else: + header_text = "Materials" + + self.layout.label(text=header_text) + row = self.layout.row(align=True) + box = row.box() active_object = bpy.context.active_object self.layerset_bounds(box, active_object, location="Top_Interior") @@ -403,20 +490,27 @@ class BIM_PT_object_material(Panel): set_usage = ObjectMaterialData.data.get("set_usage", {}) layer_set_direction = set_usage.get("layer_set_direction") if layer_set_direction: - box = self.layout.box() - row = box.row(align=True) - row.prop(self.props, "use_custom_offset", text="Use Custom Offset") - row = box.row(align=True) + row = self.layout.row(align=True) + row.label(text="BBIM_MaterialLayer Pset") + + # Add indentation with a row that has a separator + row = self.layout.row(align=True) + # row.separator(factor=2.0) # Adjust factor for more/less indent + + box = row.box() + box_row = box.row(align=True) + box_row.prop(self.props, "use_custom_offset", text="Use Custom Offset") + box_row = box.row(align=True) if layer_set_direction == "AXIS2": - row.prop(self.props, "custom_wall_reference", text="Reference") - row.enabled = self.props.use_custom_offset + box_row.prop(self.props, "custom_wall_reference", text="Reference") + box_row.enabled = self.props.use_custom_offset if layer_set_direction == "AXIS3": - row.prop(self.props, "custom_slab_reference", text="Reference") - row.enabled = self.props.use_custom_offset + box_row.prop(self.props, "custom_slab_reference", text="Reference") + box_row.enabled = self.props.use_custom_offset - row = box.row(align=True) - row.prop(self.props, "custom_offset", text="Custom Offset") - row.enabled = self.props.use_custom_offset + box_row = box.row(align=True) + box_row.prop(self.props, "custom_offset", text="Custom Offset") + box_row.enabled = self.props.use_custom_offset class BIM_UL_materials(UIList): diff --git a/src/bonsai/bonsai/tool/model.py b/src/bonsai/bonsai/tool/model.py index bfe8d6b020..2250cbe9c0 100644 --- a/src/bonsai/bonsai/tool/model.py +++ b/src/bonsai/bonsai/tool/model.py @@ -620,6 +620,72 @@ class Model(bonsai.core.tool.Model): if not openings[i].obj: openings.remove(i) + + @classmethod + def save_custom_offset_to_pset(cls, element: ifcopenshell.entity_instance, obj: bpy.types.Object) -> None: + """Save custom offset settings to BBIM_MaterialLayer pset.""" + props = tool.Material.get_object_material_props(obj) + + if not props.use_custom_offset: + # Remove pset if custom offset is disabled + pset = ifcopenshell.util.element.get_pset(element, "BBIM_MaterialLayer") + if pset: + pset_entity = tool.Ifc.get().by_id(pset["id"]) + ifcopenshell.api.pset.remove_pset(tool.Ifc.get(), product=element, pset=pset_entity) + return + + # Determine which reference to save based on usage type + usage_type = tool.Model.get_usage_type(element) + custom_wall_reference = None + custom_slab_reference = None + + if usage_type == "LAYER2": + custom_wall_reference = props.custom_wall_reference + elif usage_type == "LAYER3": + custom_slab_reference = props.custom_slab_reference + + # Get or create pset + pset_data = ifcopenshell.util.element.get_pset(element, "BBIM_MaterialLayer") + if pset_data: + pset = tool.Ifc.get().by_id(pset_data["id"]) + else: + pset = ifcopenshell.api.pset.add_pset(tool.Ifc.get(), product=element, name="BBIM_MaterialLayer") + + # Save properties (store in SI units) + unit_scale = ifcopenshell.util.unit.calculate_unit_scale(tool.Ifc.get()) + properties = { + "UseCustomOffset": props.use_custom_offset, + "CustomOffset": props.custom_offset / unit_scale, + "CustomWallReference": custom_wall_reference if custom_wall_reference else "", + "CustomSlabReference": custom_slab_reference if custom_slab_reference else "", + } + ifcopenshell.api.pset.edit_pset(tool.Ifc.get(), pset=pset, properties=properties) + + @classmethod + def load_custom_offset_from_pset(cls, element: ifcopenshell.entity_instance, obj: bpy.types.Object) -> None: + """Load custom offset settings from BBIM_MaterialLayer pset.""" + pset = ifcopenshell.util.element.get_pset(element, "BBIM_MaterialLayer") + if not pset: + return + + props = tool.Material.get_object_material_props(obj) + unit_scale = ifcopenshell.util.unit.calculate_unit_scale(tool.Ifc.get()) + + # Load properties + props.use_custom_offset = pset.get("UseCustomOffset", False) + props.custom_offset = pset.get("CustomOffset", 0.0) * unit_scale # Convert from SI + + # Load the appropriate reference based on usage type + usage_type = tool.Model.get_usage_type(element) + if usage_type == "LAYER2": + custom_wall_ref = pset.get("CustomWallReference", "") + if custom_wall_ref: + props.custom_wall_reference = custom_wall_ref + elif usage_type == "LAYER3": + custom_slab_ref = pset.get("CustomSlabReference", "") + if custom_slab_ref: + props.custom_slab_reference = custom_slab_ref + class MaterialLayerParameters(TypedDict): """Float values are in project units.""" @@ -652,13 +718,33 @@ class Model(bonsai.core.tool.Model): ) @classmethod - def get_material_layer_custom_offset(cls, element: ifcopenshell.entity_instance, obj) -> MaterialLayerParameters: + def get_material_layer_custom_offset(cls, element: ifcopenshell.entity_instance, obj: bpy.types.Object) -> Optional[float]: + """Get custom offset value, reading from pset if props are not set.""" unit_scale = ifcopenshell.util.unit.calculate_unit_scale(tool.Ifc.get()) layer_params = tool.Model.get_material_layer_parameters(element) layer_offset = layer_params["offset"] thickness = layer_params["thickness"] / unit_scale props = tool.Material.get_object_material_props(obj) - if props.use_custom_offset: + + # Try to load from pset if not already in props + if not props.use_custom_offset: + pset = ifcopenshell.util.element.get_pset(element, "BBIM_MaterialLayer") + if pset and pset.get("UseCustomOffset", False): + # Load from pset + custom_offset = pset.get("CustomOffset", 0.0) + usage_type = tool.Model.get_usage_type(element) + + if usage_type == "LAYER2": + custom_offset_reference = pset.get("CustomWallReference", "CENTER") + elif usage_type == "LAYER3": + custom_offset_reference = pset.get("CustomSlabReference", "MIDDLE") + else: + return None + else: + return None + else: + # Use current props + custom_offset = props.custom_offset / unit_scale if tool.Model.get_usage_type(element) == "LAYER2": custom_offset_reference = props.custom_wall_reference elif tool.Model.get_usage_type(element) == "LAYER3": @@ -666,24 +752,22 @@ class Model(bonsai.core.tool.Model): else: return None - custom_offset = props.custom_offset - direction_sense = layer_params["direction_sense"] - if direction_sense == "POSITIVE" and custom_offset_reference in {"INTERIOR", "TOP"}: - layer_offset = custom_offset - thickness * unit_scale - if direction_sense == "POSITIVE" and custom_offset_reference in {"CENTER", "MIDDLE"}: - layer_offset = custom_offset - (thickness / 2) * unit_scale - if (direction_sense == "POSITIVE" and custom_offset_reference in {"EXTERIOR", "BOTTOM"}) or ( - direction_sense == "NEGATIVE" and custom_offset_reference in {"EXTERIOR", "TOP"} - ): - layer_offset = custom_offset - if direction_sense == "NEGATIVE" and custom_offset_reference in {"CENTER", "MIDDLE"}: - layer_offset = custom_offset + (thickness / 2) * unit_scale - if direction_sense == "NEGATIVE" and custom_offset_reference in {"INTERIOR", "BOTTOM"}: - layer_offset = custom_offset + thickness * unit_scale + direction_sense = layer_params["direction_sense"] + + if direction_sense == "POSITIVE" and custom_offset_reference in {"INTERIOR", "TOP"}: + layer_offset = custom_offset - thickness * unit_scale + if direction_sense == "POSITIVE" and custom_offset_reference in {"CENTER", "MIDDLE"}: + layer_offset = custom_offset - (thickness / 2) * unit_scale + if (direction_sense == "POSITIVE" and custom_offset_reference in {"EXTERIOR", "BOTTOM"}) or ( + direction_sense == "NEGATIVE" and custom_offset_reference in {"EXTERIOR", "TOP"} + ): + layer_offset = custom_offset + if direction_sense == "NEGATIVE" and custom_offset_reference in {"CENTER", "MIDDLE"}: + layer_offset = custom_offset + (thickness / 2) * unit_scale + if direction_sense == "NEGATIVE" and custom_offset_reference in {"INTERIOR", "BOTTOM"}: + layer_offset = custom_offset + thickness * unit_scale - return layer_offset / unit_scale - - return None + return layer_offset / unit_scale @classmethod def get_booleans( From 321878e1bb5e51aef24a4fcc28f8a5fc3cf3ae01 Mon Sep 17 00:00:00 2001 From: falken10vdl Date: Tue, 6 Jan 2026 22:14:08 +0100 Subject: [PATCH 11/49] Add Ifc Document information element to decide if blend metadata info should be used in this project file --- .../bonsai/bim/module/project/operator.py | 63 +++++++++++++++---- src/bonsai/bonsai/bim/module/project/prop.py | 6 ++ src/bonsai/bonsai/bim/module/project/ui.py | 6 +- src/bonsai/bonsai/tool/project.py | 57 +++++++++++++++++ 4 files changed, 116 insertions(+), 16 deletions(-) diff --git a/src/bonsai/bonsai/bim/module/project/operator.py b/src/bonsai/bonsai/bim/module/project/operator.py index c0dd8c70c1..a549f7d69e 100644 --- a/src/bonsai/bonsai/bim/module/project/operator.py +++ b/src/bonsai/bonsai/bim/module/project/operator.py @@ -1062,18 +1062,34 @@ class LoadProject(bpy.types.Operator, IFCFileSelector, ImportHelper): and not self.is_advanced ): filepath = self.get_filepath() - suffix = tool.Blender.get_addon_preferences().metadata_blend_file_suffix - if str(filepath).lower().endswith(".ifc"): - metadata_path = Path(str(filepath)[:-4] + suffix) - else: - metadata_path = Path(str(filepath) + suffix) - if metadata_path.exists() and metadata_path.is_file(): - try: - bpy.ops.bim.load_blend_metadata_and_ifc(filepath=filepath) - self.report({"INFO"}, f"Loaded metadata file: {metadata_path.name}") - return {"FINISHED"} - except Exception as e: - self.report({"WARNING"}, f"Failed to load metadata file, using regular load: {e}") + + # First, load the IFC file temporarily to check for metadata document + temp_ifc = None + has_metadata_doc = False + try: + temp_ifc = ifcopenshell.open(str(filepath)) + for doc in temp_ifc.by_type("IfcDocumentInformation"): + if getattr(doc, "Scope", None) == "BLEND_METADATA": + has_metadata_doc = True + break + except: + pass + finally: + temp_ifc = None + + if has_metadata_doc: + suffix = tool.Blender.get_addon_preferences().metadata_blend_file_suffix + if str(filepath).lower().endswith(".ifc"): + metadata_path = Path(str(filepath)[:-4] + suffix) + else: + metadata_path = Path(str(filepath) + suffix) + if metadata_path.exists() and metadata_path.is_file(): + try: + bpy.ops.bim.load_blend_metadata_and_ifc(filepath=filepath) + self.report({"INFO"}, f"Loaded metadata file: {metadata_path.name}") + return {"FINISHED"} + except Exception as e: + self.report({"WARNING"}, f"Failed to load metadata file, using regular load: {e}") @persistent def load_handler(*args): @@ -1121,6 +1137,10 @@ class LoadProject(bpy.types.Operator, IFCFileSelector, ImportHelper): props.is_loading = True props.total_elements = len(tool.Ifc.get().by_type("IfcElement")) props.use_relative_project_path = self.use_relative_path + + metadata_doc = tool.Project.get_metadata_document_information() + props.should_save_metadata_for_this_file = metadata_doc is not None + tool.Blender.register_toolbar() tool.Project.add_recent_ifc_project(self.get_filepath_abs()) @@ -1749,6 +1769,22 @@ class ExportIFC(bpy.types.Operator, ExportHelper): settings.json_version = self.json_version settings.json_compact = self.json_compact + pprops = tool.Project.get_project_props() + if tool.Blender.get_addon_preferences().save_metadata_blend_file and pprops.should_save_metadata_for_this_file: + suffix = tool.Blender.get_addon_preferences().metadata_blend_file_suffix + if output_file.lower().endswith(".ifc"): + metadata_filename = os.path.basename(output_file)[:-4] + suffix + else: + metadata_filename = os.path.basename(output_file) + suffix + + if not tool.Project.get_metadata_document_information(): + tool.Project.create_metadata_document_information(metadata_filename) + else: + tool.Project.update_metadata_document_information(metadata_filename) + else: + if not pprops.should_save_metadata_for_this_file: + tool.Project.remove_metadata_document_information() + ifc_exporter = export_ifc.IfcExporter(settings) print("Starting export") settings.logger.info("Starting export") @@ -1765,7 +1801,8 @@ class ExportIFC(bpy.types.Operator, ExportHelper): tool.Ifc.set_path(output_file) bim_props.is_dirty = False - if tool.Blender.get_addon_preferences().save_metadata_blend_file: + pprops = tool.Project.get_project_props() + if tool.Blender.get_addon_preferences().save_metadata_blend_file and pprops.should_save_metadata_for_this_file: try: bpy.ops.bim.save_blend_metadata_file() suffix = tool.Blender.get_addon_preferences().metadata_blend_file_suffix diff --git a/src/bonsai/bonsai/bim/module/project/prop.py b/src/bonsai/bonsai/bim/module/project/prop.py index 2cbdeb6e7b..8983c927c9 100644 --- a/src/bonsai/bonsai/bim/module/project/prop.py +++ b/src/bonsai/bonsai/bim/module/project/prop.py @@ -411,6 +411,11 @@ class BIMProjectProperties(PropertyGroup): ) use_relative_project_path: BoolProperty(name="Use Relative Project Path", default=False) + should_save_metadata_for_this_file: BoolProperty( + name="Save Session Data for This File", + description="Enable saving session data (window layout, settings) to a metadata blend file for this specific IFC file", + default=False, + ) queried_obj: bpy.props.PointerProperty(type=bpy.types.Object) queried_obj_root: bpy.props.PointerProperty(type=bpy.types.Object) clipping_planes: bpy.props.CollectionProperty(type=ObjProperty) @@ -504,6 +509,7 @@ class BIMProjectProperties(PropertyGroup): parent_library: str use_relative_project_path: bool + should_save_metadata_for_this_file: bool queried_obj: Union[bpy.types.Object, None] queried_obj_root: Union[bpy.types.Object, None] clipping_planes: bpy.types.bpy_prop_collection_idprop[ObjProperty] diff --git a/src/bonsai/bonsai/bim/module/project/ui.py b/src/bonsai/bonsai/bim/module/project/ui.py index 5d4f3f695b..1a6f727b20 100644 --- a/src/bonsai/bonsai/bim/module/project/ui.py +++ b/src/bonsai/bonsai/bim/module/project/ui.py @@ -338,9 +338,9 @@ class BIM_PT_project(Panel): else: metadata_filename = os.path.basename(props.ifc_file) + suffix row = self.layout.row(align=True) - col = row.column() - col.enabled = False - col.label(text=f"Saving session data to: {metadata_filename}") + row.use_property_split = False + pprops = tool.Project.get_project_props() + row.prop(pprops, "should_save_metadata_for_this_file", text=f"Save session data to: {metadata_filename}") class BIM_PT_new_project_wizard(Panel): diff --git a/src/bonsai/bonsai/tool/project.py b/src/bonsai/bonsai/tool/project.py index 0bfb4b00c1..fec4f6a42b 100644 --- a/src/bonsai/bonsai/tool/project.py +++ b/src/bonsai/bonsai/tool/project.py @@ -513,3 +513,60 @@ class Project(bonsai.core.tool.Project): if tmp.exists(): shutil.rmtree(tmp) bpy.ops.bim.save_project(filepath=cls.TEMP_PROJECT_PATH.__str__(), should_save_as=True) + + @classmethod + def get_metadata_document_information(cls) -> Optional[ifcopenshell.entity_instance]: + ifc_file = tool.Ifc.get() + if not ifc_file: + return None + for doc in ifc_file.by_type("IfcDocumentInformation"): + if getattr(doc, "Scope", None) == "BLEND_METADATA": + return doc + return None + + @classmethod + def create_metadata_document_information(cls, metadata_filename: str) -> ifcopenshell.entity_instance: + ifc_file = tool.Ifc.get() + if not ifc_file: + raise Exception("No IFC file loaded") + + doc = tool.Ifc.run("document.add_information", parent=None) + + if ifc_file.schema == "IFC2X3": + tool.Ifc.run("document.edit_information", information=doc, attributes={ + "DocumentId": "BLEND_METADATA", + "Name": "Blend Metadata", + "Scope": "BLEND_METADATA", + "Description": "References to blend metadata file for this IFC project", + "Location": metadata_filename + }) + else: + tool.Ifc.run("document.edit_information", information=doc, attributes={ + "Identification": "BLEND_METADATA", + "Name": "Blend Metadata", + "Scope": "BLEND_METADATA", + "Description": "References to blend metadata file for this IFC project", + "Location": metadata_filename + }) + + return doc + + @classmethod + def update_metadata_document_information(cls, metadata_filename: str) -> None: + doc = cls.get_metadata_document_information() + if not doc: + return + + ifc_file = tool.Ifc.get() + if not ifc_file: + return + + tool.Ifc.run("document.edit_information", information=doc, attributes={ + "Location": metadata_filename + }) + + @classmethod + def remove_metadata_document_information(cls) -> None: + doc = cls.get_metadata_document_information() + if doc: + tool.Ifc.run("document.remove_information", information=doc) From e9eca5e3f2579d2117b2787b0a7ea9a1018a05ba Mon Sep 17 00:00:00 2001 From: Ryan Schultz Date: Wed, 7 Jan 2026 09:08:12 -0600 Subject: [PATCH 12/49] If feet is negative, inches should also be negative (subtractive) --- src/bonsai/bonsai/tool/unit.py | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/src/bonsai/bonsai/tool/unit.py b/src/bonsai/bonsai/tool/unit.py index c982c43523..dbfcc3cd3e 100644 --- a/src/bonsai/bonsai/tool/unit.py +++ b/src/bonsai/bonsai/tool/unit.py @@ -166,6 +166,11 @@ def parse_distance_string(input_string: str, use_project_unit: bool = True) -> t break if inches is None: inches = 0 + + # If feet is negative, inches should also be negative (subtractive) + if feet < 0: + inches = -inches + # Convert to meters total_meters = (feet * 0.3048) + (inches * 0.0254) return total_meters From a460e29473622c75685eedf6a2f531dc9aff1905 Mon Sep 17 00:00:00 2001 From: Ryan Schultz Date: Thu, 8 Jan 2026 09:41:29 -0600 Subject: [PATCH 13/49] retain current selection in `bim.select_similar_type` --- src/bonsai/bonsai/bim/module/type/operator.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/bonsai/bonsai/bim/module/type/operator.py b/src/bonsai/bonsai/bim/module/type/operator.py index c078d66f67..ce70f59231 100644 --- a/src/bonsai/bonsai/bim/module/type/operator.py +++ b/src/bonsai/bonsai/bim/module/type/operator.py @@ -239,7 +239,7 @@ class SelectSimilarType(bpy.types.Operator): for related_object in objects: relating_type = ifcopenshell.util.element.get_type(tool.Ifc.get_entity(related_object)) if not relating_type: - related_object.select_set(False) + # Keep objects without a type selected (retain current selection) continue relating_types.add(relating_type) From 0c3153ab7f56fb2d7714d5ff2678dcec8f10a1ef Mon Sep 17 00:00:00 2001 From: Bruno Postle Date: Fri, 9 Jan 2026 23:19:39 +0000 Subject: [PATCH 14/49] Docs, link fedora Bonsai/IfcOpenShell packages --- src/bonsai/docs/guides/development/installation.rst | 1 + 1 file changed, 1 insertion(+) diff --git a/src/bonsai/docs/guides/development/installation.rst b/src/bonsai/docs/guides/development/installation.rst index e68d90aa00..3446617ab6 100644 --- a/src/bonsai/docs/guides/development/installation.rst +++ b/src/bonsai/docs/guides/development/installation.rst @@ -200,6 +200,7 @@ Packaged installation - **Arch Linux**: `Direct from Git `__. - **Chocolatey on Windows**: `Unstable `__. +- **Fedora Linux**: `IfcOpenShell Copr repository `__. Tips for package managers ------------------------- From 5ea66730d41ee2170c32a62e6cba6ab0295a1121 Mon Sep 17 00:00:00 2001 From: falken10 Date: Fri, 20 Jun 2025 10:14:02 +0200 Subject: [PATCH 15/49] Implemented tree like structure for documents --- .../bonsai/bim/module/document/__init__.py | 17 +- src/bonsai/bonsai/bim/module/document/data.py | 123 ++++++-- .../bonsai/bim/module/document/operator.py | 292 ++++++++++++++++- src/bonsai/bonsai/bim/module/document/prop.py | 76 ++++- src/bonsai/bonsai/bim/module/document/ui.py | 298 ++++++++++++++---- src/bonsai/bonsai/core/document.py | 102 +++--- src/bonsai/bonsai/core/tool.py | 6 - src/bonsai/bonsai/tool/document.py | 181 +++++++---- src/bonsai/test/bim/feature/document.feature | 9 - src/bonsai/test/core/test_document.py | 18 -- src/bonsai/test/tool/test_document.py | 52 --- 11 files changed, 873 insertions(+), 301 deletions(-) diff --git a/src/bonsai/bonsai/bim/module/document/__init__.py b/src/bonsai/bonsai/bim/module/document/__init__.py index f4eede1721..6a925d0f98 100644 --- a/src/bonsai/bonsai/bim/module/document/__init__.py +++ b/src/bonsai/bonsai/bim/module/document/__init__.py @@ -18,32 +18,47 @@ import bpy from . import ui, prop, operator +from bpy.types import VIEW3D_MT_object_context_menu classes = ( operator.AddDocumentReference, operator.AddInformation, operator.AssignDocument, operator.DisableDocumentEditingUI, + operator.DisableObjectDocumentEditingUI, operator.DisableEditingDocument, operator.EditDocument, operator.EnableEditingDocument, operator.LoadDocument, - operator.LoadParentDocument, + operator.LoadObjectDocuments, operator.LoadProjectDocuments, operator.RemoveDocument, operator.SelectDocumentObjects, + operator.ToggleDocument, operator.UnassignDocument, + operator.UpdateAssignedDocuments, + operator.OpenIFCDocument, prop.Document, + prop.DocumentObject, + prop.AssignedDocument, + prop.ExpandedDocuments, prop.BIMDocumentProperties, ui.BIM_PT_documents, ui.BIM_PT_object_documents, ui.BIM_UL_documents, + ui.BIM_UL_document_objects, + ui.BIM_UL_assigned_documents, + ui.BIM_MT_object_documents_context_menu, ) def register(): bpy.types.Scene.BIMDocumentProperties = bpy.props.PointerProperty(type=prop.BIMDocumentProperties) + bpy.types.Scene.ExpandedDocuments = bpy.props.PointerProperty(type=prop.ExpandedDocuments) + VIEW3D_MT_object_context_menu.append(ui.add_object_documents_context_menu) def unregister(): del bpy.types.Scene.BIMDocumentProperties + del bpy.types.Scene.ExpandedDocuments + VIEW3D_MT_object_context_menu.remove(ui.add_object_documents_context_menu) diff --git a/src/bonsai/bonsai/bim/module/document/data.py b/src/bonsai/bonsai/bim/module/document/data.py index 5cde82e499..be6b628bed 100644 --- a/src/bonsai/bonsai/bim/module/document/data.py +++ b/src/bonsai/bonsai/bim/module/document/data.py @@ -35,30 +35,69 @@ class DocumentData: @classmethod def load(cls): cls.data = { - "total_information": cls.total_information(), - "parent_document": cls.parent_document(), + "total_document_informations": cls.total_document_informations(), + "total_document_references": cls.total_document_references(), + "total_referenced_objects": cls.total_referenced_objects(), + "document_objects": cls.document_objects(), } cls.is_loaded = True @classmethod - def total_information(cls): - return len( - [ - rel - for rel in tool.Ifc.get().by_type("IfcProject")[0].HasAssociations or [] - if rel.is_a("IfcRelAssociatesDocument") and rel.RelatingDocument.is_a("IfcDocumentInformation") - ] - ) + def total_document_informations(cls): + file = tool.Ifc.get() + info_count = len(file.by_type("IfcDocumentInformation")) + return info_count @classmethod - def parent_document(cls): + def total_document_references(cls): + file = tool.Ifc.get() + ref_count = len(file.by_type("IfcDocumentReference")) + return ref_count + + @classmethod + def total_referenced_objects(cls): + file = tool.Ifc.get() + document_rels = file.by_type("IfcRelAssociatesDocument") + documented_objects = set() + for rel in document_rels: + for related_object in rel.RelatedObjects: + obj = tool.Ifc.get_object(related_object) + if obj: + documented_objects.add(related_object.id()) + + return len(documented_objects) + + @classmethod + def document_objects(cls): + document_objects = {} + file = tool.Ifc.get() + + for rel in file.by_type("IfcRelAssociatesDocument"): + document_id = rel.RelatingDocument.id() + if document_id not in document_objects: + document_objects[document_id] = [] + + for related_object in rel.RelatedObjects: + element = related_object + obj = tool.Ifc.get_object(element) + if obj: + document_objects[document_id].append({"id": element.id(), "name": obj.name, "obj": obj}) + + return document_objects + + @classmethod + def load_document_objects_into_props(cls, document_id): props = tool.Document.get_document_props() - if len(props.breadcrumbs): - parent = tool.Ifc.get().by_id(int(props.breadcrumbs[-1].name)) - if tool.Ifc.get_schema() == "IFC2X3": - return str(parent.DocumentId) - return str(parent.Identification) - return "" + props.document_objects.clear() + + if "document_objects" not in cls.data or document_id not in cls.data["document_objects"]: + return + + sorted_objects = sorted(cls.data["document_objects"][document_id], key=lambda x: x["name"].lower()) + + for obj_data in sorted_objects: + item = props.document_objects.add() + item.name = obj_data["name"] class ObjectDocumentData: @@ -80,31 +119,47 @@ class ObjectDocumentData: return results for rel in getattr(element, "HasAssociations", []): if rel.is_a("IfcRelAssociatesDocument"): - if not rel.RelatingDocument.is_a("IfcDocumentReference"): + is_information = rel.RelatingDocument.is_a("IfcDocumentInformation") + is_reference = rel.RelatingDocument.is_a("IfcDocumentReference") + + if not (is_information or is_reference): continue name = rel.RelatingDocument.Name - if tool.Ifc.get_schema() == "IFC2X3": - if not name and rel.RelatingDocument.ReferenceToDocument: - name = rel.RelatingDocument.ReferenceToDocument[0].Name + location = None + identification = None + description = None - identification = rel.RelatingDocument.ItemReference - if not identification and rel.RelatingDocument.ReferenceToDocument: - identification = rel.RelatingDocument.ReferenceToDocument[0].DocumentId + if is_information: + if tool.Ifc.get_schema() == "IFC2X3": + identification = rel.RelatingDocument.DocumentId + else: + identification = rel.RelatingDocument.Identification + + location = getattr(rel.RelatingDocument, "Location", None) - location = rel.RelatingDocument.Location else: - if not name and rel.RelatingDocument.ReferencedDocument: - name = rel.RelatingDocument.ReferencedDocument.Name + description = rel.RelatingDocument.Description + if tool.Ifc.get_schema() == "IFC2X3": + if not name and rel.RelatingDocument.ReferenceToDocument: + name = rel.RelatingDocument.ReferenceToDocument[0].Name - identification = rel.RelatingDocument.Identification - if not identification and rel.RelatingDocument.ReferencedDocument: - identification = rel.RelatingDocument.ReferencedDocument.Identification + identification = rel.RelatingDocument.ItemReference + if not identification and rel.RelatingDocument.ReferenceToDocument: + identification = rel.RelatingDocument.ReferenceToDocument[0].DocumentId + location = rel.RelatingDocument.Location + else: + if not name and rel.RelatingDocument.ReferencedDocument: + name = rel.RelatingDocument.ReferencedDocument.Name - location = rel.RelatingDocument.Location - if location is None and rel.RelatingDocument.ReferencedDocument: - location = rel.RelatingDocument.ReferencedDocument.Location + identification = rel.RelatingDocument.Identification + if not identification and rel.RelatingDocument.ReferencedDocument: + identification = rel.RelatingDocument.ReferencedDocument.Identification + + location = rel.RelatingDocument.Location + if location is None and rel.RelatingDocument.ReferencedDocument: + location = rel.RelatingDocument.ReferencedDocument.Location if location: if not "://" in location: @@ -118,6 +173,8 @@ class ObjectDocumentData: "identification": identification, "name": name, "location": location, + "is_information": is_information, + "description": description, } ) return results diff --git a/src/bonsai/bonsai/bim/module/document/operator.py b/src/bonsai/bonsai/bim/module/document/operator.py index da75e16e63..ffc81537ee 100644 --- a/src/bonsai/bonsai/bim/module/document/operator.py +++ b/src/bonsai/bonsai/bim/module/document/operator.py @@ -24,6 +24,24 @@ import ifcopenshell.util.element import bonsai.bim.handler import bonsai.tool as tool import bonsai.core.document as core +import subprocess +import os +from bonsai.bim.module.document.data import DocumentData, ObjectDocumentData + + +def update_document_objects(document_id=None): + DocumentData.is_loaded = False + DocumentData.load() + + if document_id is None: + props = tool.Document.get_document_props() + if props.documents and props.active_document_index < len(props.documents): + document = props.documents[props.active_document_index] + if document.ifc_definition_id: + document_id = document.ifc_definition_id + + if document_id: + DocumentData.load_document_objects_into_props(document_id) class LoadProjectDocuments(bpy.types.Operator): @@ -33,7 +51,7 @@ class LoadProjectDocuments(bpy.types.Operator): def execute(self, context): core.load_project_documents(tool.Document) - bonsai.bim.handler.refresh_ui_data() # Update breadcrumbs data. + update_document_objects() return {"FINISHED"} @@ -45,18 +63,8 @@ class LoadDocument(bpy.types.Operator): def execute(self, context): core.load_document(tool.Document, document=tool.Ifc.get().by_id(self.document)) - bonsai.bim.handler.refresh_ui_data() # Update breadcrumbs data. - return {"FINISHED"} - - -class LoadParentDocument(bpy.types.Operator): - bl_idname = "bim.load_parent_document" - bl_label = "Load Parent Document" - bl_options = {"REGISTER", "UNDO"} - - def execute(self, context): - core.load_parent_document(tool.Document) - bonsai.bim.handler.refresh_ui_data() # Update breadcrumbs data. + bonsai.bim.handler.refresh_ui_data() # Is this needed? + update_document_objects() return {"FINISHED"} @@ -70,6 +78,17 @@ class DisableDocumentEditingUI(bpy.types.Operator): return {"FINISHED"} +class DisableObjectDocumentEditingUI(bpy.types.Operator): + bl_idname = "bim.disable_object_document_editing_ui" + bl_label = "Disable Object Document Editing UI" + bl_options = {"REGISTER", "UNDO"} + + def execute(self, context): + props = tool.Document.get_document_props() + props.is_object_editing = False + return {"FINISHED"} + + class EnableEditingDocument(bpy.types.Operator): bl_idname = "bim.enable_editing_document" bl_label = "Enable Editing Document" @@ -77,6 +96,8 @@ class EnableEditingDocument(bpy.types.Operator): document: bpy.props.IntProperty() def execute(self, context): + props = tool.Document.get_document_props() + props.is_document_editing = True core.enable_editing_document(tool.Document, document=tool.Ifc.get().by_id(self.document)) return {"FINISHED"} @@ -87,6 +108,8 @@ class DisableEditingDocument(bpy.types.Operator): bl_options = {"REGISTER", "UNDO"} def execute(self, context): + props = tool.Document.get_document_props() + props.is_document_editing = False core.disable_editing_document(tool.Document) return {"FINISHED"} @@ -97,7 +120,41 @@ class AddInformation(bpy.types.Operator, tool.Ifc.Operator): bl_options = {"REGISTER", "UNDO"} def _execute(self, context): - core.add_information(tool.Ifc, tool.Document) + props = tool.Document.get_document_props() + parent = None + if props.documents and props.active_document_index < len(props.documents): + selected_document = props.documents[props.active_document_index] + + if selected_document.ifc_definition_id == -1: + parent = tool.Ifc.get().by_type("IfcProject")[0] if tool.Ifc.get().by_type("IfcProject") else None + elif selected_document.is_information: + parent = tool.Ifc.get().by_id(selected_document.ifc_definition_id) + else: + self.report({"ERROR"}, "Cannot add an information element as a child of a reference element") + return {"CANCELLED"} + else: + parent = tool.Ifc.get().by_type("IfcProject")[0] if tool.Ifc.get().by_type("IfcProject") else None + + core.add_information(tool.Ifc, tool.Document, parent) + + expanded_docs = [] + try: + expanded_docs = json.loads(context.scene.ExpandedDocuments.json_string) + except (AttributeError, json.JSONDecodeError): + pass + + project = tool.Ifc.get().by_type("IfcProject")[0] + virtual_root_id = -project.id() + if virtual_root_id in expanded_docs: + expanded_docs.remove(virtual_root_id) + + if parent and parent.is_a("IfcDocumentInformation"): + if parent.id() not in expanded_docs: + expanded_docs.append(parent.id()) + + context.scene.ExpandedDocuments.json_string = json.dumps(expanded_docs) + + bpy.ops.bim.load_project_documents() class AddDocumentReference(bpy.types.Operator, tool.Ifc.Operator): @@ -106,7 +163,33 @@ class AddDocumentReference(bpy.types.Operator, tool.Ifc.Operator): bl_options = {"REGISTER", "UNDO"} def _execute(self, context): + props = tool.Document.get_document_props() + + if not props.documents or props.active_document_index >= len(props.documents): + self.report({"ERROR"}, "No document selected") + return {"CANCELLED"} + + selected_document = props.documents[props.active_document_index] + + if not selected_document.is_information: + self.report({"ERROR"}, "Cannot add a reference to a reference element") + return {"CANCELLED"} + + parent = tool.Ifc.get().by_id(selected_document.ifc_definition_id) + + props.document_attributes.clear() core.add_reference(tool.Ifc, tool.Document) + expanded_docs = [] + try: + expanded_docs = json.loads(context.scene.ExpandedDocuments.json_string) + except (AttributeError, json.JSONDecodeError): + pass + + if parent.id() not in expanded_docs: + expanded_docs.append(parent.id()) + context.scene.ExpandedDocuments.json_string = json.dumps(expanded_docs) + + bpy.ops.bim.load_project_documents() class EditDocument(bpy.types.Operator, tool.Ifc.Operator): @@ -116,7 +199,16 @@ class EditDocument(bpy.types.Operator, tool.Ifc.Operator): def _execute(self, context): props = tool.Document.get_document_props() - core.edit_document(tool.Ifc, tool.Document, document=tool.Ifc.get().by_id(props.active_document_id)) + if props.active_document_id: + core.edit_document(tool.Ifc, tool.Document, document=tool.Ifc.get().by_id(props.active_document_id)) + props.active_document_id = 0 + props.is_document_editing = False + DocumentData.is_loaded = False + DocumentData.load() + ObjectDocumentData.is_loaded = False + ObjectDocumentData.load() + bpy.ops.bim.update_assigned_documents() + bonsai.bim.handler.refresh_ui_data() class RemoveDocument(bpy.types.Operator, tool.Ifc.Operator): @@ -129,6 +221,39 @@ class RemoveDocument(bpy.types.Operator, tool.Ifc.Operator): core.remove_document(tool.Ifc, tool.Document, document=tool.Ifc.get().by_id(self.document)) +class UpdateAssignedDocuments(bpy.types.Operator): + bl_idname = "bim.update_assigned_documents" + bl_label = "Update Assigned Documents" + bl_description = "Update the list of documents assigned to the active object" + bl_options = {"REGISTER"} + + def execute(self, context): + ObjectDocumentData.is_loaded = False + ObjectDocumentData.load() + + props = tool.Document.get_document_props() + props.assigned_documents.clear() + + if not ObjectDocumentData.data.get("documents"): + return {"FINISHED"} + + sorted_docs = sorted( + ObjectDocumentData.data["documents"], + key=lambda doc: ((doc.get("identification") or "").lower(), (doc.get("name") or "").lower()), + ) + + for document in sorted_docs: + new = props.assigned_documents.add() + new.name = document["name"] or "Unnamed" + new.identification = document["identification"] or "*" + new.is_information = document.get("is_information", False) + new.ifc_definition_id = document["id"] + new.location = document.get("location") or "" + new.description = document.get("description") or "" + + return {"FINISHED"} + + class AssignDocument(bpy.types.Operator, tool.Ifc.Operator): bl_idname = "bim.assign_document" bl_label = "Assign Document" @@ -145,6 +270,11 @@ class AssignDocument(bpy.types.Operator, tool.Ifc.Operator): if element: core.assign_document(tool.Ifc, product=element, document=document) + update_document_objects(self.document) + ObjectDocumentData.is_loaded = False + ObjectDocumentData.load() + bpy.ops.bim.update_assigned_documents() + class UnassignDocument(bpy.types.Operator, tool.Ifc.Operator): bl_idname = "bim.unassign_document" @@ -160,6 +290,19 @@ class UnassignDocument(bpy.types.Operator, tool.Ifc.Operator): element = tool.Ifc.get_entity(obj) if element: core.unassign_document(tool.Ifc, product=element, document=document) + props = tool.Document.get_document_props() + active_document_id = None + if props.documents and props.active_document_index < len(props.documents): + active_document = props.documents[props.active_document_index] + active_document_id = active_document.ifc_definition_id + + if active_document_id and active_document_id != self.document: + update_document_objects(active_document_id) + else: + update_document_objects(self.document) + ObjectDocumentData.is_loaded = False + ObjectDocumentData.load() + bpy.ops.bim.update_assigned_documents() class SelectDocumentObjects(bpy.types.Operator): @@ -182,3 +325,122 @@ class SelectDocumentObjects(bpy.types.Operator): i += 1 self.report({"INFO"}, f"{i} objects selected.") return {"FINISHED"} + + +class LoadObjectDocuments(bpy.types.Operator): + bl_idname = "bim.load_object_documents" + bl_label = "Load Object Documents" + bl_description = "Load documents to assign to the selected object" + bl_options = {"REGISTER", "UNDO"} + + def execute(self, context): + if not ObjectDocumentData.is_loaded: + ObjectDocumentData.load() + + core.load_project_documents(tool.Document) + + props = tool.Document.get_document_props() + props.is_object_editing = True + + bonsai.bim.handler.refresh_ui_data() + + self.update_assigned_documents(props) + + return {"FINISHED"} + + def update_assigned_documents(self, props): + props.assigned_documents.clear() + + if not ObjectDocumentData.data.get("documents"): + return + + sorted_docs = sorted( + ObjectDocumentData.data["documents"], + key=lambda doc: ((doc.get("identification") or "").lower(), (doc.get("name") or "").lower()), + ) + + for document in sorted_docs: + new = props.assigned_documents.add() + new.name = document["name"] or "Unnamed" + new.identification = document["identification"] or "*" + new.is_information = document.get("is_information", False) + new.ifc_definition_id = document["id"] + new.location = document["location"] or "" + new.description = document["description"] or "" + + +class OpenIFCDocument(bpy.types.Operator): + bl_idname = "bim.open_ifc_document" + bl_label = "Open IFC Document" + bl_description = "Open the IFC document in a new Blender instance and load the project" + bl_options = {"REGISTER", "UNDO"} + + uri: bpy.props.StringProperty(name="URI") + + def execute(self, context): + + if not self.uri: + self.report({"ERROR"}, "No URI provided") + return {"CANCELLED"} + + file_path = self.uri + if file_path.startswith("file://"): + file_path = file_path[7:] + elif file_path.startswith("file:"): + file_path = file_path[5:] + + if not os.path.isabs(file_path): + file_path = os.path.abspath(file_path) + + if not os.path.exists(file_path): + self.report({"ERROR"}, f"IFC file not found: {file_path}") + return {"CANCELLED"} + + try: + subprocess.Popen( + [ + "blender", + "--python-expr", + f"import bpy; bpy.ops.bim.load_project(filepath='{file_path}', should_start_fresh_session=True)", + ] + ) + self.report({"INFO"}, f"Opening IFC file: {file_path} in a new Blender instance.") + except Exception as e: + self.report({"ERROR"}, f"Failed to open IFC file: {str(e)}") + + return {"FINISHED"} + + +class ToggleDocument(bpy.types.Operator, tool.Ifc.Operator): + bl_idname = "bim.toggle_document" + bl_label = "Toggle Document" + bl_options = {"REGISTER", "UNDO"} + document: bpy.props.IntProperty() + option: bpy.props.StringProperty() + + def _execute(self, context): + expanded_documents = [] + try: + expanded_documents = json.loads(context.scene.ExpandedDocuments.json_string) + except (AttributeError, json.JSONDecodeError): + expanded_documents = [] + + document_id = self.document + + if self.option == "Expand" and document_id not in expanded_documents: + expanded_documents.append(document_id) + elif self.option == "Collapse" and document_id in expanded_documents: + expanded_documents.remove(document_id) + elif document_id == -1: + project = tool.Ifc.get().by_type("IfcProject")[0] + virtual_root_id = -project.id() + + if self.option == "Expand" and virtual_root_id not in expanded_documents: + expanded_documents.append(virtual_root_id) + elif self.option == "Collapse" and virtual_root_id in expanded_documents: + expanded_documents.remove(virtual_root_id) + + context.scene.ExpandedDocuments.json_string = json.dumps(expanded_documents) + + bpy.ops.bim.load_project_documents() + return {"FINISHED"} diff --git a/src/bonsai/bonsai/bim/module/document/prop.py b/src/bonsai/bonsai/bim/module/document/prop.py index f4e4b16686..88878f8bfc 100644 --- a/src/bonsai/bonsai/bim/module/document/prop.py +++ b/src/bonsai/bonsai/bim/module/document/prop.py @@ -30,6 +30,7 @@ from bpy.props import ( FloatVectorProperty, CollectionProperty, ) +from bonsai.bim.module.document.data import DocumentData from typing import TYPE_CHECKING, Union @@ -49,36 +50,93 @@ def update_document_identification(self: "Document", context: bpy.types.Context) tool.Document.set_external_reference_id(document, self.identification) +def update_active_document(self, context): + if self.documents and self.active_document_index < len(self.documents): + document = self.documents[self.active_document_index] + if document.ifc_definition_id: + DocumentData.load_document_objects_into_props(document.ifc_definition_id) + + class Document(PropertyGroup): - name: StringProperty(name="Name", update=update_document_name) - identification: StringProperty(name="Identification", update=update_document_identification) - is_information: BoolProperty( - name="Is Information", - description="Whether element is IfcDocumentInformation, otherwise it's IfcDocumentReference.", - ) + name: StringProperty(name="Name") + identification: StringProperty(name="Identification") + description: StringProperty(name="Description") + is_information: BoolProperty(name="Is Information") + ifc_definition_id: IntProperty(name="IFC Definition ID") + location: StringProperty(name="Location", default="") + tree_depth: IntProperty(name="Tree Depth", default=0) + has_children: BoolProperty(name="Has Children", default=False) + is_expanded: BoolProperty(name="Is Expanded", default=False) + + if TYPE_CHECKING: + name: str + identification: str + description: str + is_information: bool + ifc_definition_id: int + location: str + tree_depth: int + has_children: bool + is_expanded: bool + + +class ExpandedDocuments(PropertyGroup): + json_string: StringProperty(name="JSON String", default="[]") + + if TYPE_CHECKING: + json_string: str + + +class DocumentObject(PropertyGroup): + name: StringProperty(name="Name") ifc_definition_id: IntProperty(name="IFC Definition ID") if TYPE_CHECKING: + name: str + ifc_definition_id: int + + +class AssignedDocument(PropertyGroup): + name: StringProperty(name="Name") + identification: StringProperty(name="Identification") + description: StringProperty(name="Description", default="") + is_information: BoolProperty(name="Is Information") + ifc_definition_id: IntProperty(name="IFC Definition ID") + location: StringProperty(name="Location", default="") + + if TYPE_CHECKING: + name: str identification: str is_information: bool ifc_definition_id: int + location: str class BIMDocumentProperties(PropertyGroup): document_attributes: CollectionProperty(name="Document Attributes", type=Attribute) active_document_id: IntProperty(name="Active Document Id") documents: CollectionProperty(name="Documents", type=Document) - breadcrumbs: CollectionProperty(name="Breadcrumbs", type=StrProperty) - active_document_index: IntProperty(name="Active Document Index") + active_document_index: IntProperty(name="Active Document Index", update=update_active_document) is_editing: BoolProperty(name="Is Editing", default=False) + is_document_editing: BoolProperty(name="Is Document Editing", default=False) + is_object_editing: BoolProperty(name="Is Object Editing", default=False) + document_objects: CollectionProperty(name="Document Objects", type=DocumentObject) + active_document_object_index: IntProperty(name="Active Document Object Index") + assigned_documents: CollectionProperty(name="Assigned Documents", type=AssignedDocument) + active_assigned_document_index: IntProperty(name="Active Assigned Document Index") if TYPE_CHECKING: document_attributes: bpy.types.bpy_prop_collection_idprop[Attribute] active_document_id: int documents: bpy.types.bpy_prop_collection_idprop[Document] - breadcrumbs: bpy.types.bpy_prop_collection_idprop[StrProperty] active_document_index: int is_editing: bool + is_document_editing: bool + is_object_editing: bool + document_objects: bpy.types.bpy_prop_collection_idprop[DocumentObject] + active_document_object_index: int + assigned_documents: bpy.types.bpy_prop_collection_idprop[AssignedDocument] + active_assigned_document_index: int @property def active_document(self) -> Union[Document, None]: diff --git a/src/bonsai/bonsai/bim/module/document/ui.py b/src/bonsai/bonsai/bim/module/document/ui.py index 5897edbad8..eab2d146fb 100644 --- a/src/bonsai/bonsai/bim/module/document/ui.py +++ b/src/bonsai/bonsai/bim/module/document/ui.py @@ -16,6 +16,7 @@ # You should have received a copy of the GNU General Public License # along with Bonsai. If not, see . +import bpy import bonsai.tool as tool from bpy.types import Panel, UIList from bonsai.bim.helper import draw_attributes @@ -42,43 +43,74 @@ class BIM_PT_documents(Panel): self.props = tool.Document.get_document_props() row = self.layout.row(align=True) - row.label(text="{} Documents Found".format(DocumentData.data["total_information"]), icon="FILE") + split = row.split(factor=0.55) + + left_row = split.row(align=True) + left_row.label(text="{} Informations".format(DocumentData.data["total_document_informations"]), icon="FILE") + left_row.label(text="{} References".format(DocumentData.data["total_document_references"]), icon="FILE_HIDDEN") + + right_row = split.row(align=True) + right_row.label( + text="{} Objects Referenced".format(DocumentData.data["total_referenced_objects"]), icon="OBJECT_DATA" + ) if self.props.is_editing: - row.operator("bim.disable_document_editing_ui", text="", icon="CANCEL") + right_row.operator("bim.disable_document_editing_ui", text="", icon="CANCEL") else: - row.operator("bim.load_project_documents", text="", icon="IMPORT") + right_row.operator("bim.load_project_documents", text="", icon="IMPORT") if not self.props.is_editing: return row = self.layout.row(align=True) - if self.props.breadcrumbs: - row.operator("bim.load_parent_document", text="", icon="FRAME_PREV") - row.label(text=DocumentData.data["parent_document"]) - else: - row.alignment = "RIGHT" - row.operator("bim.add_information", text="", icon="ADD") - if self.props.breadcrumbs: - row.operator("bim.add_document_reference", text="", icon="FILE_HIDDEN") + row.alignment = "RIGHT" - active_document = self.props.active_document - - if self.props.active_document_id: + if self.props.is_document_editing: row.operator("bim.edit_document", text="", icon="CHECKMARK") row.operator("bim.disable_editing_document", text="", icon="CANCEL") - elif active_document: - ifc_definition_id = active_document.ifc_definition_id - row.operator("bim.select_document_objects", text="", icon="RESTRICT_SELECT_OFF").document = ( - ifc_definition_id - ) - row.operator("bim.assign_document", text="", icon="BRUSH_DATA").document = ifc_definition_id - row.operator("bim.enable_editing_document", text="", icon="GREASEPENCIL").document = ifc_definition_id - row.operator("bim.remove_document", text="", icon="X").document = ifc_definition_id + else: + row.operator("bim.add_information", text="", icon="ADD") + + if self.props.documents and self.props.active_document_index < len(self.props.documents): + active_doc = self.props.documents[self.props.active_document_index] + if active_doc.is_information and active_doc.ifc_definition_id != -1: + row.operator("bim.add_document_reference", text="", icon="FILE_HIDDEN") + + active_document = self.props.active_document + if active_document: + ifc_definition_id = active_document.ifc_definition_id + row.operator("bim.select_document_objects", text="", icon="RESTRICT_SELECT_OFF").document = ( + ifc_definition_id + ) + row.operator("bim.assign_document", text="", icon="BRUSH_DATA").document = ifc_definition_id + row.operator("bim.enable_editing_document", text="", icon="GREASEPENCIL").document = ifc_definition_id + row.operator("bim.remove_document", text="", icon="X").document = ifc_definition_id self.layout.template_list("BIM_UL_documents", "", self.props, "documents", self.props, "active_document_index") - if self.props.active_document_id: - draw_attributes(self.props.document_attributes, self.layout) + if self.props.is_document_editing: + active_document = self.props.active_document + if active_document.is_information: + draw_attributes(self.props.document_attributes, self.layout) + else: + draw_attributes(self.props.document_attributes, self.layout, filter_attributes=["Name"]) + + if ( + self.props.is_editing + and self.props.documents + and self.props.active_document_index < len(self.props.documents) + ): + document = self.props.documents[self.props.active_document_index] + box = self.layout.box() + row = box.row(align=True) + row.label(text="Assigned Objects", icon="OUTLINER_OB_EMPTY") + box.template_list( + "BIM_UL_document_objects", + "", + self.props, + "document_objects", + self.props, + "active_document_object_index", + ) class BIM_PT_object_documents(Panel): @@ -110,57 +142,203 @@ class BIM_PT_object_documents(Panel): self.props = tool.Document.get_document_props() self.file = tool.Ifc.get() - self.draw_add_ui() - - if not ObjectDocumentData.data["documents"]: - row = self.layout.row(align=True) - row.label(text="No Documents", icon="FILE") - - for document in ObjectDocumentData.data["documents"]: - row = self.layout.row(align=True) - row.label(text=document["identification"] or "*", icon="FILE") - row.label(text=document["name"] or "Unnamed") - if document["location"]: - row.operator("bim.open_uri", icon="URL", text="").uri = document["location"] - row.operator("bim.unassign_document", text="", icon="X").document = document["id"] - - def draw_add_ui(self): - if not self.props.is_editing: - row = self.layout.row(align=True) - row.operator("bim.load_project_documents", text="Assign Document References", icon="ADD") - return + doc_count = len(ObjectDocumentData.data["documents"]) row = self.layout.row(align=True) - if self.props.breadcrumbs: - row.operator("bim.load_parent_document", text="", icon="FRAME_PREV") - row.label(text=DocumentData.data["parent_document"]) + row.label(text="{} Documents Assigned".format(doc_count), icon="FILE") + + if self.props.is_object_editing: + row.operator("bim.disable_object_document_editing_ui", text="", icon="CANCEL") else: + row.operator("bim.load_object_documents", text="", icon="IMPORT") + + if not self.props.is_object_editing and doc_count == 0: + row = self.layout.row() + row.label(text="No documents assigned", icon="INFO") + return + + if self.props.is_object_editing: + self.draw_add_ui() + if doc_count > 0: + box = self.layout.box() + row = box.row(align=True) + row.label(text="Assigned Documents", icon="OUTLINER_OB_EMPTY") + + box.template_list( + "BIM_UL_assigned_documents", + "", + self.props, + "assigned_documents", + self.props, + "active_assigned_document_index", + ) + + def draw_add_ui(self): + if self.props.is_object_editing: + row = self.layout.row(align=True) row.alignment = "RIGHT" - if self.props.documents and self.props.active_document_index < len(self.props.documents): - document = self.props.documents[self.props.active_document_index] - if not document.is_information: - row.operator("bim.assign_document", text="", icon="ADD").document = document.ifc_definition_id - row.operator("bim.disable_document_editing_ui", text="", icon="CANCEL") + if self.props.documents and self.props.active_document_index < len(self.props.documents): + document = self.props.documents[self.props.active_document_index] - self.layout.template_list("BIM_UL_documents", "", self.props, "documents", self.props, "active_document_index") + assigned_doc_ids = [] + for doc in ObjectDocumentData.data["documents"]: + assigned_doc_ids.append(doc["id"]) + + if document.ifc_definition_id not in assigned_doc_ids: + doc_op = row.operator("bim.assign_document", text="", icon="BRUSH_DATA") + doc_op.document = document.ifc_definition_id # Pass the current document's ID + else: + row.label(text="", icon="CHECKMARK") + + self.layout.template_list( + "BIM_UL_documents", "", self.props, "documents", self.props, "active_document_index" + ) class BIM_UL_documents(UIList): + def draw_item(self, context, layout, data, item, icon, active_data, active_propname): + if item: + row = layout.row(align=True) + indent_depth = 0 + + if item.ifc_definition_id != -1: + if item.tree_depth > 1: + indent_depth = item.tree_depth - 1 + for i in range(indent_depth): + row.label(text="", icon="BLANK1") + if item.ifc_definition_id == -1: + row.label(text="", icon="OUTLINER_COLLECTION") + row.label(text=item.name) + return + if item.is_information and item.has_children: + op = row.operator( + "bim.toggle_document", icon="TRIA_DOWN" if item.is_expanded else "TRIA_RIGHT", text="", emboss=False + ) + op.document = item.ifc_definition_id + op.option = "Collapse" if item.is_expanded else "Expand" + elif item.is_information: + row.label(text="", icon="BLANK1") + if item.is_information: + row.label(text="", icon="FILE") + text = " - ".join([x for x in [item.name, item.location] if x]) + else: + row.label(text="", icon="FILE_HIDDEN") + text = " - ".join([x for x in [item.description, item.location] if x]) + split1 = row.split(factor=0.1) + split1.prop(item, "identification", text="", emboss=False) + split2 = split1.split(factor=0.8) + split2.label(text=text) + + if item.location: + if item.location.lower().endswith(".ifc"): + row.operator("bim.open_ifc_document", icon="HIDE_OFF", text="").uri = item.location + row.operator("bim.open_uri", icon="URL", text="").uri = item.location + + +class BIM_UL_document_objects(UIList): + def draw_item(self, context, layout, data, item, icon, active_data, active_propname): + if item: + row = layout.row(align=True) + row.prop(item, "name", text="", emboss=False, icon="OBJECT_DATA") + row.operator("bim.select_object", text="", icon="RESTRICT_SELECT_OFF").obj_name = item.name + + props = tool.Document.get_document_props() + if props.documents and props.active_document_index < len(props.documents): + document = props.documents[props.active_document_index] + + op = row.operator("bim.unassign_document", text="", icon="X") + op.document = document.ifc_definition_id + op.obj = item.name + + +class BIM_UL_assigned_documents(UIList): def draw_item(self, context, layout, data, item, icon, active_data, active_propname): if item: row = layout.row(align=True) if item.is_information: - op = row.operator("bim.load_document", text="", emboss=False, icon="DISCLOSURE_TRI_RIGHT") - op.document = item.ifc_definition_id row.label(text="", icon="FILE") else: - row.label(text="", icon="BLANK1") row.label(text="", icon="FILE_HIDDEN") - split1 = row.split(factor=0.1) - # split1.label(text=item.identification) - split1.prop(item, "identification", text="", emboss=False) - split2 = split1.split(factor=0.9) - split2.prop(item, "name", text="", emboss=False) + split1 = row.split(factor=0.2) + split1.label(text=item.identification or "") + + split2 = split1.split(factor=1.0) + if item.is_information: + split2.label(text=item.name or "Unnamed") + else: + split2.label(text=item.description or "No Description") + + if item.location: + if item.location.lower().endswith(".ifc"): + row.operator("bim.open_ifc_document", icon="HIDE_OFF", text="").uri = item.location + row.operator("bim.open_uri", icon="URL", text="").uri = item.location + op = row.operator("bim.unassign_document", text="", icon="X") + op.document = item.ifc_definition_id + + +def add_object_documents_context_menu(self, context): + if not context.active_object: + return + + if not tool.Blender.get_ifc_definition_id(context.active_object): + return + + self.layout.separator() + self.layout.menu("BIM_MT_object_documents_context_menu", icon="FILE") + + +class BIM_MT_object_documents_context_menu(bpy.types.Menu): + bl_idname = "BIM_MT_object_documents_context_menu" + bl_label = "Documents" + + def draw(self, context): + layout = self.layout + + if not context.selected_objects: + layout.label(text="No documents", icon="INFO") + return + + if len(context.selected_objects) > 1: + layout.label(text="Select a single object to see its referenced documents", icon="INFO") + return + + obj = context.active_object + if not obj or not tool.Blender.get_ifc_definition_id(obj): + layout.label(text="No documents", icon="INFO") + return + + if not ObjectDocumentData.is_loaded: + ObjectDocumentData.load() + + if not ObjectDocumentData.data["documents"]: + layout.label(text="No Documents", icon="FILE") + else: + for document in ObjectDocumentData.data["documents"]: + row = layout.row(align=True) + + with_ifc_icon = document["location"] and document["location"].lower().endswith(".ifc") + with_url_icon = bool(document["location"]) + + if with_ifc_icon: + row.operator("bim.open_ifc_document", icon="HIDE_OFF", text="").uri = document["location"] + else: + row.label(text="", icon="BLANK1") + + if with_url_icon: + row.operator("bim.open_uri", icon="URL", text="").uri = document["location"] + else: + row.label(text="", icon="BLANK1") + + doc_entity = None + if "id" in document: + doc_entity = tool.Ifc.get().by_id(document["id"]) + + if doc_entity and doc_entity.is_a("IfcDocumentReference"): + display_text = document.get("description") or "" + else: + display_text = document.get("name") or "" + + row.label(text=f"{document['identification'] or ''}: {display_text}") diff --git a/src/bonsai/bonsai/core/document.py b/src/bonsai/bonsai/core/document.py index e82360dbb9..64de5799ed 100644 --- a/src/bonsai/bonsai/core/document.py +++ b/src/bonsai/bonsai/core/document.py @@ -21,6 +21,7 @@ from typing import TYPE_CHECKING, Optional if TYPE_CHECKING: import bpy + import json import ifcopenshell import bonsai.tool as tool @@ -28,28 +29,22 @@ if TYPE_CHECKING: def load_project_documents(document: tool.Document) -> None: document.clear_document_tree() document.import_project_documents() - document.clear_breadcrumbs() document.enable_editing_ui() def load_document(document_tool: tool.Document, document: ifcopenshell.entity_instance) -> None: document_tool.clear_document_tree() - document_tool.import_subdocuments(document) - document_tool.import_references(document) + try: + expanded_docs = json.loads(bpy.context.scene.ExpandedDocuments.json_string) + except (AttributeError, json.JSONDecodeError): + expanded_docs = [] + + if document.id() not in expanded_docs: + expanded_docs.append(document.id()) + bpy.context.scene.ExpandedDocuments.json_string = json.dumps(expanded_docs) + + document_tool.import_project_documents() document_tool.disable_editing_document() - document_tool.add_breadcrumb(document) - - -def load_parent_document(document: tool.Document) -> None: - document.clear_document_tree() - document.remove_latest_breadcrumb() - parent = document.get_active_breadcrumb() - if parent: - document.import_subdocuments(parent) - document.import_references(parent) - document.disable_editing_document() - else: - document.import_project_documents() def disable_document_editing_ui(document: tool.Document) -> None: @@ -58,33 +53,62 @@ def disable_document_editing_ui(document: tool.Document) -> None: def enable_editing_document(document_tool: tool.Document, document: ifcopenshell.entity_instance) -> None: + props = document_tool.get_document_props() + props.active_document_id = document.id() + props.is_document_editing = True document_tool.import_document_attributes(document) - document_tool.set_active_document(document) def disable_editing_document(document: tool.Document) -> None: - document.disable_editing_document() + props = document.get_document_props() + props.active_document_id = 0 + props.is_document_editing = False + props.document_attributes.clear() -def add_information(ifc: tool.Ifc, document: tool.Document) -> None: - document.clear_document_tree() - parent = document.get_active_breadcrumb() +def add_information(ifc: tool.Ifc, document_tool: tool.Document, parent=None) -> ifcopenshell.entity_instance: + document_tool.clear_document_tree() + + if parent is None and ifc.get().by_type("IfcProject"): + parent = ifc.get().by_type("IfcProject")[0] + information = ifc.run("document.add_information", parent=parent) ifc.run("document.add_reference", information=information) - if parent: - document.import_subdocuments(parent) - document.import_references(parent) - else: - document.import_project_documents() + if parent and parent.is_a("IfcDocumentInformation"): + try: + expanded_docs = json.loads(bpy.context.scene.ExpandedDocuments.json_string) + except (AttributeError, json.JSONDecodeError): + expanded_docs = [] + + if parent.id() not in expanded_docs: + expanded_docs.append(parent.id()) + bpy.context.scene.ExpandedDocuments.json_string = json.dumps(expanded_docs) + + document_tool.import_project_documents() def add_reference(ifc: tool.Ifc, document: tool.Document) -> None: - parent = document.get_active_breadcrumb() - assert parent - ifc.run("document.add_reference", information=parent) - document.clear_document_tree() - document.import_subdocuments(parent) - document.import_references(parent) + props = document.get_document_props() + parent = None + + if props.documents and props.active_document_index < len(props.documents): + selected_document = props.documents[props.active_document_index] + if selected_document.is_information: + parent = ifc.get().by_id(selected_document.ifc_definition_id) + + if parent: + reference = ifc.run("document.add_reference", information=parent) + reference.Location = "" + try: + expanded_docs = json.loads(bpy.context.scene.ExpandedDocuments.json_string) + except (AttributeError, json.JSONDecodeError): + expanded_docs = [] + + if parent.id() not in expanded_docs: + expanded_docs.append(parent.id()) + bpy.context.scene.ExpandedDocuments.json_string = json.dumps(expanded_docs) + + document.import_project_documents() def edit_document(ifc: tool.Ifc, document_tool: tool.Document, document: ifcopenshell.entity_instance) -> None: @@ -95,12 +119,7 @@ def edit_document(ifc: tool.Ifc, document_tool: tool.Document, document: ifcopen ifc.run("document.edit_reference", reference=document, attributes=attributes) document_tool.disable_editing_document() document_tool.clear_document_tree() - parent = document_tool.get_active_breadcrumb() - if parent: - document_tool.import_subdocuments(parent) - document_tool.import_references(parent) - else: - document_tool.import_project_documents() + document_tool.import_project_documents() def remove_document(ifc: tool.Ifc, document_tool: tool.Document, document: ifcopenshell.entity_instance) -> None: @@ -109,12 +128,7 @@ def remove_document(ifc: tool.Ifc, document_tool: tool.Document, document: ifcop ifc.run("document.remove_information", information=document) else: ifc.run("document.remove_reference", reference=document) - parent = document_tool.get_active_breadcrumb() - if parent: - document_tool.import_subdocuments(parent) - document_tool.import_references(parent) - else: - document_tool.import_project_documents() + document_tool.import_project_documents() def assign_document( diff --git a/src/bonsai/bonsai/core/tool.py b/src/bonsai/bonsai/core/tool.py index d8e4dd2396..4061914bee 100644 --- a/src/bonsai/bonsai/core/tool.py +++ b/src/bonsai/bonsai/core/tool.py @@ -287,20 +287,14 @@ class Debug: @interface class Document: - def add_breadcrumb(cls, document): pass - def clear_breadcrumbs(cls): pass def clear_document_tree(cls): pass def disable_editing_document(cls): pass def disable_editing_ui(cls): pass def enable_editing_ui(cls): pass def export_document_attributes(cls): pass - def get_active_breadcrumb(cls): pass def import_document_attributes(cls, document): pass def import_project_documents(cls): pass - def import_references(cls, document): pass - def import_subdocuments(cls, document): pass def is_document_information(cls, document): pass - def remove_latest_breadcrumb(cls): pass def set_active_document(cls, document): pass diff --git a/src/bonsai/bonsai/tool/document.py b/src/bonsai/bonsai/tool/document.py index 3a2f89aa94..5bb6ad813a 100644 --- a/src/bonsai/bonsai/tool/document.py +++ b/src/bonsai/bonsai/tool/document.py @@ -18,6 +18,7 @@ from __future__ import annotations import bpy +import json import ifcopenshell.util.system import bonsai.bim.helper import bonsai.core.tool @@ -33,17 +34,6 @@ class Document(bonsai.core.tool.Document): def get_document_props(cls) -> BIMDocumentProperties: return bpy.context.scene.BIMDocumentProperties - @classmethod - def add_breadcrumb(cls, document: ifcopenshell.entity_instance) -> None: - props = cls.get_document_props() - new = props.breadcrumbs.add() - new.name = str(document.id()) - - @classmethod - def clear_breadcrumbs(cls) -> None: - props = cls.get_document_props() - props.breadcrumbs.clear() - @classmethod def clear_document_tree(cls) -> None: props = cls.get_document_props() @@ -69,18 +59,15 @@ class Document(bonsai.core.tool.Document): props = cls.get_document_props() return bonsai.bim.helper.export_attributes(props.document_attributes) - @classmethod - def get_active_breadcrumb(cls) -> Union[ifcopenshell.entity_instance, None]: - props = cls.get_document_props() - if len(props.breadcrumbs): - return tool.Ifc.get().by_id(int(props.breadcrumbs[-1].name)) - @classmethod def import_document_attributes(cls, document: ifcopenshell.entity_instance) -> None: props = cls.get_document_props() props.document_attributes.clear() - def callback(attr_name: str, _, data: dict[str, Any]) -> Union[bool, None]: + def callback(attr_name: str, attr_value: Any, data: dict[str, Any]) -> Union[bool, None]: + if attr_name == "Location" and attr_value is None: + data[attr_name] = "" + return True if attr_name != "Name": return None # Proceed normally @@ -100,52 +87,138 @@ class Document(bonsai.core.tool.Document): def import_project_documents(cls) -> None: props = cls.get_document_props() props.documents.clear() - project = tool.Ifc.get().by_type("IfcProject")[0] + file = tool.Ifc.get() + try: + expanded_documents = json.loads(bpy.context.scene.ExpandedDocuments.json_string) + except (AttributeError, json.JSONDecodeError): + expanded_documents = [] + + project = file.by_type("IfcProject")[0] if file.by_type("IfcProject") else None + if not project: + return + + document_children = {} + + for rel in file.by_type("IfcDocumentInformationRelationship"): + parent_id = rel.RelatingDocument.id() + if parent_id not in document_children: + document_children[parent_id] = [] + + for child in rel.RelatedDocuments: + document_children[parent_id].append(child) + + is_ifc2x3 = file.schema == "IFC2X3" + + if is_ifc2x3: + for ref in file.by_type("IfcDocumentReference"): + if ref.ReferenceToDocument: + parent = ref.ReferenceToDocument[0] + parent_id = parent.id() + if parent_id not in document_children: + document_children[parent_id] = [] + document_children[parent_id].append(ref) + else: + for ref in file.by_type("IfcDocumentReference"): + if hasattr(ref, "ReferencedDocument") and ref.ReferencedDocument: + parent = ref.ReferencedDocument + parent_id = parent.id() + if parent_id not in document_children: + document_children[parent_id] = [] + document_children[parent_id].append(ref) + + root_documents = [] for rel in project.HasAssociations or []: if rel.is_a("IfcRelAssociatesDocument") and rel.RelatingDocument.is_a("IfcDocumentInformation"): - element = rel.RelatingDocument - new = props.documents.add() - new.ifc_definition_id = element.id() - new["name"] = element.Name or "Unnamed" - new.is_information = True - new["identification"] = cls.get_document_information_id(element) + is_child = False + for children in document_children.values(): + if rel.RelatingDocument in children: + is_child = True + break + + if not is_child: + root_documents.append(rel.RelatingDocument) + + root = props.documents.add() + root.ifc_definition_id = -1 + root.is_information = True + root.name = f"Project Documents ({project.Name or 'Unnamed Project'})" + root.identification = "" + root.location = "" + root.tree_depth = 0 + root.has_children = bool(root_documents) + + root_id = -project.id() + + root.is_expanded = root_id not in expanded_documents + + if root.is_expanded: + root_documents.sort( + key=lambda doc: ((cls.get_document_information_id(doc) or "").lower(), (doc.Name or "").lower()) + ) + + for doc in root_documents: + cls._process_document(doc, props, document_children, expanded_documents, 1) @classmethod - def import_references(cls, document: ifcopenshell.entity_instance) -> None: - props = cls.get_document_props() - is_ifc2x3 = tool.Ifc.get_schema() == "IFC2X3" - references = cls.get_document_references(document) - for element in references: - new = props.documents.add() - new.ifc_definition_id = element.id() - # Use Description + Location instead of Name as IFC has a restriction - # for IfcDocumentReference to have Name only if it has no ReferencedDocument. - name = " - ".join([x for x in [element.Description, element.Location] if x]) - new["name"] = name or "Unnamed" - new["identification"] = cls.get_external_reference_id(element) - new.is_information = False + def _process_document(cls, document, props, document_children, expanded_documents, depth): + new = props.documents.add() + new.ifc_definition_id = document.id() + new.is_information = document.is_a("IfcDocumentInformation") + new.tree_depth = depth - @classmethod - def import_subdocuments(cls, document: ifcopenshell.entity_instance) -> None: - props = cls.get_document_props() - if document.IsPointer: - for element in document.IsPointer[0].RelatedDocuments or []: - new = props.documents.add() - new.ifc_definition_id = element.id() - new["name"] = element.Name or "Unnamed" - new.is_information = True - new["identification"] = cls.get_document_information_id(element) or "*" + file = document.file + if new.is_information: + new.name = document.Name or "Unnamed" + new.identification = cls.get_document_information_id(document) or "" + new.location = document.Location or "" + else: + new.name = document.Name or "" + new.identification = cls.get_external_reference_id(document) or "" + new.description = document.Description or "" + new.location = document.Location or "" + + if not new.is_information: + if file.schema == "IFC2X3": + if document.ReferenceToDocument: + doc_info = document.ReferenceToDocument[0] + if not new.name: + new.name = doc_info.Name or "" + new.location = new.location or "" + else: + if hasattr(document, "ReferencedDocument") and document.ReferencedDocument: + doc_info = document.ReferencedDocument + if not new.name: + new.name = doc_info.Name or "" + new.location = new.location or "" + + doc_id = document.id() + has_children = doc_id in document_children and bool(document_children[doc_id]) + new.has_children = has_children + new.is_expanded = doc_id in expanded_documents + + if has_children and new.is_expanded: + children = document_children[doc_id] + + children.sort( + key=lambda doc: ( + doc.is_a("IfcDocumentInformation"), + ( + cls.get_document_information_id(doc) + if doc.is_a("IfcDocumentInformation") + else cls.get_external_reference_id(doc) or "" + ).lower(), + (doc.Name or "").lower(), + ), + reverse=True, + ) + + for child in children: + cls._process_document(child, props, document_children, expanded_documents, depth + 1) @classmethod def is_document_information(cls, document: ifcopenshell.entity_instance) -> bool: return document.is_a("IfcDocumentInformation") - @classmethod - def remove_latest_breadcrumb(cls) -> None: - props = cls.get_document_props() - if len(props.breadcrumbs): - props.breadcrumbs.remove(len(props.breadcrumbs) - 1) - @classmethod def set_active_document(cls, document: ifcopenshell.entity_instance) -> None: props = cls.get_document_props() diff --git a/src/bonsai/test/bim/feature/document.feature b/src/bonsai/test/bim/feature/document.feature index 9b4a34cfb4..9ef9657499 100644 --- a/src/bonsai/test/bim/feature/document.feature +++ b/src/bonsai/test/bim/feature/document.feature @@ -14,15 +14,6 @@ Scenario: Load document When I press "bim.load_document(document={information})" Then nothing happens -Scenario: Load parent document - Given an empty IFC project - And I press "bim.load_project_documents" - And I press "bim.add_information" - And the variable "information" is "{ifc}.by_type('IfcDocumentInformation')[-1].id()" - And I press "bim.load_document(document={information})" - When I press "bim.load_parent_document" - Then nothing happens - Scenario: Disable document editing UI Given an empty IFC project And I press "bim.load_project_documents" diff --git a/src/bonsai/test/core/test_document.py b/src/bonsai/test/core/test_document.py index 48421c1fe4..ab3563551d 100644 --- a/src/bonsai/test/core/test_document.py +++ b/src/bonsai/test/core/test_document.py @@ -25,7 +25,6 @@ class TestLoadProjectDocuments: def test_run(self, document): document.clear_document_tree().should_be_called() document.import_project_documents().should_be_called() - document.clear_breadcrumbs().should_be_called() document.enable_editing_ui().should_be_called() subject.load_project_documents(document) @@ -33,8 +32,6 @@ class TestLoadProjectDocuments: class TestLoadDocument: def test_run(self, document): document.clear_document_tree().should_be_called() - document.import_subdocuments("document").should_be_called() - document.import_references("document").should_be_called() document.disable_editing_document().should_be_called() document.add_breadcrumb("document").should_be_called() subject.load_document(document, document="document") @@ -63,7 +60,6 @@ class TestDisableEditingDocument: class TestAddInformation: def test_add_and_reload_tree_at_project_root(self, ifc, document): document.clear_document_tree().should_be_called() - document.get_active_breadcrumb().should_be_called().will_return(None) ifc.run("document.add_information", parent=None).should_be_called().will_return("information") ifc.run("document.add_reference", information="information").should_be_called() document.import_project_documents().should_be_called() @@ -71,21 +67,15 @@ class TestAddInformation: def test_add_and_reload_tree_at_current_parent(self, ifc, document): document.clear_document_tree().should_be_called() - document.get_active_breadcrumb().should_be_called().will_return("parent") ifc.run("document.add_information", parent="parent").should_be_called().will_return("information") ifc.run("document.add_reference", information="information").should_be_called() - document.import_subdocuments("parent").should_be_called() - document.import_references("parent").should_be_called() subject.add_information(ifc, document) class TestAddReference: def test_run(self, ifc, document): - document.get_active_breadcrumb().should_be_called().will_return("parent") ifc.run("document.add_reference", information="parent").should_be_called() document.clear_document_tree().should_be_called() - document.import_subdocuments("parent").should_be_called() - document.import_references("parent").should_be_called() subject.add_reference(ifc, document) @@ -96,7 +86,6 @@ class TestEditDocument: ifc.run("document.edit_information", information="document", attributes="attributes").should_be_called() document.disable_editing_document().should_be_called() document.clear_document_tree().should_be_called() - document.get_active_breadcrumb().should_be_called().will_return(None) document.import_project_documents().should_be_called() subject.edit_document(ifc, document, document="document") @@ -106,9 +95,6 @@ class TestEditDocument: ifc.run("document.edit_reference", reference="document", attributes="attributes").should_be_called() document.disable_editing_document().should_be_called() document.clear_document_tree().should_be_called() - document.get_active_breadcrumb().should_be_called().will_return("parent") - document.import_subdocuments("parent").should_be_called() - document.import_references("parent").should_be_called() subject.edit_document(ifc, document, document="document") @@ -117,7 +103,6 @@ class TestRemoveDocument: document.clear_document_tree().should_be_called() document.is_document_information("document").should_be_called().will_return(True) ifc.run("document.remove_information", information="document").should_be_called() - document.get_active_breadcrumb().should_be_called().will_return(None) document.import_project_documents().should_be_called() subject.remove_document(ifc, document, document="document") @@ -125,9 +110,6 @@ class TestRemoveDocument: document.clear_document_tree().should_be_called() document.is_document_information("document").should_be_called().will_return(False) ifc.run("document.remove_reference", reference="document").should_be_called() - document.get_active_breadcrumb().should_be_called().will_return("parent") - document.import_subdocuments("parent").should_be_called() - document.import_references("parent").should_be_called() subject.remove_document(ifc, document, document="document") diff --git a/src/bonsai/test/tool/test_document.py b/src/bonsai/test/tool/test_document.py index 9e3556afd5..3d313f6293 100644 --- a/src/bonsai/test/tool/test_document.py +++ b/src/bonsai/test/tool/test_document.py @@ -31,24 +31,6 @@ class TestImplementsTool(NewFile): assert isinstance(subject(), bonsai.core.tool.Document) -class TestAddBreadcrumb(NewFile): - def test_run(self): - ifc = ifcopenshell.file() - tool.Ifc().set(ifc) - document = ifc.createIfcDocumentInformation() - subject.add_breadcrumb(document) - props = tool.Document.get_document_props() - assert props.breadcrumbs[0].name == str(document.id()) - - -class TestClearBreadcrumbs(NewFile): - def test_run(self): - props = tool.Document.get_document_props() - props.breadcrumbs.add() - subject.clear_breadcrumbs() - assert len(props.breadcrumbs) == 0 - - class TestClearDocumentTree(NewFile): def test_run(self): props = tool.Document.get_document_props() @@ -103,15 +85,6 @@ class TestExportDocumentAttributes(NewFile): } -class TestGetActiveBreadcrumb(NewFile): - def test_run(self): - ifc = ifcopenshell.file() - tool.Ifc().set(ifc) - document = ifc.createIfcDocumentInformation() - subject.add_breadcrumb(document) - assert subject.get_active_breadcrumb() == document - - class TestImportDocumentAttributes(NewFile): def test_importing_information(self): ifc = ifcopenshell.file() @@ -197,22 +170,6 @@ class TestImportReferences(NewFile): assert props.documents[0].is_information is False -class TestImportSubdocuments(NewFile): - def test_run(self): - ifc = ifcopenshell.file() - tool.Ifc().set(ifc) - ifc.createIfcProject() - document = ifcopenshell.api.document.add_information(ifc) - subdocument = ifcopenshell.api.document.add_information(ifc, parent=document) - subject.import_subdocuments(document) - props = tool.Document.get_document_props() - assert len(props.documents) == 1 - assert props.documents[0].ifc_definition_id == subdocument.id() - assert props.documents[0].name == "Unnamed" - assert props.documents[0].identification == "X" - assert props.documents[0].is_information is True - - class TestIsDocumentInformation(NewFile): def test_run(self): ifc = ifcopenshell.file() @@ -222,15 +179,6 @@ class TestIsDocumentInformation(NewFile): assert subject.is_document_information(reference) is False -class TestRemoveLatestBreadcrumb(NewFile): - def test_run(self): - props = tool.Document.get_document_props() - props.breadcrumbs.add() - props.breadcrumbs.add() - subject.remove_latest_breadcrumb() - assert len(props.breadcrumbs) == 1 - - class TestSetActiveDocument(NewFile): def test_run(self): ifc = ifcopenshell.file() From d2adfc8c5d74c88125ed7257d000e6c53cd381dc Mon Sep 17 00:00:00 2001 From: falken10 Date: Fri, 20 Jun 2025 12:30:40 +0200 Subject: [PATCH 16/49] updated document ui --- src/bonsai/bonsai/bim/helper.py | 3 ++ src/bonsai/bonsai/bim/module/document/ui.py | 13 +++++--- src/bonsai/bonsai/core/document.py | 4 +-- src/bonsai/bonsai/tool/document.py | 33 +++++++++++---------- 4 files changed, 31 insertions(+), 22 deletions(-) diff --git a/src/bonsai/bonsai/bim/helper.py b/src/bonsai/bonsai/bim/helper.py index e76b512de3..eb8d9fa6e4 100644 --- a/src/bonsai/bonsai/bim/helper.py +++ b/src/bonsai/bonsai/bim/helper.py @@ -55,6 +55,7 @@ def draw_attributes( layout: bpy.types.UILayout, copy_operator: Optional[str] = None, popup_active_attribute: Optional[bonsai.bim.prop.Attribute] = None, + filter_attributes: list[str] = None, callback: Optional[Callable[[bonsai.bim.prop.Attribute, bpy.types.UILayout], None]] = None, *, enable_search: Union[bool, EllipsisType] = ..., @@ -75,6 +76,8 @@ def draw_attributes( """ for attribute in props: + if attribute.name in (filter_attributes or []): + continue row = layout.row(align=True) if attribute == popup_active_attribute: row.activate_init = True diff --git a/src/bonsai/bonsai/bim/module/document/ui.py b/src/bonsai/bonsai/bim/module/document/ui.py index eab2d146fb..d795cccdd4 100644 --- a/src/bonsai/bonsai/bim/module/document/ui.py +++ b/src/bonsai/bonsai/bim/module/document/ui.py @@ -68,7 +68,10 @@ class BIM_PT_documents(Panel): row.operator("bim.edit_document", text="", icon="CHECKMARK") row.operator("bim.disable_editing_document", text="", icon="CANCEL") else: - row.operator("bim.add_information", text="", icon="ADD") + if not self.props.documents or not self.props.active_document_index < len(self.props.documents) or \ + (self.props.active_document_index < len(self.props.documents) and + self.props.documents[self.props.active_document_index].is_information): + row.operator("bim.add_information", text="", icon="ADD") if self.props.documents and self.props.active_document_index < len(self.props.documents): active_doc = self.props.documents[self.props.active_document_index] @@ -81,10 +84,10 @@ class BIM_PT_documents(Panel): row.operator("bim.select_document_objects", text="", icon="RESTRICT_SELECT_OFF").document = ( ifc_definition_id ) + row.operator("bim.assign_document", text="", icon="BRUSH_DATA").document = ifc_definition_id row.operator("bim.enable_editing_document", text="", icon="GREASEPENCIL").document = ifc_definition_id row.operator("bim.remove_document", text="", icon="X").document = ifc_definition_id - self.layout.template_list("BIM_UL_documents", "", self.props, "documents", self.props, "active_document_index") if self.props.is_document_editing: @@ -185,10 +188,12 @@ class BIM_PT_object_documents(Panel): for doc in ObjectDocumentData.data["documents"]: assigned_doc_ids.append(doc["id"]) - if document.ifc_definition_id not in assigned_doc_ids: + # Only show assign button if the document is information (not reference) and not already assigned + if (document.is_information and + document.ifc_definition_id not in assigned_doc_ids): doc_op = row.operator("bim.assign_document", text="", icon="BRUSH_DATA") doc_op.document = document.ifc_definition_id # Pass the current document's ID - else: + elif document.ifc_definition_id in assigned_doc_ids: row.label(text="", icon="CHECKMARK") self.layout.template_list( diff --git a/src/bonsai/bonsai/core/document.py b/src/bonsai/bonsai/core/document.py index 64de5799ed..38ca080d83 100644 --- a/src/bonsai/bonsai/core/document.py +++ b/src/bonsai/bonsai/core/document.py @@ -18,10 +18,10 @@ from __future__ import annotations from typing import TYPE_CHECKING, Optional +import bpy +import json if TYPE_CHECKING: - import bpy - import json import ifcopenshell import bonsai.tool as tool diff --git a/src/bonsai/bonsai/tool/document.py b/src/bonsai/bonsai/tool/document.py index 5bb6ad813a..d3eeda694b 100644 --- a/src/bonsai/bonsai/tool/document.py +++ b/src/bonsai/bonsai/tool/document.py @@ -69,15 +69,12 @@ class Document(bonsai.core.tool.Document): data[attr_name] = "" return True if attr_name != "Name": - return None # Proceed normally + return None current_value = data[attr_name] - # If Name is already filled, display it so user would be able to correct invalid IFC. if current_value is not None: return None - # Skip import since IFC restricts Name to be filled - # for IfcDocumentReference with ReferencedDocument. return False import_callback = callback if document.is_a("IfcDocumentReference") else None @@ -199,20 +196,24 @@ class Document(bonsai.core.tool.Document): if has_children and new.is_expanded: children = document_children[doc_id] - children.sort( + info_children = [d for d in children if d.is_a("IfcDocumentInformation")] + ref_children = [d for d in children if not d.is_a("IfcDocumentInformation")] + + info_children.sort( key=lambda doc: ( - doc.is_a("IfcDocumentInformation"), - ( - cls.get_document_information_id(doc) - if doc.is_a("IfcDocumentInformation") - else cls.get_external_reference_id(doc) or "" - ).lower(), - (doc.Name or "").lower(), - ), - reverse=True, + (cls.get_document_information_id(doc) or "").lower(), + (doc.Name or "").lower() + ) ) - - for child in children: + + ref_children.sort( + key=lambda doc: ( + (cls.get_external_reference_id(doc) or "").lower(), + (doc.Description or doc.Name or "").lower() + ) + ) + + for child in info_children + ref_children: cls._process_document(child, props, document_children, expanded_documents, depth + 1) @classmethod From aa8c146f9da9add8a00fd48c9adea26cb2d99c45 Mon Sep 17 00:00:00 2001 From: falken10 Date: Tue, 1 Jul 2025 17:34:45 +0200 Subject: [PATCH 17/49] Refactoring based on developer's feedback --- .../bonsai/bim/module/document/__init__.py | 8 +-- src/bonsai/bonsai/bim/module/document/data.py | 48 +++++++------- .../bonsai/bim/module/document/operator.py | 33 +++++----- src/bonsai/bonsai/bim/module/document/prop.py | 9 +-- src/bonsai/bonsai/bim/module/document/ui.py | 31 ++++----- src/bonsai/bonsai/core/document.py | 56 ++++------------- src/bonsai/bonsai/tool/document.py | 63 ++++++++++++++++--- 7 files changed, 122 insertions(+), 126 deletions(-) diff --git a/src/bonsai/bonsai/bim/module/document/__init__.py b/src/bonsai/bonsai/bim/module/document/__init__.py index 6a925d0f98..323520420e 100644 --- a/src/bonsai/bonsai/bim/module/document/__init__.py +++ b/src/bonsai/bonsai/bim/module/document/__init__.py @@ -18,7 +18,6 @@ import bpy from . import ui, prop, operator -from bpy.types import VIEW3D_MT_object_context_menu classes = ( operator.AddDocumentReference, @@ -41,7 +40,6 @@ classes = ( prop.Document, prop.DocumentObject, prop.AssignedDocument, - prop.ExpandedDocuments, prop.BIMDocumentProperties, ui.BIM_PT_documents, ui.BIM_PT_object_documents, @@ -54,11 +52,9 @@ classes = ( def register(): bpy.types.Scene.BIMDocumentProperties = bpy.props.PointerProperty(type=prop.BIMDocumentProperties) - bpy.types.Scene.ExpandedDocuments = bpy.props.PointerProperty(type=prop.ExpandedDocuments) - VIEW3D_MT_object_context_menu.append(ui.add_object_documents_context_menu) + bpy.types.VIEW3D_MT_object_context_menu.append(ui.add_object_documents_context_menu) def unregister(): del bpy.types.Scene.BIMDocumentProperties - del bpy.types.Scene.ExpandedDocuments - VIEW3D_MT_object_context_menu.remove(ui.add_object_documents_context_menu) + bpy.types.VIEW3D_MT_object_context_menu.remove(ui.add_object_documents_context_menu) diff --git a/src/bonsai/bonsai/bim/module/document/data.py b/src/bonsai/bonsai/bim/module/document/data.py index be6b628bed..15a89f8870 100644 --- a/src/bonsai/bonsai/bim/module/document/data.py +++ b/src/bonsai/bonsai/bim/module/document/data.py @@ -119,13 +119,15 @@ class ObjectDocumentData: return results for rel in getattr(element, "HasAssociations", []): if rel.is_a("IfcRelAssociatesDocument"): - is_information = rel.RelatingDocument.is_a("IfcDocumentInformation") - is_reference = rel.RelatingDocument.is_a("IfcDocumentReference") + relating_document = rel.RelatingDocument + + is_information = relating_document.is_a("IfcDocumentInformation") + is_reference = relating_document.is_a("IfcDocumentReference") if not (is_information or is_reference): continue - name = rel.RelatingDocument.Name + name = relating_document.Name location = None identification = None @@ -133,33 +135,35 @@ class ObjectDocumentData: if is_information: if tool.Ifc.get_schema() == "IFC2X3": - identification = rel.RelatingDocument.DocumentId + identification = relating_document.DocumentId else: - identification = rel.RelatingDocument.Identification + identification = relating_document.Identification - location = getattr(rel.RelatingDocument, "Location", None) + location = getattr(relating_document, "Location", None) else: - description = rel.RelatingDocument.Description + description = relating_document.Description if tool.Ifc.get_schema() == "IFC2X3": - if not name and rel.RelatingDocument.ReferenceToDocument: - name = rel.RelatingDocument.ReferenceToDocument[0].Name + reference_to_document = relating_document.ReferenceToDocument + if not name and reference_to_document: + name = reference_to_document[0].Name - identification = rel.RelatingDocument.ItemReference - if not identification and rel.RelatingDocument.ReferenceToDocument: - identification = rel.RelatingDocument.ReferenceToDocument[0].DocumentId - location = rel.RelatingDocument.Location + identification = relating_document.ItemReference + if not identification and reference_to_document: + identification = reference_to_document[0].DocumentId + location = relating_document.Location else: - if not name and rel.RelatingDocument.ReferencedDocument: - name = rel.RelatingDocument.ReferencedDocument.Name + referenced_document = relating_document.ReferencedDocument + if not name and referenced_document: + name = referenced_document.Name - identification = rel.RelatingDocument.Identification - if not identification and rel.RelatingDocument.ReferencedDocument: - identification = rel.RelatingDocument.ReferencedDocument.Identification + identification = relating_document.Identification + if not identification and referenced_document: + identification = referenced_document.Identification - location = rel.RelatingDocument.Location - if location is None and rel.RelatingDocument.ReferencedDocument: - location = rel.RelatingDocument.ReferencedDocument.Location + location = relating_document.Location + if location is None and referenced_document: + location = referenced_document.Location if location: if not "://" in location: @@ -169,7 +173,7 @@ class ObjectDocumentData: results.append( { - "id": rel.RelatingDocument.id(), + "id": relating_document.id(), "identification": identification, "name": name, "location": location, diff --git a/src/bonsai/bonsai/bim/module/document/operator.py b/src/bonsai/bonsai/bim/module/document/operator.py index ffc81537ee..ecc95f6ac8 100644 --- a/src/bonsai/bonsai/bim/module/document/operator.py +++ b/src/bonsai/bonsai/bim/module/document/operator.py @@ -35,10 +35,8 @@ def update_document_objects(document_id=None): if document_id is None: props = tool.Document.get_document_props() - if props.documents and props.active_document_index < len(props.documents): - document = props.documents[props.active_document_index] - if document.ifc_definition_id: - document_id = document.ifc_definition_id + if props.active_document and props.active_document.ifc_definition_id: + document_id = props.active_document.ifc_definition_id if document_id: DocumentData.load_document_objects_into_props(document_id) @@ -122,8 +120,8 @@ class AddInformation(bpy.types.Operator, tool.Ifc.Operator): def _execute(self, context): props = tool.Document.get_document_props() parent = None - if props.documents and props.active_document_index < len(props.documents): - selected_document = props.documents[props.active_document_index] + if props.active_document: + selected_document = props.active_document if selected_document.ifc_definition_id == -1: parent = tool.Ifc.get().by_type("IfcProject")[0] if tool.Ifc.get().by_type("IfcProject") else None @@ -139,7 +137,7 @@ class AddInformation(bpy.types.Operator, tool.Ifc.Operator): expanded_docs = [] try: - expanded_docs = json.loads(context.scene.ExpandedDocuments.json_string) + expanded_docs = json.loads(props.json_string) except (AttributeError, json.JSONDecodeError): pass @@ -152,7 +150,7 @@ class AddInformation(bpy.types.Operator, tool.Ifc.Operator): if parent.id() not in expanded_docs: expanded_docs.append(parent.id()) - context.scene.ExpandedDocuments.json_string = json.dumps(expanded_docs) + props.json_string = json.dumps(expanded_docs) bpy.ops.bim.load_project_documents() @@ -165,11 +163,11 @@ class AddDocumentReference(bpy.types.Operator, tool.Ifc.Operator): def _execute(self, context): props = tool.Document.get_document_props() - if not props.documents or props.active_document_index >= len(props.documents): + if not props.active_document: self.report({"ERROR"}, "No document selected") return {"CANCELLED"} - selected_document = props.documents[props.active_document_index] + selected_document = props.active_document if not selected_document.is_information: self.report({"ERROR"}, "Cannot add a reference to a reference element") @@ -181,13 +179,13 @@ class AddDocumentReference(bpy.types.Operator, tool.Ifc.Operator): core.add_reference(tool.Ifc, tool.Document) expanded_docs = [] try: - expanded_docs = json.loads(context.scene.ExpandedDocuments.json_string) + expanded_docs = json.loads(props.json_string) except (AttributeError, json.JSONDecodeError): pass if parent.id() not in expanded_docs: expanded_docs.append(parent.id()) - context.scene.ExpandedDocuments.json_string = json.dumps(expanded_docs) + props.json_string = json.dumps(expanded_docs) bpy.ops.bim.load_project_documents() @@ -292,9 +290,8 @@ class UnassignDocument(bpy.types.Operator, tool.Ifc.Operator): core.unassign_document(tool.Ifc, product=element, document=document) props = tool.Document.get_document_props() active_document_id = None - if props.documents and props.active_document_index < len(props.documents): - active_document = props.documents[props.active_document_index] - active_document_id = active_document.ifc_definition_id + if props.active_document: + active_document_id = props.active_document.ifc_definition_id if active_document_id and active_document_id != self.document: update_document_objects(active_document_id) @@ -420,8 +417,9 @@ class ToggleDocument(bpy.types.Operator, tool.Ifc.Operator): def _execute(self, context): expanded_documents = [] + props = tool.Document.get_document_props() try: - expanded_documents = json.loads(context.scene.ExpandedDocuments.json_string) + expanded_documents = json.loads(props.json_string) except (AttributeError, json.JSONDecodeError): expanded_documents = [] @@ -439,8 +437,7 @@ class ToggleDocument(bpy.types.Operator, tool.Ifc.Operator): expanded_documents.append(virtual_root_id) elif self.option == "Collapse" and virtual_root_id in expanded_documents: expanded_documents.remove(virtual_root_id) - - context.scene.ExpandedDocuments.json_string = json.dumps(expanded_documents) + props.json_string = json.dumps(expanded_documents) bpy.ops.bim.load_project_documents() return {"FINISHED"} diff --git a/src/bonsai/bonsai/bim/module/document/prop.py b/src/bonsai/bonsai/bim/module/document/prop.py index 88878f8bfc..ce4a6a83cc 100644 --- a/src/bonsai/bonsai/bim/module/document/prop.py +++ b/src/bonsai/bonsai/bim/module/document/prop.py @@ -80,13 +80,6 @@ class Document(PropertyGroup): is_expanded: bool -class ExpandedDocuments(PropertyGroup): - json_string: StringProperty(name="JSON String", default="[]") - - if TYPE_CHECKING: - json_string: str - - class DocumentObject(PropertyGroup): name: StringProperty(name="Name") ifc_definition_id: IntProperty(name="IFC Definition ID") @@ -124,6 +117,7 @@ class BIMDocumentProperties(PropertyGroup): active_document_object_index: IntProperty(name="Active Document Object Index") assigned_documents: CollectionProperty(name="Assigned Documents", type=AssignedDocument) active_assigned_document_index: IntProperty(name="Active Assigned Document Index") + json_string: StringProperty(name="JSON String", default="[]") if TYPE_CHECKING: document_attributes: bpy.types.bpy_prop_collection_idprop[Attribute] @@ -137,6 +131,7 @@ class BIMDocumentProperties(PropertyGroup): active_document_object_index: int assigned_documents: bpy.types.bpy_prop_collection_idprop[AssignedDocument] active_assigned_document_index: int + json_string: str @property def active_document(self) -> Union[Document, None]: diff --git a/src/bonsai/bonsai/bim/module/document/ui.py b/src/bonsai/bonsai/bim/module/document/ui.py index d795cccdd4..bad9819205 100644 --- a/src/bonsai/bonsai/bim/module/document/ui.py +++ b/src/bonsai/bonsai/bim/module/document/ui.py @@ -68,14 +68,11 @@ class BIM_PT_documents(Panel): row.operator("bim.edit_document", text="", icon="CHECKMARK") row.operator("bim.disable_editing_document", text="", icon="CANCEL") else: - if not self.props.documents or not self.props.active_document_index < len(self.props.documents) or \ - (self.props.active_document_index < len(self.props.documents) and - self.props.documents[self.props.active_document_index].is_information): + if not self.props.active_document or self.props.active_document.is_information: row.operator("bim.add_information", text="", icon="ADD") - if self.props.documents and self.props.active_document_index < len(self.props.documents): - active_doc = self.props.documents[self.props.active_document_index] - if active_doc.is_information and active_doc.ifc_definition_id != -1: + if self.props.active_document: + if self.props.active_document.is_information and self.props.active_document.ifc_definition_id != -1: row.operator("bim.add_document_reference", text="", icon="FILE_HIDDEN") active_document = self.props.active_document @@ -84,7 +81,7 @@ class BIM_PT_documents(Panel): row.operator("bim.select_document_objects", text="", icon="RESTRICT_SELECT_OFF").document = ( ifc_definition_id ) - + row.operator("bim.assign_document", text="", icon="BRUSH_DATA").document = ifc_definition_id row.operator("bim.enable_editing_document", text="", icon="GREASEPENCIL").document = ifc_definition_id row.operator("bim.remove_document", text="", icon="X").document = ifc_definition_id @@ -97,12 +94,8 @@ class BIM_PT_documents(Panel): else: draw_attributes(self.props.document_attributes, self.layout, filter_attributes=["Name"]) - if ( - self.props.is_editing - and self.props.documents - and self.props.active_document_index < len(self.props.documents) - ): - document = self.props.documents[self.props.active_document_index] + if self.props.is_editing and self.props.active_document: + document = self.props.active_document box = self.layout.box() row = box.row(align=True) row.label(text="Assigned Objects", icon="OUTLINER_OB_EMPTY") @@ -181,21 +174,19 @@ class BIM_PT_object_documents(Panel): row = self.layout.row(align=True) row.alignment = "RIGHT" - if self.props.documents and self.props.active_document_index < len(self.props.documents): - document = self.props.documents[self.props.active_document_index] + if self.props.active_document: + document = self.props.active_document assigned_doc_ids = [] for doc in ObjectDocumentData.data["documents"]: assigned_doc_ids.append(doc["id"]) # Only show assign button if the document is information (not reference) and not already assigned - if (document.is_information and - document.ifc_definition_id not in assigned_doc_ids): + if document.is_information and document.ifc_definition_id not in assigned_doc_ids: doc_op = row.operator("bim.assign_document", text="", icon="BRUSH_DATA") doc_op.document = document.ifc_definition_id # Pass the current document's ID elif document.ifc_definition_id in assigned_doc_ids: row.label(text="", icon="CHECKMARK") - self.layout.template_list( "BIM_UL_documents", "", self.props, "documents", self.props, "active_document_index" ) @@ -249,8 +240,8 @@ class BIM_UL_document_objects(UIList): row.operator("bim.select_object", text="", icon="RESTRICT_SELECT_OFF").obj_name = item.name props = tool.Document.get_document_props() - if props.documents and props.active_document_index < len(props.documents): - document = props.documents[props.active_document_index] + if props.active_document: + document = props.active_document op = row.operator("bim.unassign_document", text="", icon="X") op.document = document.ifc_definition_id diff --git a/src/bonsai/bonsai/core/document.py b/src/bonsai/bonsai/core/document.py index 38ca080d83..56d92ea613 100644 --- a/src/bonsai/bonsai/core/document.py +++ b/src/bonsai/bonsai/core/document.py @@ -18,8 +18,6 @@ from __future__ import annotations from typing import TYPE_CHECKING, Optional -import bpy -import json if TYPE_CHECKING: import ifcopenshell @@ -34,15 +32,7 @@ def load_project_documents(document: tool.Document) -> None: def load_document(document_tool: tool.Document, document: ifcopenshell.entity_instance) -> None: document_tool.clear_document_tree() - try: - expanded_docs = json.loads(bpy.context.scene.ExpandedDocuments.json_string) - except (AttributeError, json.JSONDecodeError): - expanded_docs = [] - - if document.id() not in expanded_docs: - expanded_docs.append(document.id()) - bpy.context.scene.ExpandedDocuments.json_string = json.dumps(expanded_docs) - + document_tool.expand_document(document) document_tool.import_project_documents() document_tool.disable_editing_document() @@ -53,60 +43,40 @@ def disable_document_editing_ui(document: tool.Document) -> None: def enable_editing_document(document_tool: tool.Document, document: ifcopenshell.entity_instance) -> None: - props = document_tool.get_document_props() - props.active_document_id = document.id() - props.is_document_editing = True + document_tool.set_active_document(document) + document_tool.enable_document_editing() document_tool.import_document_attributes(document) def disable_editing_document(document: tool.Document) -> None: - props = document.get_document_props() - props.active_document_id = 0 - props.is_document_editing = False - props.document_attributes.clear() + document.clear_active_document() + document.disable_document_editing() + document.clear_document_attributes() def add_information(ifc: tool.Ifc, document_tool: tool.Document, parent=None) -> ifcopenshell.entity_instance: document_tool.clear_document_tree() - if parent is None and ifc.get().by_type("IfcProject"): - parent = ifc.get().by_type("IfcProject")[0] + if parent is None: + parent = document_tool.get_default_parent_for_information(ifc) information = ifc.run("document.add_information", parent=parent) ifc.run("document.add_reference", information=information) - if parent and parent.is_a("IfcDocumentInformation"): - try: - expanded_docs = json.loads(bpy.context.scene.ExpandedDocuments.json_string) - except (AttributeError, json.JSONDecodeError): - expanded_docs = [] - if parent.id() not in expanded_docs: - expanded_docs.append(parent.id()) - bpy.context.scene.ExpandedDocuments.json_string = json.dumps(expanded_docs) + if document_tool.is_document_information(parent): + document_tool.expand_document(parent) document_tool.import_project_documents() + return information def add_reference(ifc: tool.Ifc, document: tool.Document) -> None: - props = document.get_document_props() - parent = None - - if props.documents and props.active_document_index < len(props.documents): - selected_document = props.documents[props.active_document_index] - if selected_document.is_information: - parent = ifc.get().by_id(selected_document.ifc_definition_id) + parent = document.get_selected_document_information(ifc) if parent: reference = ifc.run("document.add_reference", information=parent) reference.Location = "" - try: - expanded_docs = json.loads(bpy.context.scene.ExpandedDocuments.json_string) - except (AttributeError, json.JSONDecodeError): - expanded_docs = [] - - if parent.id() not in expanded_docs: - expanded_docs.append(parent.id()) - bpy.context.scene.ExpandedDocuments.json_string = json.dumps(expanded_docs) + document.expand_document(parent) document.import_project_documents() diff --git a/src/bonsai/bonsai/tool/document.py b/src/bonsai/bonsai/tool/document.py index d3eeda694b..76ceaf52c3 100644 --- a/src/bonsai/bonsai/tool/document.py +++ b/src/bonsai/bonsai/tool/document.py @@ -23,6 +23,7 @@ import ifcopenshell.util.system import bonsai.bim.helper import bonsai.core.tool import bonsai.tool as tool +import json from typing import Any, Union, TYPE_CHECKING if TYPE_CHECKING: @@ -86,7 +87,7 @@ class Document(bonsai.core.tool.Document): props.documents.clear() file = tool.Ifc.get() try: - expanded_documents = json.loads(bpy.context.scene.ExpandedDocuments.json_string) + expanded_documents = json.loads(props.json_string) except (AttributeError, json.JSONDecodeError): expanded_documents = [] @@ -116,7 +117,7 @@ class Document(bonsai.core.tool.Document): document_children[parent_id].append(ref) else: for ref in file.by_type("IfcDocumentReference"): - if hasattr(ref, "ReferencedDocument") and ref.ReferencedDocument: + if ref.ReferencedDocument: parent = ref.ReferencedDocument parent_id = parent.id() if parent_id not in document_children: @@ -198,21 +199,18 @@ class Document(bonsai.core.tool.Document): info_children = [d for d in children if d.is_a("IfcDocumentInformation")] ref_children = [d for d in children if not d.is_a("IfcDocumentInformation")] - + info_children.sort( - key=lambda doc: ( - (cls.get_document_information_id(doc) or "").lower(), - (doc.Name or "").lower() - ) + key=lambda doc: ((cls.get_document_information_id(doc) or "").lower(), (doc.Name or "").lower()) ) - + ref_children.sort( key=lambda doc: ( (cls.get_external_reference_id(doc) or "").lower(), - (doc.Description or doc.Name or "").lower() + (doc.Description or doc.Name or "").lower(), ) ) - + for child in info_children + ref_children: cls._process_document(child, props, document_children, expanded_documents, depth + 1) @@ -253,3 +251,48 @@ class Document(bonsai.core.tool.Document): if document.file.schema == "IFC2X3": return document.DocumentReferences or () return document.HasDocumentReferences + + @classmethod + def enable_document_editing(cls) -> None: + props = cls.get_document_props() + props.is_editing = True + + @classmethod + def disable_document_editing(cls) -> None: + props = cls.get_document_props() + props.is_editing = False + + @classmethod + def clear_active_document(cls) -> None: + props = cls.get_document_props() + props.active_document_id = 0 + + @classmethod + def clear_document_attributes(cls) -> None: + props = cls.get_document_props() + props.document_attributes.clear() + + @classmethod + def expand_document(cls, document: ifcopenshell.entity_instance) -> None: + props = cls.get_document_props() + try: + expanded_docs = json.loads(props.json_string) + except (AttributeError, json.JSONDecodeError): + expanded_docs = [] + + if document.id() not in expanded_docs: + expanded_docs.append(document.id()) + props.json_string = json.dumps(expanded_docs) + + @classmethod + def get_default_parent_for_information(cls, ifc) -> Union[ifcopenshell.entity_instance, None]: + projects = ifc.get().by_type("IfcProject") + return projects[0] if projects else None + + @classmethod + def get_selected_document_information(cls, ifc) -> Union[ifcopenshell.entity_instance, None]: + props = cls.get_document_props() + + if props.active_document and props.active_document.is_information: + return ifc.get().by_id(props.active_document.ifc_definition_id) + return None From b6cfd165c268eb67f1cf22691d6f421c1b2cde7e Mon Sep 17 00:00:00 2001 From: falken10 Date: Mon, 7 Jul 2025 23:58:02 +0200 Subject: [PATCH 18/49] updates based on developers feedback --- .../bonsai/bim/module/document/__init__.py | 2 - src/bonsai/bonsai/bim/module/document/data.py | 29 ++- .../bonsai/bim/module/document/operator.py | 209 ++++-------------- src/bonsai/bonsai/bim/module/document/prop.py | 28 ++- src/bonsai/bonsai/bim/module/document/ui.py | 84 ++++--- src/bonsai/bonsai/core/document.py | 18 +- src/bonsai/bonsai/tool/document.py | 103 ++++++--- 7 files changed, 218 insertions(+), 255 deletions(-) diff --git a/src/bonsai/bonsai/bim/module/document/__init__.py b/src/bonsai/bonsai/bim/module/document/__init__.py index 323520420e..9730a50a11 100644 --- a/src/bonsai/bonsai/bim/module/document/__init__.py +++ b/src/bonsai/bonsai/bim/module/document/__init__.py @@ -28,14 +28,12 @@ classes = ( operator.DisableEditingDocument, operator.EditDocument, operator.EnableEditingDocument, - operator.LoadDocument, operator.LoadObjectDocuments, operator.LoadProjectDocuments, operator.RemoveDocument, operator.SelectDocumentObjects, operator.ToggleDocument, operator.UnassignDocument, - operator.UpdateAssignedDocuments, operator.OpenIFCDocument, prop.Document, prop.DocumentObject, diff --git a/src/bonsai/bonsai/bim/module/document/data.py b/src/bonsai/bonsai/bim/module/document/data.py index 15a89f8870..7f788b1388 100644 --- a/src/bonsai/bonsai/bim/module/document/data.py +++ b/src/bonsai/bonsai/bim/module/document/data.py @@ -21,7 +21,7 @@ import bpy import ifcopenshell import ifcopenshell.util.schema import bonsai.tool as tool - +from natsort import natsorted def refresh(): DocumentData.is_loaded = False @@ -87,19 +87,21 @@ class DocumentData: @classmethod def load_document_objects_into_props(cls, document_id): + if not cls.is_loaded: + cls.load() + props = tool.Document.get_document_props() props.document_objects.clear() - if "document_objects" not in cls.data or document_id not in cls.data["document_objects"]: + if document_id not in cls.data["document_objects"]: return - sorted_objects = sorted(cls.data["document_objects"][document_id], key=lambda x: x["name"].lower()) + sorted_objects = natsorted(cls.data["document_objects"][document_id], key=lambda x: x["name"].lower()) for obj_data in sorted_objects: item = props.document_objects.add() item.name = obj_data["name"] - class ObjectDocumentData: data = {} is_loaded = False @@ -111,6 +113,19 @@ class ObjectDocumentData: } cls.is_loaded = True + @staticmethod + def convert_to_file_uri(location: str) -> str: + if not location: + return "" + + uri = location + if not uri.startswith("file://"): + if not os.path.isabs(uri): + uri = os.path.abspath(os.path.join(os.path.dirname(tool.Ifc.get_path()), uri)) + uri = "file://" + uri + return uri + + @classmethod def documents(cls): results = [] @@ -165,11 +180,7 @@ class ObjectDocumentData: if location is None and referenced_document: location = referenced_document.Location - if location: - if not "://" in location: - if not os.path.isabs(location): - location = os.path.abspath(os.path.join(os.path.dirname(tool.Ifc.get_path()), location)) - location = "file://" + location + location = cls.convert_to_file_uri(location) if location else None results.append( { diff --git a/src/bonsai/bonsai/bim/module/document/operator.py b/src/bonsai/bonsai/bim/module/document/operator.py index ecc95f6ac8..557e2c72af 100644 --- a/src/bonsai/bonsai/bim/module/document/operator.py +++ b/src/bonsai/bonsai/bim/module/document/operator.py @@ -18,29 +18,10 @@ import bpy import json -import ifcopenshell.api -import ifcopenshell.util.attribute -import ifcopenshell.util.element import bonsai.bim.handler import bonsai.tool as tool import bonsai.core.document as core -import subprocess -import os -from bonsai.bim.module.document.data import DocumentData, ObjectDocumentData - - -def update_document_objects(document_id=None): - DocumentData.is_loaded = False - DocumentData.load() - - if document_id is None: - props = tool.Document.get_document_props() - if props.active_document and props.active_document.ifc_definition_id: - document_id = props.active_document.ifc_definition_id - - if document_id: - DocumentData.load_document_objects_into_props(document_id) - +from .data import DocumentData, ObjectDocumentData class LoadProjectDocuments(bpy.types.Operator): bl_idname = "bim.load_project_documents" @@ -49,23 +30,8 @@ class LoadProjectDocuments(bpy.types.Operator): def execute(self, context): core.load_project_documents(tool.Document) - update_document_objects() return {"FINISHED"} - -class LoadDocument(bpy.types.Operator): - bl_idname = "bim.load_document" - bl_label = "Load Document" - bl_options = {"REGISTER", "UNDO"} - document: bpy.props.IntProperty() - - def execute(self, context): - core.load_document(tool.Document, document=tool.Ifc.get().by_id(self.document)) - bonsai.bim.handler.refresh_ui_data() # Is this needed? - update_document_objects() - return {"FINISHED"} - - class DisableDocumentEditingUI(bpy.types.Operator): bl_idname = "bim.disable_document_editing_ui" bl_label = "Disable Document Editing UI" @@ -82,8 +48,7 @@ class DisableObjectDocumentEditingUI(bpy.types.Operator): bl_options = {"REGISTER", "UNDO"} def execute(self, context): - props = tool.Document.get_document_props() - props.is_object_editing = False + core.disable_object_document_editing_ui(tool.Document) return {"FINISHED"} @@ -94,8 +59,6 @@ class EnableEditingDocument(bpy.types.Operator): document: bpy.props.IntProperty() def execute(self, context): - props = tool.Document.get_document_props() - props.is_document_editing = True core.enable_editing_document(tool.Document, document=tool.Ifc.get().by_id(self.document)) return {"FINISHED"} @@ -106,8 +69,6 @@ class DisableEditingDocument(bpy.types.Operator): bl_options = {"REGISTER", "UNDO"} def execute(self, context): - props = tool.Document.get_document_props() - props.is_document_editing = False core.disable_editing_document(tool.Document) return {"FINISHED"} @@ -123,15 +84,15 @@ class AddInformation(bpy.types.Operator, tool.Ifc.Operator): if props.active_document: selected_document = props.active_document - if selected_document.ifc_definition_id == -1: - parent = tool.Ifc.get().by_type("IfcProject")[0] if tool.Ifc.get().by_type("IfcProject") else None - elif selected_document.is_information: + if selected_document.document_type == "PROJECT": + parent = tool.Ifc.get().by_type("IfcProject")[0] + elif selected_document.document_type == "INFORMATION": parent = tool.Ifc.get().by_id(selected_document.ifc_definition_id) - else: + elif selected_document.document_type == "REFERENCE": self.report({"ERROR"}, "Cannot add an information element as a child of a reference element") return {"CANCELLED"} else: - parent = tool.Ifc.get().by_type("IfcProject")[0] if tool.Ifc.get().by_type("IfcProject") else None + parent = tool.Ifc.get().by_type("IfcProject")[0] core.add_information(tool.Ifc, tool.Document, parent) @@ -141,20 +102,13 @@ class AddInformation(bpy.types.Operator, tool.Ifc.Operator): except (AttributeError, json.JSONDecodeError): pass - project = tool.Ifc.get().by_type("IfcProject")[0] - virtual_root_id = -project.id() - if virtual_root_id in expanded_docs: - expanded_docs.remove(virtual_root_id) - if parent and parent.is_a("IfcDocumentInformation"): if parent.id() not in expanded_docs: expanded_docs.append(parent.id()) props.json_string = json.dumps(expanded_docs) - bpy.ops.bim.load_project_documents() - class AddDocumentReference(bpy.types.Operator, tool.Ifc.Operator): bl_idname = "bim.add_document_reference" bl_label = "Add Document Reference" @@ -169,8 +123,8 @@ class AddDocumentReference(bpy.types.Operator, tool.Ifc.Operator): selected_document = props.active_document - if not selected_document.is_information: - self.report({"ERROR"}, "Cannot add a reference to a reference element") + if selected_document.document_type != "INFORMATION": + self.report({"ERROR"}, "Cannot add a reference to a document that is not an information element") return {"CANCELLED"} parent = tool.Ifc.get().by_id(selected_document.ifc_definition_id) @@ -201,12 +155,7 @@ class EditDocument(bpy.types.Operator, tool.Ifc.Operator): core.edit_document(tool.Ifc, tool.Document, document=tool.Ifc.get().by_id(props.active_document_id)) props.active_document_id = 0 props.is_document_editing = False - DocumentData.is_loaded = False - DocumentData.load() - ObjectDocumentData.is_loaded = False - ObjectDocumentData.load() - bpy.ops.bim.update_assigned_documents() - bonsai.bim.handler.refresh_ui_data() + tool.Document.update_assigned_documents() class RemoveDocument(bpy.types.Operator, tool.Ifc.Operator): @@ -218,40 +167,6 @@ class RemoveDocument(bpy.types.Operator, tool.Ifc.Operator): def _execute(self, context): core.remove_document(tool.Ifc, tool.Document, document=tool.Ifc.get().by_id(self.document)) - -class UpdateAssignedDocuments(bpy.types.Operator): - bl_idname = "bim.update_assigned_documents" - bl_label = "Update Assigned Documents" - bl_description = "Update the list of documents assigned to the active object" - bl_options = {"REGISTER"} - - def execute(self, context): - ObjectDocumentData.is_loaded = False - ObjectDocumentData.load() - - props = tool.Document.get_document_props() - props.assigned_documents.clear() - - if not ObjectDocumentData.data.get("documents"): - return {"FINISHED"} - - sorted_docs = sorted( - ObjectDocumentData.data["documents"], - key=lambda doc: ((doc.get("identification") or "").lower(), (doc.get("name") or "").lower()), - ) - - for document in sorted_docs: - new = props.assigned_documents.add() - new.name = document["name"] or "Unnamed" - new.identification = document["identification"] or "*" - new.is_information = document.get("is_information", False) - new.ifc_definition_id = document["id"] - new.location = document.get("location") or "" - new.description = document.get("description") or "" - - return {"FINISHED"} - - class AssignDocument(bpy.types.Operator, tool.Ifc.Operator): bl_idname = "bim.assign_document" bl_label = "Assign Document" @@ -268,10 +183,12 @@ class AssignDocument(bpy.types.Operator, tool.Ifc.Operator): if element: core.assign_document(tool.Ifc, product=element, document=document) - update_document_objects(self.document) + + tool.Document.update_document_objects(self.document) ObjectDocumentData.is_loaded = False ObjectDocumentData.load() - bpy.ops.bim.update_assigned_documents() + tool.Document.update_assigned_documents() + return {"FINISHED"} class UnassignDocument(bpy.types.Operator, tool.Ifc.Operator): @@ -285,21 +202,26 @@ class UnassignDocument(bpy.types.Operator, tool.Ifc.Operator): document = tool.Ifc.get().by_id(self.document) objs = [bpy.data.objects.get(self.obj)] if self.obj else tool.Blender.get_selected_objects() for obj in objs: - element = tool.Ifc.get_entity(obj) - if element: - core.unassign_document(tool.Ifc, product=element, document=document) + if obj: + element = tool.Ifc.get_entity(obj) + if element: + core.unassign_document(tool.Ifc, product=element, document=document) + props = tool.Document.get_document_props() active_document_id = None if props.active_document: active_document_id = props.active_document.ifc_definition_id if active_document_id and active_document_id != self.document: - update_document_objects(active_document_id) + tool.Document.update_document_objects(active_document_id) else: - update_document_objects(self.document) + tool.Document.update_document_objects() + ObjectDocumentData.is_loaded = False ObjectDocumentData.load() - bpy.ops.bim.update_assigned_documents() + + tool.Document.update_assigned_documents() + return {"FINISHED"} class SelectDocumentObjects(bpy.types.Operator): @@ -339,32 +261,10 @@ class LoadObjectDocuments(bpy.types.Operator): props = tool.Document.get_document_props() props.is_object_editing = True - bonsai.bim.handler.refresh_ui_data() - - self.update_assigned_documents(props) + tool.Document.update_assigned_documents() return {"FINISHED"} - def update_assigned_documents(self, props): - props.assigned_documents.clear() - - if not ObjectDocumentData.data.get("documents"): - return - - sorted_docs = sorted( - ObjectDocumentData.data["documents"], - key=lambda doc: ((doc.get("identification") or "").lower(), (doc.get("name") or "").lower()), - ) - - for document in sorted_docs: - new = props.assigned_documents.add() - new.name = document["name"] or "Unnamed" - new.identification = document["identification"] or "*" - new.is_information = document.get("is_information", False) - new.ifc_definition_id = document["id"] - new.location = document["location"] or "" - new.description = document["description"] or "" - class OpenIFCDocument(bpy.types.Operator): bl_idname = "bim.open_ifc_document" @@ -375,47 +275,37 @@ class OpenIFCDocument(bpy.types.Operator): uri: bpy.props.StringProperty(name="URI") def execute(self, context): - - if not self.uri: - self.report({"ERROR"}, "No URI provided") + import subprocess + import os + if not self.uri or not self.uri.lower().startswith("file://"): + self.report({"ERROR"}, "Only local file:// URIs are supported") return {"CANCELLED"} - file_path = self.uri - if file_path.startswith("file://"): - file_path = file_path[7:] - elif file_path.startswith("file:"): - file_path = file_path[5:] - - if not os.path.isabs(file_path): - file_path = os.path.abspath(file_path) - - if not os.path.exists(file_path): - self.report({"ERROR"}, f"IFC file not found: {file_path}") + filepath = self.uri[7:] # Remove file:// prefix + + if not os.path.exists(filepath): + self.report({"ERROR"}, f"File not found: {filepath}") return {"CANCELLED"} try: - subprocess.Popen( - [ - "blender", - "--python-expr", - f"import bpy; bpy.ops.bim.load_project(filepath='{file_path}', should_start_fresh_session=True)", - ] - ) - self.report({"INFO"}, f"Opening IFC file: {file_path} in a new Blender instance.") + blender_path = bpy.app.binary_path + args = [blender_path, "--python-expr", "import bpy; bpy.ops.bim.load_project(filepath='{}')".format(filepath)] + subprocess.Popen(args) + self.report({"INFO"}, f"Opening {filepath} in a new Blender instance") except Exception as e: self.report({"ERROR"}, f"Failed to open IFC file: {str(e)}") return {"FINISHED"} -class ToggleDocument(bpy.types.Operator, tool.Ifc.Operator): +class ToggleDocument(bpy.types.Operator): bl_idname = "bim.toggle_document" bl_label = "Toggle Document" bl_options = {"REGISTER", "UNDO"} document: bpy.props.IntProperty() option: bpy.props.StringProperty() - def _execute(self, context): + def execute(self, context): expanded_documents = [] props = tool.Document.get_document_props() try: @@ -425,19 +315,14 @@ class ToggleDocument(bpy.types.Operator, tool.Ifc.Operator): document_id = self.document - if self.option == "Expand" and document_id not in expanded_documents: - expanded_documents.append(document_id) - elif self.option == "Collapse" and document_id in expanded_documents: - expanded_documents.remove(document_id) - elif document_id == -1: - project = tool.Ifc.get().by_type("IfcProject")[0] - virtual_root_id = -project.id() + document = tool.Ifc.get().by_id(document_id) + if document: + if self.option == "Expand" and document_id not in expanded_documents: + expanded_documents.append(document_id) + elif self.option == "Collapse" and document_id in expanded_documents: + expanded_documents.remove(document_id) - if self.option == "Expand" and virtual_root_id not in expanded_documents: - expanded_documents.append(virtual_root_id) - elif self.option == "Collapse" and virtual_root_id in expanded_documents: - expanded_documents.remove(virtual_root_id) props.json_string = json.dumps(expanded_documents) - bpy.ops.bim.load_project_documents() return {"FINISHED"} + diff --git a/src/bonsai/bonsai/bim/module/document/prop.py b/src/bonsai/bonsai/bim/module/document/prop.py index ce4a6a83cc..92d60771f7 100644 --- a/src/bonsai/bonsai/bim/module/document/prop.py +++ b/src/bonsai/bonsai/bim/module/document/prop.py @@ -51,8 +51,7 @@ def update_document_identification(self: "Document", context: bpy.types.Context) def update_active_document(self, context): - if self.documents and self.active_document_index < len(self.documents): - document = self.documents[self.active_document_index] + if (document := self.active_document): if document.ifc_definition_id: DocumentData.load_document_objects_into_props(document.ifc_definition_id) @@ -61,23 +60,31 @@ class Document(PropertyGroup): name: StringProperty(name="Name") identification: StringProperty(name="Identification") description: StringProperty(name="Description") - is_information: BoolProperty(name="Is Information") ifc_definition_id: IntProperty(name="IFC Definition ID") location: StringProperty(name="Location", default="") tree_depth: IntProperty(name="Tree Depth", default=0) has_children: BoolProperty(name="Has Children", default=False) is_expanded: BoolProperty(name="Is Expanded", default=False) + document_type: EnumProperty( + name="Document Type", + items=[ + ("PROJECT", "Project", "Virtual project root node"), + ("INFORMATION", "Information", "IfcDocumentInformation"), + ("REFERENCE", "Reference", "IfcDocumentReference"), + ], + default="INFORMATION" + ) if TYPE_CHECKING: name: str identification: str description: str - is_information: bool ifc_definition_id: int location: str tree_depth: int has_children: bool is_expanded: bool + document_type: str class DocumentObject(PropertyGroup): @@ -93,17 +100,24 @@ class AssignedDocument(PropertyGroup): name: StringProperty(name="Name") identification: StringProperty(name="Identification") description: StringProperty(name="Description", default="") - is_information: BoolProperty(name="Is Information") ifc_definition_id: IntProperty(name="IFC Definition ID") location: StringProperty(name="Location", default="") + document_type: EnumProperty( + name="Document Type", + items=[ + ("PROJECT", "Project", "Virtual project root node"), + ("INFORMATION", "Information", "IfcDocumentInformation"), + ("REFERENCE", "Reference", "IfcDocumentReference"), + ], + default="INFORMATION" + ) if TYPE_CHECKING: name: str identification: str - is_information: bool ifc_definition_id: int location: str - + document_type: str class BIMDocumentProperties(PropertyGroup): document_attributes: CollectionProperty(name="Document Attributes", type=Attribute) diff --git a/src/bonsai/bonsai/bim/module/document/ui.py b/src/bonsai/bonsai/bim/module/document/ui.py index bad9819205..1563555b9a 100644 --- a/src/bonsai/bonsai/bim/module/document/ui.py +++ b/src/bonsai/bonsai/bim/module/document/ui.py @@ -20,8 +20,7 @@ import bpy import bonsai.tool as tool from bpy.types import Panel, UIList from bonsai.bim.helper import draw_attributes -from bonsai.bim.module.document.data import DocumentData, ObjectDocumentData - +from .data import DocumentData, ObjectDocumentData class BIM_PT_documents(Panel): bl_label = "Documents" @@ -46,8 +45,8 @@ class BIM_PT_documents(Panel): split = row.split(factor=0.55) left_row = split.row(align=True) - left_row.label(text="{} Informations".format(DocumentData.data["total_document_informations"]), icon="FILE") - left_row.label(text="{} References".format(DocumentData.data["total_document_references"]), icon="FILE_HIDDEN") + total_documents = DocumentData.data["total_document_informations"] + DocumentData.data["total_document_references"] + left_row.label(text="{} Documents".format(total_documents), icon="FILE") right_row = split.row(align=True) right_row.label( @@ -68,31 +67,31 @@ class BIM_PT_documents(Panel): row.operator("bim.edit_document", text="", icon="CHECKMARK") row.operator("bim.disable_editing_document", text="", icon="CANCEL") else: - if not self.props.active_document or self.props.active_document.is_information: + if not self.props.active_document or self.props.active_document.document_type in ["INFORMATION", "PROJECT"]: row.operator("bim.add_information", text="", icon="ADD") - if self.props.active_document: - if self.props.active_document.is_information and self.props.active_document.ifc_definition_id != -1: - row.operator("bim.add_document_reference", text="", icon="FILE_HIDDEN") + if self.props.active_document and ( + self.props.active_document.document_type == "INFORMATION" and + self.props.active_document.document_type != "PROJECT" + ): + row.operator("bim.add_document_reference", text="", icon="FILE_HIDDEN") active_document = self.props.active_document if active_document: ifc_definition_id = active_document.ifc_definition_id - row.operator("bim.select_document_objects", text="", icon="RESTRICT_SELECT_OFF").document = ( - ifc_definition_id - ) - - row.operator("bim.assign_document", text="", icon="BRUSH_DATA").document = ifc_definition_id - row.operator("bim.enable_editing_document", text="", icon="GREASEPENCIL").document = ifc_definition_id - row.operator("bim.remove_document", text="", icon="X").document = ifc_definition_id + + if active_document.document_type != "PROJECT": + row.operator("bim.select_document_objects", text="", icon="RESTRICT_SELECT_OFF").document = ( + ifc_definition_id + ) + row.operator("bim.assign_document", text="", icon="BRUSH_DATA").document = ifc_definition_id + row.operator("bim.enable_editing_document", text="", icon="GREASEPENCIL").document = ifc_definition_id + row.operator("bim.remove_document", text="", icon="X").document = ifc_definition_id self.layout.template_list("BIM_UL_documents", "", self.props, "documents", self.props, "active_document_index") if self.props.is_document_editing: active_document = self.props.active_document - if active_document.is_information: - draw_attributes(self.props.document_attributes, self.layout) - else: - draw_attributes(self.props.document_attributes, self.layout, filter_attributes=["Name"]) + draw_attributes(self.props.document_attributes, self.layout) if self.props.is_editing and self.props.active_document: document = self.props.active_document @@ -108,7 +107,6 @@ class BIM_PT_documents(Panel): "active_document_object_index", ) - class BIM_PT_object_documents(Panel): bl_label = "Documents" bl_idname = "BIM_PT_object_documents" @@ -119,6 +117,9 @@ class BIM_PT_object_documents(Panel): bl_order = 1 bl_parent_id = "BIM_PT_tab_misc" + # Class variable to track the last selected object + _last_object_id = None + @classmethod def poll(cls, context): if not (obj := context.active_object): @@ -130,10 +131,14 @@ class BIM_PT_object_documents(Panel): return True def draw(self, context): - if not ObjectDocumentData.is_loaded: + obj = context.active_object + current_ifc_id = tool.Blender.get_ifc_definition_id(obj) + + if BIM_PT_object_documents._last_object_id != current_ifc_id: + BIM_PT_object_documents._last_object_id = current_ifc_id + ObjectDocumentData.is_loaded = False ObjectDocumentData.load() - obj = context.active_object self.oprops = tool.Blender.get_object_bim_props(obj) self.props = tool.Document.get_document_props() self.file = tool.Ifc.get() @@ -181,10 +186,11 @@ class BIM_PT_object_documents(Panel): for doc in ObjectDocumentData.data["documents"]: assigned_doc_ids.append(doc["id"]) - # Only show assign button if the document is information (not reference) and not already assigned - if document.is_information and document.ifc_definition_id not in assigned_doc_ids: + if (document.document_type == "INFORMATION" and + document.document_type != "PROJECT" and + document.ifc_definition_id not in assigned_doc_ids): doc_op = row.operator("bim.assign_document", text="", icon="BRUSH_DATA") - doc_op.document = document.ifc_definition_id # Pass the current document's ID + doc_op.document = document.ifc_definition_id elif document.ifc_definition_id in assigned_doc_ids: row.label(text="", icon="CHECKMARK") self.layout.template_list( @@ -198,24 +204,28 @@ class BIM_UL_documents(UIList): row = layout.row(align=True) indent_depth = 0 - if item.ifc_definition_id != -1: + if item.document_type != "PROJECT": if item.tree_depth > 1: indent_depth = item.tree_depth - 1 + for i in range(indent_depth): row.label(text="", icon="BLANK1") - if item.ifc_definition_id == -1: + + if item.document_type == "PROJECT": row.label(text="", icon="OUTLINER_COLLECTION") row.label(text=item.name) return - if item.is_information and item.has_children: + + if item.document_type == "INFORMATION" and item.has_children: op = row.operator( "bim.toggle_document", icon="TRIA_DOWN" if item.is_expanded else "TRIA_RIGHT", text="", emboss=False ) op.document = item.ifc_definition_id op.option = "Collapse" if item.is_expanded else "Expand" - elif item.is_information: + elif item.document_type == "INFORMATION": row.label(text="", icon="BLANK1") - if item.is_information: + + if item.document_type == "INFORMATION": row.label(text="", icon="FILE") text = " - ".join([x for x in [item.name, item.location] if x]) else: @@ -227,9 +237,10 @@ class BIM_UL_documents(UIList): split2.label(text=text) if item.location: + uri = ObjectDocumentData.convert_to_file_uri(item.location) if item.location.lower().endswith(".ifc"): - row.operator("bim.open_ifc_document", icon="HIDE_OFF", text="").uri = item.location - row.operator("bim.open_uri", icon="URL", text="").uri = item.location + row.operator("bim.open_ifc_document", icon="HIDE_OFF", text="").uri = uri + row.operator("bim.open_uri", icon="URL", text="").uri = uri class BIM_UL_document_objects(UIList): @@ -253,7 +264,7 @@ class BIM_UL_assigned_documents(UIList): if item: row = layout.row(align=True) - if item.is_information: + if item.document_type == "INFORMATION": row.label(text="", icon="FILE") else: row.label(text="", icon="FILE_HIDDEN") @@ -262,15 +273,16 @@ class BIM_UL_assigned_documents(UIList): split1.label(text=item.identification or "") split2 = split1.split(factor=1.0) - if item.is_information: + if item.document_type == "INFORMATION": split2.label(text=item.name or "Unnamed") else: split2.label(text=item.description or "No Description") if item.location: + uri = ObjectDocumentData.convert_to_file_uri(item.location) if item.location.lower().endswith(".ifc"): - row.operator("bim.open_ifc_document", icon="HIDE_OFF", text="").uri = item.location - row.operator("bim.open_uri", icon="URL", text="").uri = item.location + row.operator("bim.open_ifc_document", icon="HIDE_OFF", text="").uri = uri + row.operator("bim.open_uri", icon="URL", text="").uri = uri op = row.operator("bim.unassign_document", text="", icon="X") op.document = item.ifc_definition_id diff --git a/src/bonsai/bonsai/core/document.py b/src/bonsai/bonsai/core/document.py index 56d92ea613..e55c077022 100644 --- a/src/bonsai/bonsai/core/document.py +++ b/src/bonsai/bonsai/core/document.py @@ -29,31 +29,27 @@ def load_project_documents(document: tool.Document) -> None: document.import_project_documents() document.enable_editing_ui() - -def load_document(document_tool: tool.Document, document: ifcopenshell.entity_instance) -> None: - document_tool.clear_document_tree() - document_tool.expand_document(document) - document_tool.import_project_documents() - document_tool.disable_editing_document() - - def disable_document_editing_ui(document: tool.Document) -> None: document.disable_editing_ui() document.disable_editing_document() +def disable_object_document_editing_ui(document: tool.Document) -> None: + props = document.get_document_props() + props.is_object_editing = False def enable_editing_document(document_tool: tool.Document, document: ifcopenshell.entity_instance) -> None: + props = document_tool.get_document_props() + props.is_document_editing = True document_tool.set_active_document(document) - document_tool.enable_document_editing() document_tool.import_document_attributes(document) def disable_editing_document(document: tool.Document) -> None: + props = document.get_document_props() + props.is_document_editing = False document.clear_active_document() - document.disable_document_editing() document.clear_document_attributes() - def add_information(ifc: tool.Ifc, document_tool: tool.Document, parent=None) -> ifcopenshell.entity_instance: document_tool.clear_document_tree() diff --git a/src/bonsai/bonsai/tool/document.py b/src/bonsai/bonsai/tool/document.py index 76ceaf52c3..cd51f35e8d 100644 --- a/src/bonsai/bonsai/tool/document.py +++ b/src/bonsai/bonsai/tool/document.py @@ -18,12 +18,11 @@ from __future__ import annotations import bpy -import json import ifcopenshell.util.system -import bonsai.bim.helper import bonsai.core.tool import bonsai.tool as tool import json +from natsort import natsorted from typing import Any, Union, TYPE_CHECKING if TYPE_CHECKING: @@ -70,12 +69,15 @@ class Document(bonsai.core.tool.Document): data[attr_name] = "" return True if attr_name != "Name": - return None + return None # Proceed normally current_value = data[attr_name] + # If Name is already filled, display it so user would be able to correct invalid IFC. if current_value is not None: return None + # Skip import since IFC restricts Name to be filled + # for IfcDocumentReference with ReferencedDocument. return False import_callback = callback if document.is_a("IfcDocumentReference") else None @@ -137,8 +139,8 @@ class Document(bonsai.core.tool.Document): root_documents.append(rel.RelatingDocument) root = props.documents.add() - root.ifc_definition_id = -1 - root.is_information = True + root.ifc_definition_id = -project.id() + root.document_type = "PROJECT" root.name = f"Project Documents ({project.Name or 'Unnamed Project'})" root.identification = "" root.location = "" @@ -150,8 +152,9 @@ class Document(bonsai.core.tool.Document): root.is_expanded = root_id not in expanded_documents if root.is_expanded: - root_documents.sort( - key=lambda doc: ((cls.get_document_information_id(doc) or "").lower(), (doc.Name or "").lower()) + root_documents = natsorted( + root_documents, + key=lambda doc: (cls.get_document_information_id(doc) or "", doc.Name or "") ) for doc in root_documents: @@ -161,11 +164,11 @@ class Document(bonsai.core.tool.Document): def _process_document(cls, document, props, document_children, expanded_documents, depth): new = props.documents.add() new.ifc_definition_id = document.id() - new.is_information = document.is_a("IfcDocumentInformation") + new.document_type = "INFORMATION" if document.is_a("IfcDocumentInformation") else "REFERENCE" new.tree_depth = depth file = document.file - if new.is_information: + if new.document_type == "INFORMATION": new.name = document.Name or "Unnamed" new.identification = cls.get_document_information_id(document) or "" new.location = document.Location or "" @@ -175,7 +178,7 @@ class Document(bonsai.core.tool.Document): new.description = document.Description or "" new.location = document.Location or "" - if not new.is_information: + if new.document_type == "REFERENCE": if file.schema == "IFC2X3": if document.ReferenceToDocument: doc_info = document.ReferenceToDocument[0] @@ -183,7 +186,7 @@ class Document(bonsai.core.tool.Document): new.name = doc_info.Name or "" new.location = new.location or "" else: - if hasattr(document, "ReferencedDocument") and document.ReferencedDocument: + if document.ReferencedDocument: doc_info = document.ReferencedDocument if not new.name: new.name = doc_info.Name or "" @@ -200,20 +203,22 @@ class Document(bonsai.core.tool.Document): info_children = [d for d in children if d.is_a("IfcDocumentInformation")] ref_children = [d for d in children if not d.is_a("IfcDocumentInformation")] - info_children.sort( - key=lambda doc: ((cls.get_document_information_id(doc) or "").lower(), (doc.Name or "").lower()) + info_children = natsorted( + info_children, + key=lambda doc: (cls.get_document_information_id(doc) or "", doc.Name or "") ) - ref_children.sort( + ref_children = natsorted( + ref_children, key=lambda doc: ( - (cls.get_external_reference_id(doc) or "").lower(), - (doc.Description or doc.Name or "").lower(), + cls.get_external_reference_id(doc) or "", + doc.Description or doc.Name or "" ) ) for child in info_children + ref_children: cls._process_document(child, props, document_children, expanded_documents, depth + 1) - + @classmethod def is_document_information(cls, document: ifcopenshell.entity_instance) -> bool: return document.is_a("IfcDocumentInformation") @@ -252,16 +257,6 @@ class Document(bonsai.core.tool.Document): return document.DocumentReferences or () return document.HasDocumentReferences - @classmethod - def enable_document_editing(cls) -> None: - props = cls.get_document_props() - props.is_editing = True - - @classmethod - def disable_document_editing(cls) -> None: - props = cls.get_document_props() - props.is_editing = False - @classmethod def clear_active_document(cls) -> None: props = cls.get_document_props() @@ -293,6 +288,58 @@ class Document(bonsai.core.tool.Document): def get_selected_document_information(cls, ifc) -> Union[ifcopenshell.entity_instance, None]: props = cls.get_document_props() - if props.active_document and props.active_document.is_information: + if props.active_document and props.active_document.document_type == "INFORMATION": return ifc.get().by_id(props.active_document.ifc_definition_id) return None + + @classmethod + def refresh_document_data(cls) -> None: + import bonsai.bim.module.document.data as document_data + document_data.DocumentData.is_loaded = False + document_data.DocumentData.load() + + @classmethod + def load_document_objects_into_props(cls, document_id: int) -> None: + import bonsai.bim.module.document.data as document_data + document_data.DocumentData.load_document_objects_into_props(document_id) + + @classmethod + def update_document_objects(cls, document_id: Union[int, None] = None) -> None: + cls.refresh_document_data() + + if document_id is None: + props = cls.get_document_props() + if props.active_document and props.active_document.ifc_definition_id > 0: + document_id = props.active_document.ifc_definition_id + + if document_id: + cls.load_document_objects_into_props(document_id) + + @classmethod + def update_assigned_documents(cls) -> None: + from bonsai.bim.module.document.data import ObjectDocumentData + + ObjectDocumentData.is_loaded = False + + props = cls.get_document_props() + props.assigned_documents.clear() + + if not ObjectDocumentData.is_loaded: + ObjectDocumentData.load() + + if not ObjectDocumentData.data.get("documents"): + return + + sorted_docs = sorted( + ObjectDocumentData.data["documents"], + key=lambda doc: ((doc.get("identification") or "").lower(), (doc.get("name") or "").lower()), + ) + + for document in sorted_docs: + new = props.assigned_documents.add() + new.name = document["name"] or "Unnamed" + new.identification = document["identification"] or "*" + new.document_type = "INFORMATION" if document.get("is_information", False) else "REFERENCE" + new.ifc_definition_id = document["id"] + new.location = document.get("location") or "" + new.description = document.get("description") or "" \ No newline at end of file From ca8eb9b3580e3f3c69f1a690abefcbf8ca12d96e Mon Sep 17 00:00:00 2001 From: falken10 Date: Tue, 8 Jul 2025 08:39:21 +0200 Subject: [PATCH 19/49] misc panel working --- .../bonsai/bim/module/document/__init__.py | 1 - src/bonsai/bonsai/bim/module/document/ui.py | 44 ++++--------------- 2 files changed, 8 insertions(+), 37 deletions(-) diff --git a/src/bonsai/bonsai/bim/module/document/__init__.py b/src/bonsai/bonsai/bim/module/document/__init__.py index 9730a50a11..d2da8285b9 100644 --- a/src/bonsai/bonsai/bim/module/document/__init__.py +++ b/src/bonsai/bonsai/bim/module/document/__init__.py @@ -43,7 +43,6 @@ classes = ( ui.BIM_PT_object_documents, ui.BIM_UL_documents, ui.BIM_UL_document_objects, - ui.BIM_UL_assigned_documents, ui.BIM_MT_object_documents_context_menu, ) diff --git a/src/bonsai/bonsai/bim/module/document/ui.py b/src/bonsai/bonsai/bim/module/document/ui.py index 1563555b9a..d891836517 100644 --- a/src/bonsai/bonsai/bim/module/document/ui.py +++ b/src/bonsai/bonsai/bim/module/document/ui.py @@ -165,14 +165,14 @@ class BIM_PT_object_documents(Panel): row = box.row(align=True) row.label(text="Assigned Documents", icon="OUTLINER_OB_EMPTY") - box.template_list( - "BIM_UL_assigned_documents", - "", - self.props, - "assigned_documents", - self.props, - "active_assigned_document_index", - ) + + for document in ObjectDocumentData.data["documents"]: + row = self.layout.row(align=True) + row.label(text=document["identification"] or "*", icon="FILE") + row.label(text=document["name"] or "Unnamed") + if document["location"]: + row.operator("bim.open_uri", icon="URL", text="").uri = document["location"] + row.operator("bim.unassign_document", text="", icon="X").document = document["id"] def draw_add_ui(self): if self.props.is_object_editing: @@ -259,34 +259,6 @@ class BIM_UL_document_objects(UIList): op.obj = item.name -class BIM_UL_assigned_documents(UIList): - def draw_item(self, context, layout, data, item, icon, active_data, active_propname): - if item: - row = layout.row(align=True) - - if item.document_type == "INFORMATION": - row.label(text="", icon="FILE") - else: - row.label(text="", icon="FILE_HIDDEN") - - split1 = row.split(factor=0.2) - split1.label(text=item.identification or "") - - split2 = split1.split(factor=1.0) - if item.document_type == "INFORMATION": - split2.label(text=item.name or "Unnamed") - else: - split2.label(text=item.description or "No Description") - - if item.location: - uri = ObjectDocumentData.convert_to_file_uri(item.location) - if item.location.lower().endswith(".ifc"): - row.operator("bim.open_ifc_document", icon="HIDE_OFF", text="").uri = uri - row.operator("bim.open_uri", icon="URL", text="").uri = uri - op = row.operator("bim.unassign_document", text="", icon="X") - op.document = item.ifc_definition_id - - def add_object_documents_context_menu(self, context): if not context.active_object: return From 9c8c40c0ff97378c6d79c37653169fa7967c52c9 Mon Sep 17 00:00:00 2001 From: falken10 Date: Tue, 8 Jul 2025 16:24:02 +0200 Subject: [PATCH 20/49] nicer ui --- src/bonsai/bonsai/bim/module/document/prop.py | 2 - src/bonsai/bonsai/bim/module/document/ui.py | 44 +++++++++++-------- 2 files changed, 26 insertions(+), 20 deletions(-) diff --git a/src/bonsai/bonsai/bim/module/document/prop.py b/src/bonsai/bonsai/bim/module/document/prop.py index 92d60771f7..b0964dd5c9 100644 --- a/src/bonsai/bonsai/bim/module/document/prop.py +++ b/src/bonsai/bonsai/bim/module/document/prop.py @@ -143,8 +143,6 @@ class BIMDocumentProperties(PropertyGroup): is_object_editing: bool document_objects: bpy.types.bpy_prop_collection_idprop[DocumentObject] active_document_object_index: int - assigned_documents: bpy.types.bpy_prop_collection_idprop[AssignedDocument] - active_assigned_document_index: int json_string: str @property diff --git a/src/bonsai/bonsai/bim/module/document/ui.py b/src/bonsai/bonsai/bim/module/document/ui.py index d891836517..a742130f94 100644 --- a/src/bonsai/bonsai/bim/module/document/ui.py +++ b/src/bonsai/bonsai/bim/module/document/ui.py @@ -117,9 +117,6 @@ class BIM_PT_object_documents(Panel): bl_order = 1 bl_parent_id = "BIM_PT_tab_misc" - # Class variable to track the last selected object - _last_object_id = None - @classmethod def poll(cls, context): if not (obj := context.active_object): @@ -132,11 +129,7 @@ class BIM_PT_object_documents(Panel): def draw(self, context): obj = context.active_object - current_ifc_id = tool.Blender.get_ifc_definition_id(obj) - - if BIM_PT_object_documents._last_object_id != current_ifc_id: - BIM_PT_object_documents._last_object_id = current_ifc_id - ObjectDocumentData.is_loaded = False + if not ObjectDocumentData.is_loaded: ObjectDocumentData.load() self.oprops = tool.Blender.get_object_bim_props(obj) @@ -160,19 +153,34 @@ class BIM_PT_object_documents(Panel): if self.props.is_object_editing: self.draw_add_ui() + box = self.layout.box() + row = box.row(align=True) + row.label(text="Assigned Documents", icon="OUTLINER_OB_EMPTY") + if doc_count > 0: - box = self.layout.box() - row = box.row(align=True) - row.label(text="Assigned Documents", icon="OUTLINER_OB_EMPTY") - - + col = box.column(align=True) for document in ObjectDocumentData.data["documents"]: - row = self.layout.row(align=True) - row.label(text=document["identification"] or "*", icon="FILE") - row.label(text=document["name"] or "Unnamed") + row = col.row(align=True) + + # Create a split layout to separate left and right sides + split = row.split(factor=0.7) # Adjust factor as needed (0.7 = 70% left, 30% right) + + # Left side - Document identification and name + left_side = split.row(align=True) + left_side.alignment = 'LEFT' + left_side.label(text=document["identification"] or "*", icon="FILE") + left_side.label(text=document["name"] or "Unnamed") + + # Right side - Action buttons + right_side = split.row(align=True) + right_side.alignment = 'RIGHT' # Align buttons to the right + if document["location"]: - row.operator("bim.open_uri", icon="URL", text="").uri = document["location"] - row.operator("bim.unassign_document", text="", icon="X").document = document["id"] + if document["location"].lower().endswith(".ifc"): + right_side.operator("bim.open_ifc_document", icon="HIDE_OFF", text="").uri = document["location"] + right_side.operator("bim.open_uri", icon="URL", text="").uri = document["location"] + + right_side.operator("bim.unassign_document", text="", icon="X").document = document["id"] def draw_add_ui(self): if self.props.is_object_editing: From 128fe66837f662ea835c207008d3b778436553c9 Mon Sep 17 00:00:00 2001 From: falken10 Date: Tue, 8 Jul 2025 19:45:12 +0200 Subject: [PATCH 21/49] cleanup and formatting --- .../bonsai/bim/module/document/__init__.py | 1 - src/bonsai/bonsai/bim/module/document/data.py | 24 ++------ .../bonsai/bim/module/document/operator.py | 32 +++++------ src/bonsai/bonsai/bim/module/document/prop.py | 31 +--------- src/bonsai/bonsai/bim/module/document/ui.py | 56 ++++++++++--------- src/bonsai/bonsai/core/document.py | 6 +- src/bonsai/bonsai/tool/document.py | 44 ++------------- 7 files changed, 64 insertions(+), 130 deletions(-) diff --git a/src/bonsai/bonsai/bim/module/document/__init__.py b/src/bonsai/bonsai/bim/module/document/__init__.py index d2da8285b9..cc71d16306 100644 --- a/src/bonsai/bonsai/bim/module/document/__init__.py +++ b/src/bonsai/bonsai/bim/module/document/__init__.py @@ -37,7 +37,6 @@ classes = ( operator.OpenIFCDocument, prop.Document, prop.DocumentObject, - prop.AssignedDocument, prop.BIMDocumentProperties, ui.BIM_PT_documents, ui.BIM_PT_object_documents, diff --git a/src/bonsai/bonsai/bim/module/document/data.py b/src/bonsai/bonsai/bim/module/document/data.py index 7f788b1388..0ba0106881 100644 --- a/src/bonsai/bonsai/bim/module/document/data.py +++ b/src/bonsai/bonsai/bim/module/document/data.py @@ -23,10 +23,6 @@ import ifcopenshell.util.schema import bonsai.tool as tool from natsort import natsorted -def refresh(): - DocumentData.is_loaded = False - ObjectDocumentData.is_loaded = False - class DocumentData: data = {} @@ -35,24 +31,16 @@ class DocumentData: @classmethod def load(cls): cls.data = { - "total_document_informations": cls.total_document_informations(), - "total_document_references": cls.total_document_references(), + "total_documents": cls.total_documents(), "total_referenced_objects": cls.total_referenced_objects(), "document_objects": cls.document_objects(), } cls.is_loaded = True @classmethod - def total_document_informations(cls): + def total_documents(cls): file = tool.Ifc.get() - info_count = len(file.by_type("IfcDocumentInformation")) - return info_count - - @classmethod - def total_document_references(cls): - file = tool.Ifc.get() - ref_count = len(file.by_type("IfcDocumentReference")) - return ref_count + return len(file.by_type("IfcDocumentInformation")) + len(file.by_type("IfcDocumentReference")) @classmethod def total_referenced_objects(cls): @@ -89,7 +77,7 @@ class DocumentData: def load_document_objects_into_props(cls, document_id): if not cls.is_loaded: cls.load() - + props = tool.Document.get_document_props() props.document_objects.clear() @@ -102,6 +90,7 @@ class DocumentData: item = props.document_objects.add() item.name = obj_data["name"] + class ObjectDocumentData: data = {} is_loaded = False @@ -117,7 +106,7 @@ class ObjectDocumentData: def convert_to_file_uri(location: str) -> str: if not location: return "" - + uri = location if not uri.startswith("file://"): if not os.path.isabs(uri): @@ -125,7 +114,6 @@ class ObjectDocumentData: uri = "file://" + uri return uri - @classmethod def documents(cls): results = [] diff --git a/src/bonsai/bonsai/bim/module/document/operator.py b/src/bonsai/bonsai/bim/module/document/operator.py index 557e2c72af..c2ccd5edc9 100644 --- a/src/bonsai/bonsai/bim/module/document/operator.py +++ b/src/bonsai/bonsai/bim/module/document/operator.py @@ -23,6 +23,7 @@ import bonsai.tool as tool import bonsai.core.document as core from .data import DocumentData, ObjectDocumentData + class LoadProjectDocuments(bpy.types.Operator): bl_idname = "bim.load_project_documents" bl_label = "Load Project Documents" @@ -32,6 +33,7 @@ class LoadProjectDocuments(bpy.types.Operator): core.load_project_documents(tool.Document) return {"FINISHED"} + class DisableDocumentEditingUI(bpy.types.Operator): bl_idname = "bim.disable_document_editing_ui" bl_label = "Disable Document Editing UI" @@ -109,6 +111,7 @@ class AddInformation(bpy.types.Operator, tool.Ifc.Operator): props.json_string = json.dumps(expanded_docs) bpy.ops.bim.load_project_documents() + class AddDocumentReference(bpy.types.Operator, tool.Ifc.Operator): bl_idname = "bim.add_document_reference" bl_label = "Add Document Reference" @@ -154,8 +157,6 @@ class EditDocument(bpy.types.Operator, tool.Ifc.Operator): if props.active_document_id: core.edit_document(tool.Ifc, tool.Document, document=tool.Ifc.get().by_id(props.active_document_id)) props.active_document_id = 0 - props.is_document_editing = False - tool.Document.update_assigned_documents() class RemoveDocument(bpy.types.Operator, tool.Ifc.Operator): @@ -167,6 +168,7 @@ class RemoveDocument(bpy.types.Operator, tool.Ifc.Operator): def _execute(self, context): core.remove_document(tool.Ifc, tool.Document, document=tool.Ifc.get().by_id(self.document)) + class AssignDocument(bpy.types.Operator, tool.Ifc.Operator): bl_idname = "bim.assign_document" bl_label = "Assign Document" @@ -183,11 +185,9 @@ class AssignDocument(bpy.types.Operator, tool.Ifc.Operator): if element: core.assign_document(tool.Ifc, product=element, document=document) - tool.Document.update_document_objects(self.document) ObjectDocumentData.is_loaded = False ObjectDocumentData.load() - tool.Document.update_assigned_documents() return {"FINISHED"} @@ -206,7 +206,7 @@ class UnassignDocument(bpy.types.Operator, tool.Ifc.Operator): element = tool.Ifc.get_entity(obj) if element: core.unassign_document(tool.Ifc, product=element, document=document) - + props = tool.Document.get_document_props() active_document_id = None if props.active_document: @@ -216,11 +216,9 @@ class UnassignDocument(bpy.types.Operator, tool.Ifc.Operator): tool.Document.update_document_objects(active_document_id) else: tool.Document.update_document_objects() - + ObjectDocumentData.is_loaded = False ObjectDocumentData.load() - - tool.Document.update_assigned_documents() return {"FINISHED"} @@ -253,16 +251,12 @@ class LoadObjectDocuments(bpy.types.Operator): bl_options = {"REGISTER", "UNDO"} def execute(self, context): - if not ObjectDocumentData.is_loaded: - ObjectDocumentData.load() - core.load_project_documents(tool.Document) props = tool.Document.get_document_props() props.is_object_editing = True - - tool.Document.update_assigned_documents() - + ObjectDocumentData.is_loaded = False + ObjectDocumentData.load() return {"FINISHED"} @@ -277,19 +271,24 @@ class OpenIFCDocument(bpy.types.Operator): def execute(self, context): import subprocess import os + if not self.uri or not self.uri.lower().startswith("file://"): self.report({"ERROR"}, "Only local file:// URIs are supported") return {"CANCELLED"} filepath = self.uri[7:] # Remove file:// prefix - + if not os.path.exists(filepath): self.report({"ERROR"}, f"File not found: {filepath}") return {"CANCELLED"} try: blender_path = bpy.app.binary_path - args = [blender_path, "--python-expr", "import bpy; bpy.ops.bim.load_project(filepath='{}')".format(filepath)] + args = [ + blender_path, + "--python-expr", + "import bpy; bpy.ops.bim.load_project(filepath='{}')".format(filepath), + ] subprocess.Popen(args) self.report({"INFO"}, f"Opening {filepath} in a new Blender instance") except Exception as e: @@ -325,4 +324,3 @@ class ToggleDocument(bpy.types.Operator): props.json_string = json.dumps(expanded_documents) bpy.ops.bim.load_project_documents() return {"FINISHED"} - diff --git a/src/bonsai/bonsai/bim/module/document/prop.py b/src/bonsai/bonsai/bim/module/document/prop.py index b0964dd5c9..5d9a978c69 100644 --- a/src/bonsai/bonsai/bim/module/document/prop.py +++ b/src/bonsai/bonsai/bim/module/document/prop.py @@ -51,7 +51,7 @@ def update_document_identification(self: "Document", context: bpy.types.Context) def update_active_document(self, context): - if (document := self.active_document): + if document := self.active_document: if document.ifc_definition_id: DocumentData.load_document_objects_into_props(document.ifc_definition_id) @@ -72,7 +72,7 @@ class Document(PropertyGroup): ("INFORMATION", "Information", "IfcDocumentInformation"), ("REFERENCE", "Reference", "IfcDocumentReference"), ], - default="INFORMATION" + default="INFORMATION", ) if TYPE_CHECKING: @@ -96,41 +96,15 @@ class DocumentObject(PropertyGroup): ifc_definition_id: int -class AssignedDocument(PropertyGroup): - name: StringProperty(name="Name") - identification: StringProperty(name="Identification") - description: StringProperty(name="Description", default="") - ifc_definition_id: IntProperty(name="IFC Definition ID") - location: StringProperty(name="Location", default="") - document_type: EnumProperty( - name="Document Type", - items=[ - ("PROJECT", "Project", "Virtual project root node"), - ("INFORMATION", "Information", "IfcDocumentInformation"), - ("REFERENCE", "Reference", "IfcDocumentReference"), - ], - default="INFORMATION" - ) - - if TYPE_CHECKING: - name: str - identification: str - ifc_definition_id: int - location: str - document_type: str - class BIMDocumentProperties(PropertyGroup): document_attributes: CollectionProperty(name="Document Attributes", type=Attribute) active_document_id: IntProperty(name="Active Document Id") documents: CollectionProperty(name="Documents", type=Document) active_document_index: IntProperty(name="Active Document Index", update=update_active_document) is_editing: BoolProperty(name="Is Editing", default=False) - is_document_editing: BoolProperty(name="Is Document Editing", default=False) is_object_editing: BoolProperty(name="Is Object Editing", default=False) document_objects: CollectionProperty(name="Document Objects", type=DocumentObject) active_document_object_index: IntProperty(name="Active Document Object Index") - assigned_documents: CollectionProperty(name="Assigned Documents", type=AssignedDocument) - active_assigned_document_index: IntProperty(name="Active Assigned Document Index") json_string: StringProperty(name="JSON String", default="[]") if TYPE_CHECKING: @@ -139,7 +113,6 @@ class BIMDocumentProperties(PropertyGroup): documents: bpy.types.bpy_prop_collection_idprop[Document] active_document_index: int is_editing: bool - is_document_editing: bool is_object_editing: bool document_objects: bpy.types.bpy_prop_collection_idprop[DocumentObject] active_document_object_index: int diff --git a/src/bonsai/bonsai/bim/module/document/ui.py b/src/bonsai/bonsai/bim/module/document/ui.py index a742130f94..84f7d04632 100644 --- a/src/bonsai/bonsai/bim/module/document/ui.py +++ b/src/bonsai/bonsai/bim/module/document/ui.py @@ -22,6 +22,7 @@ from bpy.types import Panel, UIList from bonsai.bim.helper import draw_attributes from .data import DocumentData, ObjectDocumentData + class BIM_PT_documents(Panel): bl_label = "Documents" bl_idname = "BIM_PT_documents" @@ -45,9 +46,7 @@ class BIM_PT_documents(Panel): split = row.split(factor=0.55) left_row = split.row(align=True) - total_documents = DocumentData.data["total_document_informations"] + DocumentData.data["total_document_references"] - left_row.label(text="{} Documents".format(total_documents), icon="FILE") - + left_row.label(text="{} Documents".format(DocumentData.data["total_documents"]), icon="FILE") right_row = split.row(align=True) right_row.label( text="{} Objects Referenced".format(DocumentData.data["total_referenced_objects"]), icon="OBJECT_DATA" @@ -63,7 +62,7 @@ class BIM_PT_documents(Panel): row = self.layout.row(align=True) row.alignment = "RIGHT" - if self.props.is_document_editing: + if self.props.active_document_id > 0: row.operator("bim.edit_document", text="", icon="CHECKMARK") row.operator("bim.disable_editing_document", text="", icon="CANCEL") else: @@ -71,25 +70,27 @@ class BIM_PT_documents(Panel): row.operator("bim.add_information", text="", icon="ADD") if self.props.active_document and ( - self.props.active_document.document_type == "INFORMATION" and - self.props.active_document.document_type != "PROJECT" + self.props.active_document.document_type == "INFORMATION" + and self.props.active_document.document_type != "PROJECT" ): row.operator("bim.add_document_reference", text="", icon="FILE_HIDDEN") active_document = self.props.active_document if active_document: ifc_definition_id = active_document.ifc_definition_id - + if active_document.document_type != "PROJECT": row.operator("bim.select_document_objects", text="", icon="RESTRICT_SELECT_OFF").document = ( ifc_definition_id ) row.operator("bim.assign_document", text="", icon="BRUSH_DATA").document = ifc_definition_id - row.operator("bim.enable_editing_document", text="", icon="GREASEPENCIL").document = ifc_definition_id - row.operator("bim.remove_document", text="", icon="X").document = ifc_definition_id + row.operator("bim.enable_editing_document", text="", icon="GREASEPENCIL").document = ( + ifc_definition_id + ) + row.operator("bim.remove_document", text="", icon="X").document = ifc_definition_id self.layout.template_list("BIM_UL_documents", "", self.props, "documents", self.props, "active_document_index") - if self.props.is_document_editing: + if self.props.active_document_id > 0: active_document = self.props.active_document draw_attributes(self.props.document_attributes, self.layout) @@ -107,6 +108,7 @@ class BIM_PT_documents(Panel): "active_document_object_index", ) + class BIM_PT_object_documents(Panel): bl_label = "Documents" bl_idname = "BIM_PT_object_documents" @@ -161,25 +163,27 @@ class BIM_PT_object_documents(Panel): col = box.column(align=True) for document in ObjectDocumentData.data["documents"]: row = col.row(align=True) - + # Create a split layout to separate left and right sides split = row.split(factor=0.7) # Adjust factor as needed (0.7 = 70% left, 30% right) - + # Left side - Document identification and name left_side = split.row(align=True) - left_side.alignment = 'LEFT' + left_side.alignment = "LEFT" left_side.label(text=document["identification"] or "*", icon="FILE") left_side.label(text=document["name"] or "Unnamed") - + # Right side - Action buttons right_side = split.row(align=True) - right_side.alignment = 'RIGHT' # Align buttons to the right - + right_side.alignment = "RIGHT" # Align buttons to the right + if document["location"]: if document["location"].lower().endswith(".ifc"): - right_side.operator("bim.open_ifc_document", icon="HIDE_OFF", text="").uri = document["location"] + right_side.operator("bim.open_ifc_document", icon="HIDE_OFF", text="").uri = document[ + "location" + ] right_side.operator("bim.open_uri", icon="URL", text="").uri = document["location"] - + right_side.operator("bim.unassign_document", text="", icon="X").document = document["id"] def draw_add_ui(self): @@ -194,9 +198,11 @@ class BIM_PT_object_documents(Panel): for doc in ObjectDocumentData.data["documents"]: assigned_doc_ids.append(doc["id"]) - if (document.document_type == "INFORMATION" and - document.document_type != "PROJECT" and - document.ifc_definition_id not in assigned_doc_ids): + if ( + document.document_type == "INFORMATION" + and document.document_type != "PROJECT" + and document.ifc_definition_id not in assigned_doc_ids + ): doc_op = row.operator("bim.assign_document", text="", icon="BRUSH_DATA") doc_op.document = document.ifc_definition_id elif document.ifc_definition_id in assigned_doc_ids: @@ -215,15 +221,15 @@ class BIM_UL_documents(UIList): if item.document_type != "PROJECT": if item.tree_depth > 1: indent_depth = item.tree_depth - 1 - + for i in range(indent_depth): row.label(text="", icon="BLANK1") - + if item.document_type == "PROJECT": row.label(text="", icon="OUTLINER_COLLECTION") row.label(text=item.name) return - + if item.document_type == "INFORMATION" and item.has_children: op = row.operator( "bim.toggle_document", icon="TRIA_DOWN" if item.is_expanded else "TRIA_RIGHT", text="", emboss=False @@ -232,7 +238,7 @@ class BIM_UL_documents(UIList): op.option = "Collapse" if item.is_expanded else "Expand" elif item.document_type == "INFORMATION": row.label(text="", icon="BLANK1") - + if item.document_type == "INFORMATION": row.label(text="", icon="FILE") text = " - ".join([x for x in [item.name, item.location] if x]) diff --git a/src/bonsai/bonsai/core/document.py b/src/bonsai/bonsai/core/document.py index e55c077022..07a167c785 100644 --- a/src/bonsai/bonsai/core/document.py +++ b/src/bonsai/bonsai/core/document.py @@ -29,27 +29,29 @@ def load_project_documents(document: tool.Document) -> None: document.import_project_documents() document.enable_editing_ui() + def disable_document_editing_ui(document: tool.Document) -> None: document.disable_editing_ui() document.disable_editing_document() + def disable_object_document_editing_ui(document: tool.Document) -> None: props = document.get_document_props() props.is_object_editing = False + def enable_editing_document(document_tool: tool.Document, document: ifcopenshell.entity_instance) -> None: props = document_tool.get_document_props() - props.is_document_editing = True document_tool.set_active_document(document) document_tool.import_document_attributes(document) def disable_editing_document(document: tool.Document) -> None: props = document.get_document_props() - props.is_document_editing = False document.clear_active_document() document.clear_document_attributes() + def add_information(ifc: tool.Ifc, document_tool: tool.Document, parent=None) -> ifcopenshell.entity_instance: document_tool.clear_document_tree() diff --git a/src/bonsai/bonsai/tool/document.py b/src/bonsai/bonsai/tool/document.py index cd51f35e8d..bbc20663dc 100644 --- a/src/bonsai/bonsai/tool/document.py +++ b/src/bonsai/bonsai/tool/document.py @@ -153,8 +153,7 @@ class Document(bonsai.core.tool.Document): if root.is_expanded: root_documents = natsorted( - root_documents, - key=lambda doc: (cls.get_document_information_id(doc) or "", doc.Name or "") + root_documents, key=lambda doc: (cls.get_document_information_id(doc) or "", doc.Name or "") ) for doc in root_documents: @@ -204,21 +203,17 @@ class Document(bonsai.core.tool.Document): ref_children = [d for d in children if not d.is_a("IfcDocumentInformation")] info_children = natsorted( - info_children, - key=lambda doc: (cls.get_document_information_id(doc) or "", doc.Name or "") + info_children, key=lambda doc: (cls.get_document_information_id(doc) or "", doc.Name or "") ) ref_children = natsorted( ref_children, - key=lambda doc: ( - cls.get_external_reference_id(doc) or "", - doc.Description or doc.Name or "" - ) + key=lambda doc: (cls.get_external_reference_id(doc) or "", doc.Description or doc.Name or ""), ) for child in info_children + ref_children: cls._process_document(child, props, document_children, expanded_documents, depth + 1) - + @classmethod def is_document_information(cls, document: ifcopenshell.entity_instance) -> bool: return document.is_a("IfcDocumentInformation") @@ -295,12 +290,14 @@ class Document(bonsai.core.tool.Document): @classmethod def refresh_document_data(cls) -> None: import bonsai.bim.module.document.data as document_data + document_data.DocumentData.is_loaded = False document_data.DocumentData.load() @classmethod def load_document_objects_into_props(cls, document_id: int) -> None: import bonsai.bim.module.document.data as document_data + document_data.DocumentData.load_document_objects_into_props(document_id) @classmethod @@ -314,32 +311,3 @@ class Document(bonsai.core.tool.Document): if document_id: cls.load_document_objects_into_props(document_id) - - @classmethod - def update_assigned_documents(cls) -> None: - from bonsai.bim.module.document.data import ObjectDocumentData - - ObjectDocumentData.is_loaded = False - - props = cls.get_document_props() - props.assigned_documents.clear() - - if not ObjectDocumentData.is_loaded: - ObjectDocumentData.load() - - if not ObjectDocumentData.data.get("documents"): - return - - sorted_docs = sorted( - ObjectDocumentData.data["documents"], - key=lambda doc: ((doc.get("identification") or "").lower(), (doc.get("name") or "").lower()), - ) - - for document in sorted_docs: - new = props.assigned_documents.add() - new.name = document["name"] or "Unnamed" - new.identification = document["identification"] or "*" - new.document_type = "INFORMATION" if document.get("is_information", False) else "REFERENCE" - new.ifc_definition_id = document["id"] - new.location = document.get("location") or "" - new.description = document.get("description") or "" \ No newline at end of file From 6f400c8e44bc9e097472542f4470c97f2bd46232 Mon Sep 17 00:00:00 2001 From: falken10vdl Date: Fri, 8 Aug 2025 18:48:15 +0200 Subject: [PATCH 22/49] updates based on core developer's feedback --- src/bonsai/bonsai/bim/helper.py | 3 -- src/bonsai/bonsai/bim/module/document/data.py | 19 ++++--------- .../bonsai/bim/module/document/operator.py | 28 +++++++------------ src/bonsai/bonsai/bim/module/document/prop.py | 24 +++------------- src/bonsai/bonsai/bim/module/document/ui.py | 17 ++++------- src/bonsai/bonsai/core/document.py | 5 +--- src/bonsai/bonsai/tool/document.py | 6 ++++ 7 files changed, 31 insertions(+), 71 deletions(-) diff --git a/src/bonsai/bonsai/bim/helper.py b/src/bonsai/bonsai/bim/helper.py index eb8d9fa6e4..e76b512de3 100644 --- a/src/bonsai/bonsai/bim/helper.py +++ b/src/bonsai/bonsai/bim/helper.py @@ -55,7 +55,6 @@ def draw_attributes( layout: bpy.types.UILayout, copy_operator: Optional[str] = None, popup_active_attribute: Optional[bonsai.bim.prop.Attribute] = None, - filter_attributes: list[str] = None, callback: Optional[Callable[[bonsai.bim.prop.Attribute, bpy.types.UILayout], None]] = None, *, enable_search: Union[bool, EllipsisType] = ..., @@ -76,8 +75,6 @@ def draw_attributes( """ for attribute in props: - if attribute.name in (filter_attributes or []): - continue row = layout.row(align=True) if attribute == popup_active_attribute: row.activate_init = True diff --git a/src/bonsai/bonsai/bim/module/document/data.py b/src/bonsai/bonsai/bim/module/document/data.py index 0ba0106881..7f91247b87 100644 --- a/src/bonsai/bonsai/bim/module/document/data.py +++ b/src/bonsai/bonsai/bim/module/document/data.py @@ -24,6 +24,11 @@ import bonsai.tool as tool from natsort import natsorted +def refresh(): + DocumentData.is_loaded = False + ObjectDocumentData.is_loaded = False + + class DocumentData: data = {} is_loaded = False @@ -32,7 +37,6 @@ class DocumentData: def load(cls): cls.data = { "total_documents": cls.total_documents(), - "total_referenced_objects": cls.total_referenced_objects(), "document_objects": cls.document_objects(), } cls.is_loaded = True @@ -42,19 +46,6 @@ class DocumentData: file = tool.Ifc.get() return len(file.by_type("IfcDocumentInformation")) + len(file.by_type("IfcDocumentReference")) - @classmethod - def total_referenced_objects(cls): - file = tool.Ifc.get() - document_rels = file.by_type("IfcRelAssociatesDocument") - documented_objects = set() - for rel in document_rels: - for related_object in rel.RelatedObjects: - obj = tool.Ifc.get_object(related_object) - if obj: - documented_objects.add(related_object.id()) - - return len(documented_objects) - @classmethod def document_objects(cls): document_objects = {} diff --git a/src/bonsai/bonsai/bim/module/document/operator.py b/src/bonsai/bonsai/bim/module/document/operator.py index c2ccd5edc9..10eab2f5ed 100644 --- a/src/bonsai/bonsai/bim/module/document/operator.py +++ b/src/bonsai/bonsai/bim/module/document/operator.py @@ -21,8 +21,7 @@ import json import bonsai.bim.handler import bonsai.tool as tool import bonsai.core.document as core -from .data import DocumentData, ObjectDocumentData - +from bonsai.bim.module.document.data import DocumentData, ObjectDocumentData class LoadProjectDocuments(bpy.types.Operator): bl_idname = "bim.load_project_documents" @@ -186,7 +185,6 @@ class AssignDocument(bpy.types.Operator, tool.Ifc.Operator): core.assign_document(tool.Ifc, product=element, document=document) tool.Document.update_document_objects(self.document) - ObjectDocumentData.is_loaded = False ObjectDocumentData.load() return {"FINISHED"} @@ -217,7 +215,6 @@ class UnassignDocument(bpy.types.Operator, tool.Ifc.Operator): else: tool.Document.update_document_objects() - ObjectDocumentData.is_loaded = False ObjectDocumentData.load() return {"FINISHED"} @@ -255,7 +252,6 @@ class LoadObjectDocuments(bpy.types.Operator): props = tool.Document.get_document_props() props.is_object_editing = True - ObjectDocumentData.is_loaded = False ObjectDocumentData.load() return {"FINISHED"} @@ -276,27 +272,23 @@ class OpenIFCDocument(bpy.types.Operator): self.report({"ERROR"}, "Only local file:// URIs are supported") return {"CANCELLED"} - filepath = self.uri[7:] # Remove file:// prefix + filepath = self.uri[7:] if not os.path.exists(filepath): self.report({"ERROR"}, f"File not found: {filepath}") return {"CANCELLED"} - try: - blender_path = bpy.app.binary_path - args = [ - blender_path, - "--python-expr", - "import bpy; bpy.ops.bim.load_project(filepath='{}')".format(filepath), - ] - subprocess.Popen(args) - self.report({"INFO"}, f"Opening {filepath} in a new Blender instance") - except Exception as e: - self.report({"ERROR"}, f"Failed to open IFC file: {str(e)}") + blender_path = bpy.app.binary_path + args = [ + blender_path, + "--python-expr", + "import bpy; bpy.ops.bim.load_project(filepath='{}')".format(filepath), + ] + subprocess.Popen(args) + self.report({"INFO"}, f"Opening {filepath} in a new Blender instance") return {"FINISHED"} - class ToggleDocument(bpy.types.Operator): bl_idname = "bim.toggle_document" bl_label = "Toggle Document" diff --git a/src/bonsai/bonsai/bim/module/document/prop.py b/src/bonsai/bonsai/bim/module/document/prop.py index 5d9a978c69..93369b546d 100644 --- a/src/bonsai/bonsai/bim/module/document/prop.py +++ b/src/bonsai/bonsai/bim/module/document/prop.py @@ -1,24 +1,7 @@ -# Bonsai - OpenBIM Blender Add-on -# Copyright (C) 2020, 2021 Dion Moult -# -# This file is part of Bonsai. -# -# Bonsai is free software: you can redistribute it and/or modify -# it under the terms of the GNU General Public License as published by -# the Free Software Foundation, either version 3 of the License, or -# (at your option) any later version. -# -# Bonsai is distributed in the hope that it will be useful, -# but WITHOUT ANY WARRANTY; without even the implied warranty of -# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -# GNU General Public License for more details. -# -# You should have received a copy of the GNU General Public License -# along with Bonsai. If not, see . - import bpy import bonsai.tool as tool from bonsai.bim.prop import StrProperty, Attribute +from bonsai.bim.module.document.data import refresh from bpy.types import PropertyGroup from bpy.props import ( PointerProperty, @@ -50,7 +33,8 @@ def update_document_identification(self: "Document", context: bpy.types.Context) tool.Document.set_external_reference_id(document, self.identification) -def update_active_document(self, context): +def update_active_document_index(self, context): + refresh() if document := self.active_document: if document.ifc_definition_id: DocumentData.load_document_objects_into_props(document.ifc_definition_id) @@ -100,7 +84,7 @@ class BIMDocumentProperties(PropertyGroup): document_attributes: CollectionProperty(name="Document Attributes", type=Attribute) active_document_id: IntProperty(name="Active Document Id") documents: CollectionProperty(name="Documents", type=Document) - active_document_index: IntProperty(name="Active Document Index", update=update_active_document) + active_document_index: IntProperty(name="Active Document Index", update=update_active_document_index) is_editing: BoolProperty(name="Is Editing", default=False) is_object_editing: BoolProperty(name="Is Object Editing", default=False) document_objects: CollectionProperty(name="Document Objects", type=DocumentObject) diff --git a/src/bonsai/bonsai/bim/module/document/ui.py b/src/bonsai/bonsai/bim/module/document/ui.py index 84f7d04632..60e83fdcbe 100644 --- a/src/bonsai/bonsai/bim/module/document/ui.py +++ b/src/bonsai/bonsai/bim/module/document/ui.py @@ -20,8 +20,7 @@ import bpy import bonsai.tool as tool from bpy.types import Panel, UIList from bonsai.bim.helper import draw_attributes -from .data import DocumentData, ObjectDocumentData - +from bonsai.bim.module.document.data import DocumentData, ObjectDocumentData class BIM_PT_documents(Panel): bl_label = "Documents" @@ -43,18 +42,12 @@ class BIM_PT_documents(Panel): self.props = tool.Document.get_document_props() row = self.layout.row(align=True) - split = row.split(factor=0.55) - - left_row = split.row(align=True) - left_row.label(text="{} Documents".format(DocumentData.data["total_documents"]), icon="FILE") - right_row = split.row(align=True) - right_row.label( - text="{} Objects Referenced".format(DocumentData.data["total_referenced_objects"]), icon="OBJECT_DATA" - ) + row.label(text="{} Documents found".format(DocumentData.data["total_documents"]), icon="FILE") + if self.props.is_editing: - right_row.operator("bim.disable_document_editing_ui", text="", icon="CANCEL") + row.operator("bim.disable_document_editing_ui", text="", icon="CANCEL") else: - right_row.operator("bim.load_project_documents", text="", icon="IMPORT") + row.operator("bim.load_project_documents", text="", icon="IMPORT") if not self.props.is_editing: return diff --git a/src/bonsai/bonsai/core/document.py b/src/bonsai/bonsai/core/document.py index 07a167c785..67680a7a55 100644 --- a/src/bonsai/bonsai/core/document.py +++ b/src/bonsai/bonsai/core/document.py @@ -36,18 +36,15 @@ def disable_document_editing_ui(document: tool.Document) -> None: def disable_object_document_editing_ui(document: tool.Document) -> None: - props = document.get_document_props() - props.is_object_editing = False + document.disable_object_editing_ui() def enable_editing_document(document_tool: tool.Document, document: ifcopenshell.entity_instance) -> None: - props = document_tool.get_document_props() document_tool.set_active_document(document) document_tool.import_document_attributes(document) def disable_editing_document(document: tool.Document) -> None: - props = document.get_document_props() document.clear_active_document() document.clear_document_attributes() diff --git a/src/bonsai/bonsai/tool/document.py b/src/bonsai/bonsai/tool/document.py index bbc20663dc..28edfe12e0 100644 --- a/src/bonsai/bonsai/tool/document.py +++ b/src/bonsai/bonsai/tool/document.py @@ -19,6 +19,7 @@ from __future__ import annotations import bpy import ifcopenshell.util.system +import bonsai.bim.helper import bonsai.core.tool import bonsai.tool as tool import json @@ -44,6 +45,11 @@ class Document(bonsai.core.tool.Document): props = cls.get_document_props() props.active_document_id = 0 + @classmethod + def disable_object_editing_ui(cls) -> None: + props = cls.get_document_props() + props.is_object_editing = False + @classmethod def disable_editing_ui(cls) -> None: props = cls.get_document_props() From e4ba633d94e5ce68d1d6937a6a75af194ceb56d8 Mon Sep 17 00:00:00 2001 From: falken10vdl Date: Fri, 8 Aug 2025 20:28:40 +0200 Subject: [PATCH 23/49] cleanup nomenclature and some redundant code --- src/bonsai/bonsai/bim/module/document/data.py | 3 +- .../bonsai/bim/module/document/operator.py | 12 ++--- src/bonsai/bonsai/bim/module/document/ui.py | 4 +- src/bonsai/bonsai/core/document.py | 54 +++++++++---------- src/bonsai/bonsai/tool/document.py | 47 +++++++--------- 5 files changed, 54 insertions(+), 66 deletions(-) diff --git a/src/bonsai/bonsai/bim/module/document/data.py b/src/bonsai/bonsai/bim/module/document/data.py index 7f91247b87..7fc05ca3d1 100644 --- a/src/bonsai/bonsai/bim/module/document/data.py +++ b/src/bonsai/bonsai/bim/module/document/data.py @@ -125,7 +125,6 @@ class ObjectDocumentData: location = None identification = None - description = None if is_information: if tool.Ifc.get_schema() == "IFC2X3": @@ -134,7 +133,7 @@ class ObjectDocumentData: identification = relating_document.Identification location = getattr(relating_document, "Location", None) - + description = getattr(relating_document, "Description", "No description") else: description = relating_document.Description if tool.Ifc.get_schema() == "IFC2X3": diff --git a/src/bonsai/bonsai/bim/module/document/operator.py b/src/bonsai/bonsai/bim/module/document/operator.py index 10eab2f5ed..f63b5e1693 100644 --- a/src/bonsai/bonsai/bim/module/document/operator.py +++ b/src/bonsai/bonsai/bim/module/document/operator.py @@ -60,7 +60,7 @@ class EnableEditingDocument(bpy.types.Operator): document: bpy.props.IntProperty() def execute(self, context): - core.enable_editing_document(tool.Document, document=tool.Ifc.get().by_id(self.document)) + core.enable_editing_document(tool.Document, ifc_document=tool.Ifc.get().by_id(self.document)) return {"FINISHED"} @@ -154,7 +154,7 @@ class EditDocument(bpy.types.Operator, tool.Ifc.Operator): def _execute(self, context): props = tool.Document.get_document_props() if props.active_document_id: - core.edit_document(tool.Ifc, tool.Document, document=tool.Ifc.get().by_id(props.active_document_id)) + core.edit_document(tool.Ifc, tool.Document, ifc_document=tool.Ifc.get().by_id(props.active_document_id)) props.active_document_id = 0 @@ -165,7 +165,7 @@ class RemoveDocument(bpy.types.Operator, tool.Ifc.Operator): document: bpy.props.IntProperty() def _execute(self, context): - core.remove_document(tool.Ifc, tool.Document, document=tool.Ifc.get().by_id(self.document)) + core.remove_document(tool.Ifc, tool.Document, ifc_document=tool.Ifc.get().by_id(self.document)) class AssignDocument(bpy.types.Operator, tool.Ifc.Operator): @@ -177,12 +177,11 @@ class AssignDocument(bpy.types.Operator, tool.Ifc.Operator): document: bpy.props.IntProperty() def _execute(self, context): - document = tool.Ifc.get().by_id(self.document) objs = [bpy.data.objects[self.obj]] if self.obj else tool.Blender.get_selected_objects() for obj in objs: element = tool.Ifc.get_entity(obj) if element: - core.assign_document(tool.Ifc, product=element, document=document) + core.assign_document(tool.Ifc, product=element, ifc_document=tool.Ifc.get().by_id(self.document)) tool.Document.update_document_objects(self.document) ObjectDocumentData.load() @@ -197,13 +196,12 @@ class UnassignDocument(bpy.types.Operator, tool.Ifc.Operator): document: bpy.props.IntProperty() def _execute(self, context): - document = tool.Ifc.get().by_id(self.document) objs = [bpy.data.objects.get(self.obj)] if self.obj else tool.Blender.get_selected_objects() for obj in objs: if obj: element = tool.Ifc.get_entity(obj) if element: - core.unassign_document(tool.Ifc, product=element, document=document) + core.unassign_document(tool.Ifc, product=element, ifc_document=tool.Ifc.get().by_id(self.document)) props = tool.Document.get_document_props() active_document_id = None diff --git a/src/bonsai/bonsai/bim/module/document/ui.py b/src/bonsai/bonsai/bim/module/document/ui.py index 60e83fdcbe..68a9c5adb2 100644 --- a/src/bonsai/bonsai/bim/module/document/ui.py +++ b/src/bonsai/bonsai/bim/module/document/ui.py @@ -234,10 +234,10 @@ class BIM_UL_documents(UIList): if item.document_type == "INFORMATION": row.label(text="", icon="FILE") - text = " - ".join([x for x in [item.name, item.location] if x]) + text = " - ".join([x for x in [item.location, item.description, item.name] if x]) else: row.label(text="", icon="FILE_HIDDEN") - text = " - ".join([x for x in [item.description, item.location] if x]) + text = " - ".join([x for x in [item.location, item.description] if x]) split1 = row.split(factor=0.1) split1.prop(item, "identification", text="", emboss=False) split2 = split1.split(factor=0.8) diff --git a/src/bonsai/bonsai/core/document.py b/src/bonsai/bonsai/core/document.py index 67680a7a55..7b42295500 100644 --- a/src/bonsai/bonsai/core/document.py +++ b/src/bonsai/bonsai/core/document.py @@ -39,9 +39,9 @@ def disable_object_document_editing_ui(document: tool.Document) -> None: document.disable_object_editing_ui() -def enable_editing_document(document_tool: tool.Document, document: ifcopenshell.entity_instance) -> None: - document_tool.set_active_document(document) - document_tool.import_document_attributes(document) +def enable_editing_document(document: tool.Document, ifc_document: ifcopenshell.entity_instance) -> None: + document.set_active_document(ifc_document) + document.import_document_attributes(ifc_document) def disable_editing_document(document: tool.Document) -> None: @@ -49,19 +49,19 @@ def disable_editing_document(document: tool.Document) -> None: document.clear_document_attributes() -def add_information(ifc: tool.Ifc, document_tool: tool.Document, parent=None) -> ifcopenshell.entity_instance: - document_tool.clear_document_tree() +def add_information(ifc: tool.Ifc, document: tool.Document, parent=None) -> ifcopenshell.entity_instance: + document.clear_document_tree() if parent is None: - parent = document_tool.get_default_parent_for_information(ifc) + parent = document.get_default_parent_for_information(ifc) information = ifc.run("document.add_information", parent=parent) ifc.run("document.add_reference", information=information) - if document_tool.is_document_information(parent): - document_tool.expand_document(parent) + if document.is_document_information(parent): + document.expand_document(parent) - document_tool.import_project_documents() + document.import_project_documents() return information @@ -76,33 +76,33 @@ def add_reference(ifc: tool.Ifc, document: tool.Document) -> None: document.import_project_documents() -def edit_document(ifc: tool.Ifc, document_tool: tool.Document, document: ifcopenshell.entity_instance) -> None: - attributes = document_tool.export_document_attributes() - if document_tool.is_document_information(document): - ifc.run("document.edit_information", information=document, attributes=attributes) +def edit_document(ifc: tool.Ifc, document: tool.Document, ifc_document: ifcopenshell.entity_instance) -> None: + attributes = document.export_document_attributes() + if document.is_document_information(ifc_document): + ifc.run("document.edit_information", information=ifc_document, attributes=attributes) else: - ifc.run("document.edit_reference", reference=document, attributes=attributes) - document_tool.disable_editing_document() - document_tool.clear_document_tree() - document_tool.import_project_documents() + ifc.run("document.edit_reference", reference=ifc_document, attributes=attributes) + document.disable_editing_document() + document.clear_document_tree() + document.import_project_documents() -def remove_document(ifc: tool.Ifc, document_tool: tool.Document, document: ifcopenshell.entity_instance) -> None: - document_tool.clear_document_tree() - if document_tool.is_document_information(document): - ifc.run("document.remove_information", information=document) +def remove_document(ifc: tool.Ifc, document: tool.Document, ifc_document: ifcopenshell.entity_instance) -> None: + document.clear_document_tree() + if document.is_document_information(ifc_document): + ifc.run("document.remove_information", information=ifc_document) else: - ifc.run("document.remove_reference", reference=document) - document_tool.import_project_documents() + ifc.run("document.remove_reference", reference=ifc_document) + document.import_project_documents() def assign_document( - ifc: tool.Ifc, product: ifcopenshell.entity_instance, document: ifcopenshell.entity_instance + ifc: tool.Ifc, product: ifcopenshell.entity_instance, ifc_document: ifcopenshell.entity_instance ) -> None: - ifc.run("document.assign_document", products=[product], document=document) + ifc.run("document.assign_document", products=[product], document=ifc_document) def unassign_document( - ifc: tool.Ifc, product: ifcopenshell.entity_instance, document: ifcopenshell.entity_instance + ifc: tool.Ifc, product: ifcopenshell.entity_instance, ifc_document: ifcopenshell.entity_instance ) -> None: - ifc.run("document.unassign_document", products=[product], document=document) + ifc.run("document.unassign_document", products=[product], document=ifc_document) diff --git a/src/bonsai/bonsai/tool/document.py b/src/bonsai/bonsai/tool/document.py index 28edfe12e0..f8f45fffcf 100644 --- a/src/bonsai/bonsai/tool/document.py +++ b/src/bonsai/bonsai/tool/document.py @@ -172,30 +172,23 @@ class Document(bonsai.core.tool.Document): new.document_type = "INFORMATION" if document.is_a("IfcDocumentInformation") else "REFERENCE" new.tree_depth = depth - file = document.file + new.name = document.Name or "" + new.identification = cls.get_document_information_id(document) if new.document_type == "INFORMATION" else cls.get_external_reference_id(document) + new.identification = new.identification or "" + new.description = document.Description or "" + new.location = document.Location or "" + if new.document_type == "INFORMATION": new.name = document.Name or "Unnamed" - new.identification = cls.get_document_information_id(document) or "" - new.location = document.Location or "" - else: - new.name = document.Name or "" - new.identification = cls.get_external_reference_id(document) or "" - new.description = document.Description or "" - new.location = document.Location or "" - - if new.document_type == "REFERENCE": - if file.schema == "IFC2X3": - if document.ReferenceToDocument: - doc_info = document.ReferenceToDocument[0] - if not new.name: - new.name = doc_info.Name or "" - new.location = new.location or "" - else: - if document.ReferencedDocument: - doc_info = document.ReferencedDocument - if not new.name: - new.name = doc_info.Name or "" - new.location = new.location or "" + + elif new.document_type == "REFERENCE": + file = document.file + if file.schema == "IFC2X3": + if document.ReferenceToDocument and not new.name: + new.name = document.ReferenceToDocument[0].Name or "" + else: + if document.ReferencedDocument and not new.name: + new.name = document.ReferencedDocument.Name or "" doc_id = document.id() has_children = doc_id in document_children and bool(document_children[doc_id]) @@ -205,16 +198,14 @@ class Document(bonsai.core.tool.Document): if has_children and new.is_expanded: children = document_children[doc_id] - info_children = [d for d in children if d.is_a("IfcDocumentInformation")] - ref_children = [d for d in children if not d.is_a("IfcDocumentInformation")] - info_children = natsorted( - info_children, key=lambda doc: (cls.get_document_information_id(doc) or "", doc.Name or "") + [d for d in children if d.is_a("IfcDocumentInformation")], + key=lambda doc: (cls.get_document_information_id(doc) or "", doc.Name or "") ) ref_children = natsorted( - ref_children, - key=lambda doc: (cls.get_external_reference_id(doc) or "", doc.Description or doc.Name or ""), + [d for d in children if not d.is_a("IfcDocumentInformation")], + key=lambda doc: (cls.get_external_reference_id(doc) or "", doc.Description or doc.Name or "") ) for child in info_children + ref_children: From df6592c7b96a330ea9f84961e7bbe157f732ece4 Mon Sep 17 00:00:00 2001 From: falken10vdl Date: Thu, 21 Aug 2025 09:42:22 +0200 Subject: [PATCH 24/49] updated to get make test-tool MODULE=document working --- src/bonsai/test/tool/test_document.py | 78 +++++++++++++++++++-------- 1 file changed, 55 insertions(+), 23 deletions(-) diff --git a/src/bonsai/test/tool/test_document.py b/src/bonsai/test/tool/test_document.py index 3d313f6293..628f040923 100644 --- a/src/bonsai/test/tool/test_document.py +++ b/src/bonsai/test/tool/test_document.py @@ -22,6 +22,7 @@ import ifcopenshell.api import ifcopenshell.api.document import bonsai.core.tool import bonsai.tool as tool +import json from test.bim.bootstrap import NewFile from bonsai.tool.document import Document as subject @@ -139,35 +140,66 @@ class TestImportDocumentAttributes(NewFile): assert props.document_attributes["Description"].string_value == "Description" -class TestImportProjectDocuments(NewFile): +class TestImportProjectDocumentsExpanded(NewFile): def test_run(self): ifc = ifcopenshell.file() tool.Ifc().set(ifc) - ifc.createIfcProject() - document = ifcopenshell.api.document.add_information(ifc) - subject.import_project_documents() - props = tool.Document.get_document_props() - assert len(props.documents) == 1 - assert props.documents[0].ifc_definition_id == document.id() - assert props.documents[0].name == "Unnamed" - assert props.documents[0].identification == "X" - assert props.documents[0].is_information is True - - -class TestImportReferences(NewFile): - def test_run(self): - ifc = ifcopenshell.file() - tool.Ifc().set(ifc) - ifc.createIfcProject() + project = ifc.createIfcProject() document = ifcopenshell.api.document.add_information(ifc) reference = ifcopenshell.api.document.add_reference(ifc, information=document) - subject.import_references(document) + + props = tool.Document.get_document_props() - assert len(props.documents) == 1 - assert props.documents[0].ifc_definition_id == reference.id() - assert props.documents[0].name == "Unnamed" - assert props.documents[0].identification == "X" - assert props.documents[0].is_information is False + expanded_docs = [document.id()] # Mark document as expanded + props.json_string = json.dumps(expanded_docs) + + subject.import_project_documents() + props = tool.Document.get_document_props() + + # Should have project root + document + reference = 3 total + assert len(props.documents) == 3 + + assert props.documents[0].ifc_definition_id == -project.id() + assert props.documents[0].document_type == "PROJECT" + + doc_info = next((d for d in props.documents if d.ifc_definition_id == document.id()), None) + assert doc_info is not None + assert doc_info.document_type == "INFORMATION" + + doc_ref = next((d for d in props.documents if d.ifc_definition_id == reference.id()), None) + assert doc_ref is not None + assert doc_ref.location == "" + assert doc_ref.identification == "X" + assert doc_ref.document_type == "REFERENCE" + + +class TestImportProjectDocumentsCollapsed(NewFile): + def test_run(self): + ifc = ifcopenshell.file() + tool.Ifc().set(ifc) + project = ifc.createIfcProject() + document = ifcopenshell.api.document.add_information(ifc) + reference = ifcopenshell.api.document.add_reference(ifc, information=document) + + + props = tool.Document.get_document_props() + props.json_string = json.dumps([]) # Empty expanded list + + subject.import_project_documents() + props = tool.Document.get_document_props() + + # Should have project root + document = 2 total (reference not imported because parent is collapsed) + assert len(props.documents) == 2 + + assert props.documents[0].ifc_definition_id == -project.id() + assert props.documents[0].document_type == "PROJECT" + + doc_info = next((d for d in props.documents if d.ifc_definition_id == document.id()), None) + assert doc_info is not None + assert doc_info.document_type == "INFORMATION" + + doc_ref = next((d for d in props.documents if d.ifc_definition_id == reference.id()), None) + assert doc_ref is None class TestIsDocumentInformation(NewFile): From 454ed2c5a118f930eb58495121ea2a6558296801 Mon Sep 17 00:00:00 2001 From: falken10vdl Date: Thu, 21 Aug 2025 10:16:44 +0200 Subject: [PATCH 25/49] updated to get make test-bim MODULE=document working --- src/bonsai/test/bim/feature/document.feature | 39 ++++++-------------- 1 file changed, 12 insertions(+), 27 deletions(-) diff --git a/src/bonsai/test/bim/feature/document.feature b/src/bonsai/test/bim/feature/document.feature index 9ef9657499..35509c2314 100644 --- a/src/bonsai/test/bim/feature/document.feature +++ b/src/bonsai/test/bim/feature/document.feature @@ -6,14 +6,6 @@ Scenario: Load project documents When I press "bim.load_project_documents" Then nothing happens -Scenario: Load document - Given an empty IFC project - And I press "bim.load_project_documents" - And I press "bim.add_information" - And the variable "information" is "{ifc}.by_type('IfcDocumentInformation')[-1].id()" - When I press "bim.load_document(document={information})" - Then nothing happens - Scenario: Disable document editing UI Given an empty IFC project And I press "bim.load_project_documents" @@ -48,7 +40,8 @@ Scenario: Add document reference And I press "bim.load_project_documents" And I press "bim.add_information" And the variable "information" is "{ifc}.by_type('IfcDocumentInformation')[-1].id()" - And I press "bim.load_document(document={information})" + And I press "bim.load_project_documents" + And I set "scene.BIMDocumentProperties.active_document_index" to "1" When I press "bim.add_document_reference" Then nothing happens @@ -74,16 +67,12 @@ Scenario: Assign document And I press "bim.load_project_documents" And I press "bim.add_information" And the variable "information" is "{ifc}.by_type('IfcDocumentInformation')[-1].id()" - And I press "bim.load_document(document={information})" - And I press "bim.add_document_reference" - And the variable "reference" is "{ifc}.by_type('IfcDocumentReference')[-1].id()" And I add a cube And the object "Cube" is selected - And I look at the "Class" panel - And I set the "Products" property to "IfcElement" - And I set the "Class" property to "IfcWall" - And I click "Assign IFC Class" - When I press "bim.assign_document(document={reference})" + And I set "scene.BIMRootProperties.ifc_product" to "IfcElement" + And I set "scene.BIMRootProperties.ifc_class" to "IfcWall" + And I press "bim.assign_class" + When I press "bim.assign_document(document={information})" Then nothing happens Scenario: Unassign document @@ -91,15 +80,11 @@ Scenario: Unassign document And I press "bim.load_project_documents" And I press "bim.add_information" And the variable "information" is "{ifc}.by_type('IfcDocumentInformation')[-1].id()" - And I press "bim.load_document(document={information})" - And I press "bim.add_document_reference" - And the variable "reference" is "{ifc}.by_type('IfcDocumentReference')[-1].id()" And I add a cube And the object "Cube" is selected - And I look at the "Class" panel - And I set the "Products" property to "IfcElement" - And I set the "Class" property to "IfcWall" - And I click "Assign IFC Class" - And I press "bim.assign_document(document={reference})" - When I press "bim.unassign_document(document={reference})" - Then nothing happens + And I set "scene.BIMRootProperties.ifc_product" to "IfcElement" + And I set "scene.BIMRootProperties.ifc_class" to "IfcWall" + And I press "bim.assign_class" + And I press "bim.assign_document(document={information})" + When I press "bim.unassign_document(document={information})" + Then nothing happens \ No newline at end of file From 81fdf63bd03bfdf0eb2b6ded2f35a4d7b8131451 Mon Sep 17 00:00:00 2001 From: falken10vdl Date: Thu, 21 Aug 2025 12:05:41 +0200 Subject: [PATCH 26/49] adapted to get pytest -p no:pytest-blender test/core/test_document.py working. Black formating --- .../bonsai/bim/module/document/operator.py | 2 + src/bonsai/bonsai/bim/module/document/ui.py | 3 +- src/bonsai/bonsai/core/document.py | 4 +- src/bonsai/bonsai/core/tool.py | 14 ++++ src/bonsai/bonsai/tool/document.py | 24 ++++--- src/bonsai/test/core/test_document.py | 70 +++++++++++++------ src/bonsai/test/tool/test_document.py | 18 +++-- 7 files changed, 91 insertions(+), 44 deletions(-) diff --git a/src/bonsai/bonsai/bim/module/document/operator.py b/src/bonsai/bonsai/bim/module/document/operator.py index f63b5e1693..447d3b1693 100644 --- a/src/bonsai/bonsai/bim/module/document/operator.py +++ b/src/bonsai/bonsai/bim/module/document/operator.py @@ -23,6 +23,7 @@ import bonsai.tool as tool import bonsai.core.document as core from bonsai.bim.module.document.data import DocumentData, ObjectDocumentData + class LoadProjectDocuments(bpy.types.Operator): bl_idname = "bim.load_project_documents" bl_label = "Load Project Documents" @@ -287,6 +288,7 @@ class OpenIFCDocument(bpy.types.Operator): return {"FINISHED"} + class ToggleDocument(bpy.types.Operator): bl_idname = "bim.toggle_document" bl_label = "Toggle Document" diff --git a/src/bonsai/bonsai/bim/module/document/ui.py b/src/bonsai/bonsai/bim/module/document/ui.py index 68a9c5adb2..c382f1c721 100644 --- a/src/bonsai/bonsai/bim/module/document/ui.py +++ b/src/bonsai/bonsai/bim/module/document/ui.py @@ -22,6 +22,7 @@ from bpy.types import Panel, UIList from bonsai.bim.helper import draw_attributes from bonsai.bim.module.document.data import DocumentData, ObjectDocumentData + class BIM_PT_documents(Panel): bl_label = "Documents" bl_idname = "BIM_PT_documents" @@ -43,7 +44,7 @@ class BIM_PT_documents(Panel): row = self.layout.row(align=True) row.label(text="{} Documents found".format(DocumentData.data["total_documents"]), icon="FILE") - + if self.props.is_editing: row.operator("bim.disable_document_editing_ui", text="", icon="CANCEL") else: diff --git a/src/bonsai/bonsai/core/document.py b/src/bonsai/bonsai/core/document.py index 7b42295500..ac49fee10d 100644 --- a/src/bonsai/bonsai/core/document.py +++ b/src/bonsai/bonsai/core/document.py @@ -53,7 +53,7 @@ def add_information(ifc: tool.Ifc, document: tool.Document, parent=None) -> ifco document.clear_document_tree() if parent is None: - parent = document.get_default_parent_for_information(ifc) + parent = document.get_default_parent_for_information() information = ifc.run("document.add_information", parent=parent) ifc.run("document.add_reference", information=information) @@ -66,7 +66,7 @@ def add_information(ifc: tool.Ifc, document: tool.Document, parent=None) -> ifco def add_reference(ifc: tool.Ifc, document: tool.Document) -> None: - parent = document.get_selected_document_information(ifc) + parent = document.get_selected_document_information() if parent: reference = ifc.run("document.add_reference", information=parent) diff --git a/src/bonsai/bonsai/core/tool.py b/src/bonsai/bonsai/core/tool.py index 4061914bee..36afd7d66c 100644 --- a/src/bonsai/bonsai/core/tool.py +++ b/src/bonsai/bonsai/core/tool.py @@ -289,6 +289,7 @@ class Debug: class Document: def clear_document_tree(cls): pass def disable_editing_document(cls): pass + def disable_object_editing_ui(cls): pass def disable_editing_ui(cls): pass def enable_editing_ui(cls): pass def export_document_attributes(cls): pass @@ -296,6 +297,19 @@ class Document: def import_project_documents(cls): pass def is_document_information(cls, document): pass def set_active_document(cls, document): pass + def clear_active_document(cls): pass + def clear_document_attributes(cls): pass + def expand_document(cls, document): pass + def get_default_parent_for_information(cls): pass + def get_selected_document_information(cls): pass + def get_document_information_id(cls, document): pass + def set_document_information_id(cls, document, value): pass + def get_external_reference_id(cls, reference): pass + def set_external_reference_id(cls, reference, value): pass + def get_document_references(cls, document): pass + def refresh_document_data(cls): pass + def load_document_objects_into_props(cls, document_id): pass + def update_document_objects(cls, document_id): pass @interface diff --git a/src/bonsai/bonsai/tool/document.py b/src/bonsai/bonsai/tool/document.py index f8f45fffcf..bf8e4d17b8 100644 --- a/src/bonsai/bonsai/tool/document.py +++ b/src/bonsai/bonsai/tool/document.py @@ -173,14 +173,18 @@ class Document(bonsai.core.tool.Document): new.tree_depth = depth new.name = document.Name or "" - new.identification = cls.get_document_information_id(document) if new.document_type == "INFORMATION" else cls.get_external_reference_id(document) + new.identification = ( + cls.get_document_information_id(document) + if new.document_type == "INFORMATION" + else cls.get_external_reference_id(document) + ) new.identification = new.identification or "" new.description = document.Description or "" new.location = document.Location or "" - + if new.document_type == "INFORMATION": new.name = document.Name or "Unnamed" - + elif new.document_type == "REFERENCE": file = document.file if file.schema == "IFC2X3": @@ -200,12 +204,12 @@ class Document(bonsai.core.tool.Document): info_children = natsorted( [d for d in children if d.is_a("IfcDocumentInformation")], - key=lambda doc: (cls.get_document_information_id(doc) or "", doc.Name or "") + key=lambda doc: (cls.get_document_information_id(doc) or "", doc.Name or ""), ) ref_children = natsorted( [d for d in children if not d.is_a("IfcDocumentInformation")], - key=lambda doc: (cls.get_external_reference_id(doc) or "", doc.Description or doc.Name or "") + key=lambda doc: (cls.get_external_reference_id(doc) or "", doc.Description or doc.Name or ""), ) for child in info_children + ref_children: @@ -272,16 +276,18 @@ class Document(bonsai.core.tool.Document): props.json_string = json.dumps(expanded_docs) @classmethod - def get_default_parent_for_information(cls, ifc) -> Union[ifcopenshell.entity_instance, None]: - projects = ifc.get().by_type("IfcProject") + def get_default_parent_for_information(cls) -> Union[ifcopenshell.entity_instance, None]: + file = tool.Ifc.get() + projects = file.by_type("IfcProject") return projects[0] if projects else None @classmethod - def get_selected_document_information(cls, ifc) -> Union[ifcopenshell.entity_instance, None]: + def get_selected_document_information(cls) -> Union[ifcopenshell.entity_instance, None]: props = cls.get_document_props() if props.active_document and props.active_document.document_type == "INFORMATION": - return ifc.get().by_id(props.active_document.ifc_definition_id) + file = tool.Ifc.get() + return file.by_id(props.active_document.ifc_definition_id) return None @classmethod diff --git a/src/bonsai/test/core/test_document.py b/src/bonsai/test/core/test_document.py index ab3563551d..5baf3e709f 100644 --- a/src/bonsai/test/core/test_document.py +++ b/src/bonsai/test/core/test_document.py @@ -16,7 +16,6 @@ # You should have received a copy of the GNU General Public License # along with Bonsai. If not, see . - import bonsai.core.document as subject from test.core.bootstrap import ifc, document @@ -29,14 +28,6 @@ class TestLoadProjectDocuments: subject.load_project_documents(document) -class TestLoadDocument: - def test_run(self, document): - document.clear_document_tree().should_be_called() - document.disable_editing_document().should_be_called() - document.add_breadcrumb("document").should_be_called() - subject.load_document(document, document="document") - - class TestDisableDocumentEditingUi: def test_run(self, document): document.disable_editing_ui().should_be_called() @@ -44,38 +35,71 @@ class TestDisableDocumentEditingUi: subject.disable_document_editing_ui(document) +class TestDisableObjectDocumentEditingUi: + def test_run(self, document): + document.disable_object_editing_ui().should_be_called() + subject.disable_object_document_editing_ui(document) + + class TestEnableEditingDocument: def test_run(self, document): - document.import_document_attributes("document").should_be_called() document.set_active_document("document").should_be_called() - subject.enable_editing_document(document, document="document") + document.import_document_attributes("document").should_be_called() + subject.enable_editing_document(document, ifc_document="document") class TestDisableEditingDocument: def test_run(self, document): - document.disable_editing_document().should_be_called() + document.clear_active_document().should_be_called() + document.clear_document_attributes().should_be_called() subject.disable_editing_document(document) class TestAddInformation: def test_add_and_reload_tree_at_project_root(self, ifc, document): document.clear_document_tree().should_be_called() - ifc.run("document.add_information", parent=None).should_be_called().will_return("information") + document.get_default_parent_for_information().should_be_called().will_return("default_parent") + ifc.run("document.add_information", parent="default_parent").should_be_called().will_return("information") ifc.run("document.add_reference", information="information").should_be_called() + document.is_document_information("default_parent").should_be_called().will_return(True) + document.expand_document("default_parent").should_be_called() document.import_project_documents().should_be_called() + subject.add_information(ifc, document) def test_add_and_reload_tree_at_current_parent(self, ifc, document): document.clear_document_tree().should_be_called() ifc.run("document.add_information", parent="parent").should_be_called().will_return("information") ifc.run("document.add_reference", information="information").should_be_called() - subject.add_information(ifc, document) + document.is_document_information("parent").should_be_called().will_return(True) + document.expand_document("parent").should_be_called() + document.import_project_documents().should_be_called() + + subject.add_information(ifc, document, parent="parent") + + def test_add_without_expanding_if_parent_is_not_information(self, ifc, document): + document.clear_document_tree().should_be_called() + ifc.run("document.add_information", parent="parent").should_be_called().will_return("information") + ifc.run("document.add_reference", information="information").should_be_called() + document.is_document_information("parent").should_be_called().will_return(False) + document.import_project_documents().should_be_called() + + subject.add_information(ifc, document, parent="parent") class TestAddReference: - def test_run(self, ifc, document): + def test_run_with_selected_parent(self, ifc, document): + document.get_selected_document_information().should_be_called().will_return("parent") ifc.run("document.add_reference", information="parent").should_be_called() - document.clear_document_tree().should_be_called() + document.expand_document("parent").should_be_called() + document.import_project_documents().should_be_called() + + subject.add_reference(ifc, document) + + def test_run_without_selected_parent(self, ifc, document): + document.get_selected_document_information().should_be_called().will_return(None) + document.import_project_documents().should_be_called() + subject.add_reference(ifc, document) @@ -87,7 +111,7 @@ class TestEditDocument: document.disable_editing_document().should_be_called() document.clear_document_tree().should_be_called() document.import_project_documents().should_be_called() - subject.edit_document(ifc, document, document="document") + subject.edit_document(ifc, document, ifc_document="document") def test_edit_reference(self, ifc, document): document.export_document_attributes().should_be_called().will_return("attributes") @@ -95,7 +119,8 @@ class TestEditDocument: ifc.run("document.edit_reference", reference="document", attributes="attributes").should_be_called() document.disable_editing_document().should_be_called() document.clear_document_tree().should_be_called() - subject.edit_document(ifc, document, document="document") + document.import_project_documents().should_be_called() + subject.edit_document(ifc, document, ifc_document="document") class TestRemoveDocument: @@ -104,22 +129,23 @@ class TestRemoveDocument: document.is_document_information("document").should_be_called().will_return(True) ifc.run("document.remove_information", information="document").should_be_called() document.import_project_documents().should_be_called() - subject.remove_document(ifc, document, document="document") + subject.remove_document(ifc, document, ifc_document="document") def test_remove_reference(self, ifc, document): document.clear_document_tree().should_be_called() document.is_document_information("document").should_be_called().will_return(False) ifc.run("document.remove_reference", reference="document").should_be_called() - subject.remove_document(ifc, document, document="document") + document.import_project_documents().should_be_called() + subject.remove_document(ifc, document, ifc_document="document") class TestAssignDocument: def test_run(self, ifc): ifc.run("document.assign_document", products=["product"], document="document").should_be_called() - subject.assign_document(ifc, product="product", document="document") + subject.assign_document(ifc, product="product", ifc_document="document") class TestUnassignDocument: def test_run(self, ifc): ifc.run("document.unassign_document", products=["product"], document="document").should_be_called() - subject.unassign_document(ifc, product="product", document="document") + subject.unassign_document(ifc, product="product", ifc_document="document") diff --git a/src/bonsai/test/tool/test_document.py b/src/bonsai/test/tool/test_document.py index 628f040923..64ec2791b0 100644 --- a/src/bonsai/test/tool/test_document.py +++ b/src/bonsai/test/tool/test_document.py @@ -148,24 +148,23 @@ class TestImportProjectDocumentsExpanded(NewFile): document = ifcopenshell.api.document.add_information(ifc) reference = ifcopenshell.api.document.add_reference(ifc, information=document) - props = tool.Document.get_document_props() expanded_docs = [document.id()] # Mark document as expanded props.json_string = json.dumps(expanded_docs) - + subject.import_project_documents() props = tool.Document.get_document_props() # Should have project root + document + reference = 3 total assert len(props.documents) == 3 - + assert props.documents[0].ifc_definition_id == -project.id() assert props.documents[0].document_type == "PROJECT" - + doc_info = next((d for d in props.documents if d.ifc_definition_id == document.id()), None) assert doc_info is not None assert doc_info.document_type == "INFORMATION" - + doc_ref = next((d for d in props.documents if d.ifc_definition_id == reference.id()), None) assert doc_ref is not None assert doc_ref.location == "" @@ -180,24 +179,23 @@ class TestImportProjectDocumentsCollapsed(NewFile): project = ifc.createIfcProject() document = ifcopenshell.api.document.add_information(ifc) reference = ifcopenshell.api.document.add_reference(ifc, information=document) - props = tool.Document.get_document_props() props.json_string = json.dumps([]) # Empty expanded list - + subject.import_project_documents() props = tool.Document.get_document_props() # Should have project root + document = 2 total (reference not imported because parent is collapsed) assert len(props.documents) == 2 - + assert props.documents[0].ifc_definition_id == -project.id() assert props.documents[0].document_type == "PROJECT" - + doc_info = next((d for d in props.documents if d.ifc_definition_id == document.id()), None) assert doc_info is not None assert doc_info.document_type == "INFORMATION" - + doc_ref = next((d for d in props.documents if d.ifc_definition_id == reference.id()), None) assert doc_ref is None From 8205408a66713a1e9629ee9cc15d4393dc00686a Mon Sep 17 00:00:00 2001 From: falken10vdl <33285113+falken10vdl@users.noreply.github.com> Date: Sat, 10 Jan 2026 15:24:04 +0100 Subject: [PATCH 27/49] Add filter_mode property to BIMFacet for selection management (#7548) --- src/bonsai/bonsai/bim/prop.py | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/src/bonsai/bonsai/bim/prop.py b/src/bonsai/bonsai/bim/prop.py index d4ed4f8a9b..f799076727 100644 --- a/src/bonsai/bonsai/bim/prop.py +++ b/src/bonsai/bonsai/bim/prop.py @@ -791,12 +791,21 @@ class BIMFacet(PropertyGroup): ("!*=", "does not contain", ""), ], ) + filter_mode: EnumProperty( + items=[ + ("ADD", "Add", "Add results to the current selection"), + ("SUBTRACT", "Subtract", "Remove results from the current selection"), + ("FILTER", "Filter", "Filter the current selection"), + ], + default="ADD", + ) if TYPE_CHECKING: pset: str value: str type: str comparison: Literal["=", "!=", ">=", "<=", ">", "<", "*=", "!*="] + filter_mode: Literal["ADD", "SUBTRACT", "FILTER"] class BIMFilterGroup(PropertyGroup): From c2b72440a271e6b7d1ae8faa905d6e09d9930969 Mon Sep 17 00:00:00 2001 From: falken10vdl <33285113+falken10vdl@users.noreply.github.com> Date: Sat, 10 Jan 2026 15:30:43 +0100 Subject: [PATCH 28/49] Use helper function for search suggestions in literal property drawing (#7547) --- src/bonsai/bonsai/bim/module/drawing/ui.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/bonsai/bonsai/bim/module/drawing/ui.py b/src/bonsai/bonsai/bim/module/drawing/ui.py index 552ca7f9b4..c781d6ea23 100644 --- a/src/bonsai/bonsai/bim/module/drawing/ui.py +++ b/src/bonsai/bonsai/bim/module/drawing/ui.py @@ -679,7 +679,7 @@ class BIM_PT_text(Panel): if len(literal_props.attributes) > 0 and i < len(props.literal_apply_settings): row = box.row(align=True) - row.prop(literal_props.attributes[0], "string_value", text="Literal") + bonsai.bim.helper.draw_attribute(literal_props.attributes[0], row, enable_search=True) expand_icon = "DOWNARROW_HLT" if getattr(literal_props, "show_element_values", False) else "RIGHTARROW" op = row.operator("bim.toggle_element_values_panel", icon=expand_icon, text="") From 15bbdc808527df58bec62627d146c9f6123ae890 Mon Sep 17 00:00:00 2001 From: Ryan Schultz Date: Sat, 10 Jan 2026 09:36:36 -0600 Subject: [PATCH 29/49] Fix #7539: preserve nested aggregate structure when refreshing linked aggregates --- .../bonsai/bim/module/geometry/operator.py | 133 ++++++++++++------ 1 file changed, 89 insertions(+), 44 deletions(-) diff --git a/src/bonsai/bonsai/bim/module/geometry/operator.py b/src/bonsai/bonsai/bim/module/geometry/operator.py index d148b3df05..4282cd8625 100644 --- a/src/bonsai/bonsai/bim/module/geometry/operator.py +++ b/src/bonsai/bonsai/bim/module/geometry/operator.py @@ -1491,6 +1491,7 @@ class RefreshLinkedAggregate(bpy.types.Operator, tool.Ifc.Operator): old_to_new = {} original_data: dict[int, dict[int, dict[str, Any]]] = {} + # Define all nested functions FIRST def delete_objects(element: ifcopenshell.entity_instance) -> None: """Remove IfcElementAssembly and it's parts.""" parts = ifcopenshell.util.element.get_parts(element) @@ -1542,7 +1543,10 @@ class RefreshLinkedAggregate(bpy.types.Operator, tool.Ifc.Operator): if r.is_a("IfcRelAssignsToGroup") if self.group_name in r.RelatingGroup.Name ).id() - original_data[group] = {} + + # Initialize if not exists + if group not in original_data: + original_data[group] = {} pset: dict[str, Any] = ifcopenshell.util.element.get_pset(element, self.pset_name) index: int = pset["Index"] @@ -1559,8 +1563,13 @@ class RefreshLinkedAggregate(bpy.types.Operator, tool.Ifc.Operator): if parts: for part in parts: if part.is_a("IfcElementAssembly"): - # TODO: unused expression. - original_data | get_original_data(part) + # Recursively collect data from nested assemblies + nested_data = get_original_data(part) + # Merge nested data into original_data + for nested_group_id, nested_group_data in nested_data.items(): + if nested_group_id not in original_data: + original_data[nested_group_id] = {} + original_data[nested_group_id].update(nested_group_data) else: try: pset = ifcopenshell.util.element.get_pset(part, self.pset_name) @@ -1584,50 +1593,100 @@ class RefreshLinkedAggregate(bpy.types.Operator, tool.Ifc.Operator): ): # if element has parts it means it is the base of and aggregate or sub-aggregate aggregate = element - group = next( - r.RelatingGroup + # Get the new group + new_group_entity = next( + (r.RelatingGroup for r in getattr(aggregate, "HasAssignments", []) or [] if r.is_a("IfcRelAssignsToGroup") - if self.group_name in r.RelatingGroup.Name - ).id() - if not group: + if self.group_name in r.RelatingGroup.Name), + None + ) + + if not new_group_entity: return pset = ifcopenshell.util.element.get_pset(element, self.pset_name) + if not pset: + return + index = pset["Index"] + # Find the matching old group by looking for the same aggregate name + matching_group_id = None if index == 0: - obj.name = pset["Name"] + "_" + str(original_data[group][index]["Aggregate_Index"]) + # This is a root assembly - find by Name + aggregate_name = pset.get("Name") + for group_id, group_data in original_data.items(): + if 0 in group_data and group_data[0].get("Name") == aggregate_name: + matching_group_id = group_id + break + else: + # This is a part - find the group that has this index + for group_id, group_data in original_data.items(): + if index in group_data: + matching_group_id = group_id + break + + if matching_group_id is None: + return + + if index == 0: + obj.name = pset["Name"] + "_" + str(original_data[matching_group_id][index]["Aggregate_Index"]) ifc_file = tool.Ifc.get() ifcopenshell.api.pset.edit_pset( ifc_file, ifc_file.by_id(pset["id"]), - properties={"Aggregate_Index": int(original_data[group][index]["Aggregate_Index"])}, + properties={"Aggregate_Index": int(original_data[matching_group_id][index]["Aggregate_Index"])}, ) - bonsai.core.spatial.assign_container( - tool.Ifc, - tool.Collector, - tool.Spatial, - container=original_data[group][index]["Container"], - element_obj=obj, - ) - for part in ifcopenshell.util.element.get_parts(tool.Ifc.get_entity(obj)): - tool.Collector.assign(tool.Ifc.get_object(part)) - assignments = original_data[group][index]["Assignment"] + + # Only assign container if element is not already aggregated under another element + # Aggregated elements should not be in the spatial structure + if not ifcopenshell.util.element.get_aggregate(element): + bonsai.core.spatial.assign_container( + tool.Ifc, + tool.Collector, + tool.Spatial, + container=original_data[matching_group_id][index]["Container"], + element_obj=obj, + ) + for part in ifcopenshell.util.element.get_parts(tool.Ifc.get_entity(obj)): + tool.Collector.assign(tool.Ifc.get_object(part)) + + assignments = original_data[matching_group_id][index]["Assignment"] if assignments: assign_to_annotations(obj, assignments) else: try: - obj.name = original_data[group][index]["Name"] + obj.name = original_data[matching_group_id][index]["Name"] except: pass try: - assignments = original_data[group][index]["Assignment"] + assignments = original_data[matching_group_id][index]["Assignment"] except: assignments = [] if assignments: assign_to_annotations(obj, assignments) + def get_original_matrix( + element: ifcopenshell.entity_instance, base_instance: ifcopenshell.entity_instance + ) -> tuple[Matrix, tuple[Vector, Quaternion, Vector]]: + selected_obj = tool.Ifc.get_object(base_instance) + selected_matrix = selected_obj.matrix_world + object_duplicate = tool.Ifc.get_object(element) + duplicate_matrix = object_duplicate.matrix_world.decompose() + + return selected_matrix, duplicate_matrix + + def set_new_matrix( + selected_matrix: Matrix, duplicate_matrix: tuple[Vector, Quaternion, Vector], old_to_new: dict + ) -> None: + for old, new in old_to_new.items(): + new_obj = tool.Ifc.get_object(new[0]) + new_base_matrix = Matrix.LocRotScale(*duplicate_matrix) + matrix_diff = Matrix.inverted(selected_matrix) @ new_obj.matrix_world + new_obj_matrix = new_base_matrix @ matrix_diff + new_obj.matrix_world = new_obj_matrix + def get_element_assembly(element: ifcopenshell.entity_instance) -> Union[ifcopenshell.entity_instance, None]: if element.is_a("IfcElementAssembly"): return element @@ -1671,26 +1730,6 @@ class RefreshLinkedAggregate(bpy.types.Operator, tool.Ifc.Operator): return list(set(linked_aggregate_groups)), selected_parents - def get_original_matrix( - element: ifcopenshell.entity_instance, base_instance: ifcopenshell.entity_instance - ) -> tuple[Matrix, tuple[Vector, Quaternion, Vector]]: - selected_obj = tool.Ifc.get_object(base_instance) - selected_matrix = selected_obj.matrix_world - object_duplicate = tool.Ifc.get_object(element) - duplicate_matrix = object_duplicate.matrix_world.decompose() - - return selected_matrix, duplicate_matrix - - def set_new_matrix( - selected_matrix: Matrix, duplicate_matrix: tuple[Vector, Quaternion, Vector], old_to_new: dict - ) -> None: - for old, new in old_to_new.items(): - new_obj = tool.Ifc.get_object(new[0]) - new_base_matrix = Matrix.LocRotScale(*duplicate_matrix) - matrix_diff = Matrix.inverted(selected_matrix) @ new_obj.matrix_world - new_obj_matrix = new_base_matrix @ matrix_diff - new_obj.matrix_world = new_obj_matrix - active_element = tool.Ifc.get_entity(context.active_object) if not active_element: self.report({"INFO"}, "Object has no Ifc metadata.") @@ -1727,6 +1766,7 @@ class RefreshLinkedAggregate(bpy.types.Operator, tool.Ifc.Operator): base_pset = ifcopenshell.util.element.get_pset(base_instance, self.pset_name) base_obj = tool.Ifc.get_object(base_instance) base_obj.name = base_pset["Name"] + "_" + str(base_pset["Aggregate_Index"]) + for element in instances_to_refresh: if element.GlobalId == base_instance.GlobalId: continue @@ -1735,7 +1775,12 @@ class RefreshLinkedAggregate(bpy.types.Operator, tool.Ifc.Operator): selected_matrix, duplicate_matrix = get_original_matrix(element, base_instance) - original_data = get_original_data(element) + # Merge data instead of overwriting + element_original_data = get_original_data(element) + for group_id, group_data in element_original_data.items(): + if group_id not in original_data: + original_data[group_id] = {} + original_data[group_id].update(group_data) delete_objects(element) @@ -1749,7 +1794,7 @@ class RefreshLinkedAggregate(bpy.types.Operator, tool.Ifc.Operator): set_new_matrix(selected_matrix, duplicate_matrix, old_to_new) for old, new in old_to_new.items(): - if element_aggregate and new[0].is_a("IfcElementAssembly"): + if element_aggregate and new[0].is_a("IfcElementAssembly") and old == base_instance: new_aggregate = ifcopenshell.util.element.get_aggregate(new[0]) if not new_aggregate: From 7f87f1fb89fb001320223a4d85e6267f342bf13c Mon Sep 17 00:00:00 2001 From: Ryan Schultz Date: Sun, 11 Jan 2026 18:43:07 -0600 Subject: [PATCH 30/49] fix #7537 - Layer thickness correct when slab is rotated and few other features... - Add dual-rotation support for AXIS3 slabs (IFC angle + object rotation) - Fix profile editing to display horizontal projection for tilted slabs - Fix AXIS2 layer slicing to use local extrusion direction for walls - Fix ChangeExtrusionDepth to refresh geometry after depth changes - Remove rotation lock on slabs to allow free rotation - Fix undefined variable bug in add_slab_representation.py --- src/bonsai/bonsai/bim/module/model/slab.py | 312 ++++++++++++++---- src/bonsai/bonsai/bim/module/model/wall.py | 154 ++++++--- src/bonsai/bonsai/tool/collector.py | 2 - src/bonsai/bonsai/tool/loader.py | 70 +++- .../api/geometry/add_slab_representation.py | 42 ++- .../api/geometry/add_wall_representation.py | 3 +- 6 files changed, 451 insertions(+), 132 deletions(-) diff --git a/src/bonsai/bonsai/bim/module/model/slab.py b/src/bonsai/bonsai/bim/module/model/slab.py index ddc7c1d9a8..dd65f9c1c1 100644 --- a/src/bonsai/bonsai/bim/module/model/slab.py +++ b/src/bonsai/bonsai/bim/module/model/slab.py @@ -35,7 +35,7 @@ import bonsai.core.geometry import bonsai.core.root import bonsai.tool as tool from bonsai.bim.ifc import IfcStore -from math import cos, pi +from math import cos, sin, pi, acos, degrees from mathutils import Vector, Matrix from bonsai.bim.module.model.decorator import ProfileDecorator, PolylineDecorator, ProductDecorator from bonsai.bim.module.model.polyline import PolylineOperator @@ -296,50 +296,65 @@ class DumbSlabPlaner: if representation: extrusion = tool.Model.get_extrusion(representation) if extrusion: - # TODO Right now we don't have a reliable way to calculate the existing x_angle only based solely on the extrusion direction. - # For instances, a 30 degrees angled extrusion with positive direction has the same extrusion direction as a - # -150 degrees angled extrusion with negative direction. The difference lies in the object's rotation. - # This means that things can get messy if the user changes the object x angle somehow. We have to figure out an alternative approach. - existing_x_angle = obj.rotation_euler.x - existing_x_angle = 0 if tool.Cad.is_x(existing_x_angle, 0, tolerance=0.001) else existing_x_angle - existing_x_angle = 0 if tool.Cad.is_x(existing_x_angle, pi, tolerance=0.001) else existing_x_angle - existing_x_angle = 0 if tool.Cad.is_x(existing_x_angle, 2 * pi, tolerance=0.001) else existing_x_angle direction_ratios = Vector(extrusion.ExtrudedDirection.DirectionRatios) - offset_direction = direction_ratios.copy() - perpendicular_depth = thickness * abs(1 / cos(existing_x_angle)) - perpendicular_offset = layer_offset * abs(1 / cos(existing_x_angle)) / self.unit_scale - - # Check angle and z direction to determine whether the extrusion direction is positive or negative - if (abs(existing_x_angle) < (pi / 2) and direction_ratios.z > 0) or ( - abs(existing_x_angle) > (pi / 2) and direction_ratios.z < 0 - ): - # The extrusion direction is positive. If the layer_parameter is set to negative, - # then the we change the extrusion direction. - if layer_params["direction_sense"] == "NEGATIVE": - direction_ratios *= -1 - elif (abs(existing_x_angle) > (pi / 2) and direction_ratios.z > 0) or ( - abs(existing_x_angle) < (pi / 2) and direction_ratios.z < 0 - ): - # The extrusion direction is negative. If the layer_parameter is set to positive, - # then the we change the extrusion direction. And the offset direction should remain positive - # for either direction sense, so we change it. - offset_direction *= -1 - if layer_params["direction_sense"] == "POSITIVE": - direction_ratios *= -1 - - extrusion.ExtrudedDirection.DirectionRatios = tuple(direction_ratios) - extrusion.Depth = perpendicular_depth - - ifc_position = extrusion.Position - position = offset_direction * perpendicular_offset - material = ifcopenshell.util.element.get_material(element) - if material: - if material.is_a("IfcMaterialLayerSetUsage"): - material.OffsetFromReferenceLine = position.z - if ifc_position: - ifc_position.Location.Coordinates = position + + # Calculate the actual extrusion angle from vertical + extrusion_angle = 0 + if direction_ratios.length > 0: + cos_angle = direction_ratios.normalized().dot(Vector((0, 0, 1))) + extrusion_angle = acos(min(max(cos_angle, -1), 1)) + + # FIX: Only apply 1/cos factor when there's actual extrusion slope + if extrusion_angle > 1e-6: + perpendicular_depth = thickness * abs(1 / cos(extrusion_angle)) + perpendicular_offset = layer_offset * abs(1 / cos(extrusion_angle)) / self.unit_scale else: - tool.Model.add_extrusion_position(extrusion, position) + perpendicular_depth = thickness + perpendicular_offset = layer_offset / self.unit_scale + + # Check if direction sense needs to be applied + # This should only happen if explicitly requested, not automatically + if layer_params.get("apply_direction_sense", False): + # Store current direction before potential change + old_direction = direction_ratios.copy() + + # Apply direction sense logic + existing_x_angle = extrusion_angle + if (abs(existing_x_angle) < (pi / 2) and direction_ratios.z > 0) or ( + abs(existing_x_angle) > (pi / 2) and direction_ratios.z < 0 + ): + if layer_params["direction_sense"] == "NEGATIVE": + direction_ratios *= -1 + elif (abs(existing_x_angle) > (pi / 2) and direction_ratios.z > 0) or ( + abs(existing_x_angle) < (pi / 2) and direction_ratios.z < 0 + ): + offset_direction = direction_ratios.copy() * -1 + if layer_params["direction_sense"] == "POSITIVE": + direction_ratios *= -1 + + # If direction changed, update extrusion with rotation compensation + if (direction_ratios.normalized() - old_direction.normalized()).length > 1e-6: + update_extrusion_direction(element, tuple(direction_ratios), obj) + # After updating direction, get the updated extrusion + extrusion = tool.Model.get_extrusion(representation) + + # Update depth + extrusion.Depth = perpendicular_depth + + # Update position + ifc_position = extrusion.Position + if direction_ratios.length > 0: + offset_vector = direction_ratios.normalized() * perpendicular_offset + position = offset_vector + + material = ifcopenshell.util.element.get_material(element) + if material and material.is_a("IfcMaterialLayerSetUsage"): + material.OffsetFromReferenceLine = position.z + + if ifc_position: + ifc_position.Location.Coordinates = position + else: + tool.Model.add_extrusion_position(extrusion, position) else: props = tool.Model.get_model_props() @@ -383,6 +398,113 @@ class DumbSlabPlaner: ) + def update_extrusion_direction(element: ifcopenshell.entity_instance, + new_direction_ratios: tuple, + obj: bpy.types.Object = None) -> None: + """ + Update extrusion direction while preserving overall object orientation. + + Args: + element: The IFC element + new_direction_ratios: New extrusion direction ratios (x,y,z) + obj: Optional Blender object (will be fetched if not provided) + """ + if not obj: + obj = tool.Ifc.get_object(element) + if not obj: + return + + representation = ifcopenshell.util.representation.get_representation(element, "Model", "Body", "MODEL_VIEW") + if not representation: + return + + extrusion = tool.Model.get_extrusion(representation) + if not extrusion: + return + + # Get current extrusion direction + old_direction = Vector(extrusion.ExtrudedDirection.DirectionRatios) + if old_direction.length == 0: + old_direction = Vector((0, 0, 1)) # Default + + new_direction = Vector(new_direction_ratios) + if new_direction.length == 0: + new_direction = Vector((0, 0, 1)) # Default + + # Normalize both directions + old_direction_normalized = old_direction.normalized() + new_direction_normalized = new_direction.normalized() + + # Store current object matrix + old_matrix = obj.matrix_world.copy() + + # Calculate the rotation needed to keep same orientation + # When extrusion direction changes from A to B relative to local coordinates, + # we need to rotate the object by the inverse of that change + + # Calculate rotation from old to new direction + rotation_axis = old_direction_normalized.cross(new_direction_normalized) + if rotation_axis.length > 1e-6: + rotation_axis.normalized() + dot_product = old_direction_normalized.dot(new_direction_normalized) + angle = acos(min(max(dot_product, -1), 1)) + + # Apply INVERSE rotation to object to compensate + rotation_matrix = Matrix.Rotation(-angle, 4, rotation_axis) + + # Update object rotation + obj.matrix_world = old_matrix @ rotation_matrix + bpy.context.view_layer.update() + + # Update extrusion direction (keeping magnitude) + if old_direction.length > 0: + # Preserve the magnitude of the original direction vector + magnitude = old_direction.length + new_direction = new_direction_normalized * magnitude + + extrusion.ExtrudedDirection.DirectionRatios = tuple(new_direction) + + # Update depth based on new extrusion angle + extrusion_angle = 0 + if new_direction.length > 0: + cos_angle = new_direction_normalized.dot(Vector((0, 0, 1))) + extrusion_angle = acos(min(max(cos_angle, -1), 1)) + + # Get current depth (perpendicular depth) + current_perpendicular_depth = extrusion.Depth + + # If we have material layer info, calculate actual thickness + material = ifcopenshell.util.element.get_material(element) + actual_thickness = current_perpendicular_depth + if material and material.is_a("IfcMaterialLayerSetUsage"): + layer_set = material.ForLayerSet + actual_thickness = sum([l.LayerThickness for l in layer_set.MaterialLayers]) + unit_scale = ifcopenshell.util.unit.calculate_unit_scale(tool.Ifc.get()) + actual_thickness *= unit_scale + + # Convert to perpendicular depth if needed + if extrusion_angle > 1e-6: + new_perpendicular_depth = actual_thickness * abs(1 / cos(extrusion_angle)) + else: + new_perpendicular_depth = actual_thickness + + extrusion.Depth = new_perpendicular_depth + + # Update position offset if needed + if extrusion.Position: + # Recalculate offset based on new direction + material = ifcopenshell.util.element.get_material(element) + if material and material.is_a("IfcMaterialLayerSetUsage"): + offset = material.OffsetFromReferenceLine + if extrusion_angle > 1e-6: + perpendicular_offset = offset * abs(1 / cos(extrusion_angle)) + else: + perpendicular_offset = offset + + offset_vector = new_direction_normalized * perpendicular_offset + extrusion.Position.Location.Coordinates = tuple(offset_vector) + + class EnableEditingSketchExtrusionProfile(bpy.types.Operator, tool.Ifc.Operator): bl_idname = "bim.enable_editing_sketch_extrusion_profile" bl_label = "Enable Editing Sketch Extrusion Profile" @@ -656,6 +778,8 @@ class EnableEditingExtrusionProfile(bpy.types.Operator, tool.Ifc.Operator): extrusion = tool.Model.get_extrusion(body) existing_x_angle = tool.Model.get_existing_x_angle(extrusion) layer_params = tool.Model.get_material_layer_parameters(element) + + usage_type = tool.Model.get_usage_type(element) if extrusion.Position: position = Matrix(ifcopenshell.util.placement.get_axis2placement(extrusion.Position).tolist()) @@ -669,22 +793,49 @@ class EnableEditingExtrusionProfile(bpy.types.Operator, tool.Ifc.Operator): tranlation_matrix = Matrix.Translation(rot_offset) position = position @ tranlation_matrix - # Restore Object rotation to zero - local_rot_mat = obj.rotation_euler.to_matrix() - rot_mat = Matrix.Rotation(-existing_x_angle, 4, "X") - new_rot_mat = local_rot_mat.to_4x4() @ rot_mat - new_rot_euler = new_rot_mat.to_euler() - obj.rotation_euler = new_rot_euler + # For AXIS3 with dual rotation: Reset rotation to zero so profile is horizontal + if usage_type == "LAYER3": + # Store original rotation for later restoration + original_rotation_x = obj.rotation_euler.x + obj["pre_edit_rotation_x"] = original_rotation_x + + # Reset rotation to zero - profile will be horizontal + current_z_rot = obj.rotation_euler.z + obj.rotation_euler.x = 0.0 + obj.rotation_euler.z = current_z_rot + else: + # Original behavior: Restore Object rotation to zero + local_rot_mat = obj.rotation_euler.to_matrix() + rot_mat = Matrix.Rotation(-existing_x_angle, 4, "X") + new_rot_mat = local_rot_mat.to_4x4() @ rot_mat + new_rot_euler = new_rot_mat.to_euler() + obj.rotation_euler = new_rot_euler else: position = Matrix() - tool.Model.import_profile(extrusion.SweptArea, obj=obj, position=position, x_angle=existing_x_angle) + # Import profile with correct x_angle + if usage_type == "LAYER3": + # For LAYER3: Use x_angle=0 and scale by cos(rotation) to get horizontal projection + obj_x_rotation = original_rotation_x # Use stored original rotation + scale_factor = abs(cos(obj_x_rotation)) if abs(obj_x_rotation) > 1e-6 else 1.0 + + # Import with x_angle=0 + tool.Model.import_profile(extrusion.SweptArea, obj=obj, position=position, x_angle=0) + + # Scale the Y coordinates by cos(rotation) to get horizontal projection + bpy.ops.object.mode_set(mode='OBJECT') + for vert in obj.data.vertices: + vert.co.y *= scale_factor + else: + # For other types: Use existing_x_angle + tool.Model.import_profile(extrusion.SweptArea, obj=obj, position=position, x_angle=existing_x_angle) bpy.ops.object.mode_set(mode="EDIT") ProfileDecorator.install(context, exit_edit_mode_callback=lambda: disable_editing_extrusion_profile(context)) if not bpy.app.background: tool.Blender.set_viewport_tool("bim.cad_tool") + return {"FINISHED"} @@ -706,6 +857,8 @@ class EditExtrusionProfile(bpy.types.Operator, tool.Ifc.Operator): extrusion = tool.Model.get_extrusion(body) existing_x_angle = tool.Model.get_existing_x_angle(extrusion) layer_params = tool.Model.get_material_layer_parameters(element) + usage_type = tool.Model.get_usage_type(element) + if extrusion.Position: position = Matrix(ifcopenshell.util.placement.get_axis2placement(extrusion.Position).tolist()) position.translation *= self.unit_scale @@ -718,20 +871,40 @@ class EditExtrusionProfile(bpy.types.Operator, tool.Ifc.Operator): tranlation_matrix = Matrix.Translation(rot_offset) position = position @ tranlation_matrix - # Restore Object rotation to x_angle - local_rot_mat = obj.rotation_euler.to_matrix() - rot_mat = Matrix.Rotation(existing_x_angle, 4, "X") - new_rot_mat = local_rot_mat.to_4x4() @ rot_mat - new_rot_euler = new_rot_mat.to_euler() - obj.rotation_euler = new_rot_euler + # Restore rotation + if usage_type == "LAYER3": + # Restore original rotation from before editing + if "pre_edit_rotation_x" in obj: + current_z_rot = obj.rotation_euler.z + obj.rotation_euler.x = obj["pre_edit_rotation_x"] + obj.rotation_euler.z = current_z_rot + del obj["pre_edit_rotation_x"] + else: + # Original behavior + local_rot_mat = obj.rotation_euler.to_matrix() + rot_mat = Matrix.Rotation(existing_x_angle, 4, "X") + new_rot_mat = local_rot_mat.to_4x4() @ rot_mat + new_rot_euler = new_rot_mat.to_euler() + obj.rotation_euler = new_rot_euler else: position = Matrix() - profile = tool.Model.export_profile(obj, position=position, x_angle=existing_x_angle) + # Export profile with correct x_angle + if usage_type == "LAYER3": + # Scale Y coordinates back up before exporting + obj_x_rotation = obj.rotation_euler.x + scale_factor = abs(cos(obj_x_rotation)) if abs(obj_x_rotation) > 1e-6 else 1.0 + + # Un-scale the profile before exporting + for vert in obj.data.vertices: + vert.co.y /= scale_factor # Inverse of import scaling + + profile = tool.Model.export_profile(obj, position=position, x_angle=0) + else: + profile = tool.Model.export_profile(obj, position=position, x_angle=existing_x_angle) if not profile: - def msg(self, context): self.layout.label(text="INVALID PROFILE") @@ -781,6 +954,29 @@ class EditExtrusionProfile(bpy.types.Operator, tool.Ifc.Operator): ) + footprint_context = ifcopenshell.util.representation.get_context( + tool.Ifc.get(), "Plan", "FootPrint", "SKETCH_VIEW" + ) + if not footprint_context: + return + + curves = [profile.OuterCurve] + if profile.is_a("IfcArbitraryProfileDefWithVoids"): + curves.extend(profile.InnerCurves) + new_footprint = ifcopenshell.api.geometry.add_footprint_representation( + tool.Ifc.get(), context=footprint_context, curves=curves + ) + old_footprint = ifcopenshell.util.representation.get_representation(element, "Plan", "FootPrint", "SKETCH_VIEW") + if old_footprint: + for inverse in tool.Ifc.get().get_inverse(old_footprint): + ifcopenshell.util.element.replace_attribute(inverse, old_footprint, new_footprint) + bonsai.core.geometry.remove_representation(tool.Ifc, tool.Geometry, obj=obj, representation=old_footprint) + else: + ifcopenshell.api.geometry.assign_representation( + tool.Ifc.get(), product=element, representation=new_footprint + ) + + class ResetVertex(bpy.types.Operator): bl_idname = "bim.reset_vertex" bl_label = "Reset Vertex" diff --git a/src/bonsai/bonsai/bim/module/model/wall.py b/src/bonsai/bonsai/bim/module/model/wall.py index 5b858dec83..8557ae6ccb 100644 --- a/src/bonsai/bonsai/bim/module/model/wall.py +++ b/src/bonsai/bonsai/bim/module/model/wall.py @@ -43,7 +43,7 @@ import bonsai.core.geometry import bonsai.core.model as core import bonsai.tool as tool from bonsai.bim.ifc import IfcStore -from math import pi, sin, cos, degrees, atan2 +from math import pi, sin, cos, degrees, atan2, acos from mathutils import Vector, Matrix from bonsai.bim.module.model.opening import FilledOpeningGenerator from bonsai.bim.module.model.decorator import PolylineDecorator, ProductDecorator @@ -397,27 +397,46 @@ class ChangeExtrusionDepth(bpy.types.Operator, tool.Ifc.Operator): for obj in selected_objs: element = tool.Ifc.get_entity(obj) assert element + representation = ifcopenshell.util.representation.get_representation(element, "Model", "Body", "MODEL_VIEW") if not representation: continue + extrusion = tool.Model.get_extrusion(representation) if not extrusion: continue + + # Get extrusion direction x, y, z = extrusion.ExtrudedDirection.DirectionRatios + + # Calculate angle from vertical x_angle = Vector((0, 1)).angle_signed(Vector((y, z))) - extrusion.Depth = self.depth / si_conversion * (1 / cos(x_angle)) + + # For sloped walls, compensate so VERTICAL height = target depth + cos_angle = cos(x_angle) + compensation_factor = abs(1 / cos_angle) if abs(cos_angle) > 1e-6 else 1.0 + new_depth_ifc = (self.depth / si_conversion) * compensation_factor + + extrusion.Depth = new_depth_ifc + + # IMPORTANT: Refresh the geometry to reflect the IFC changes + bonsai.core.geometry.switch_representation( + tool.Ifc, + tool.Geometry, + obj=obj, + representation=representation, + ) + if tool.Model.get_usage_type(element) == "LAYER2": for rel in element.ConnectedFrom: if rel.is_a() == "IfcRelConnectsElements": - ifcopenshell.api.geometry.disconnect_element( - ifc_file, - relating_element=rel.RelatingElement, - related_element=element, - ) - layer2_objs.append(obj) + related_element = rel.RelatedElement + if related_element.is_a() == "IfcWall": + layer2_objs.append(tool.Ifc.get_object(related_element)) if layer2_objs: tool.Model.recalculate_walls(layer2_objs) + return {"FINISHED"} @@ -437,80 +456,126 @@ class ChangeExtrusionXAngle(bpy.types.Operator, tool.Ifc.Operator): def _execute(self, context): layer2_objs: list[bpy.types.Object] = [] - x_angle = 0 if tool.Cad.is_x(self.x_angle, 0, tolerance=0.001) else self.x_angle - x_angle = 0 if tool.Cad.is_x(self.x_angle, pi, tolerance=0.001) else self.x_angle - unit_scale = ifcopenshell.util.unit.calculate_unit_scale(tool.Ifc.get()) - selected_objs = tool.Model.get_selected_mesh_ifc_objects() builder = ifcopenshell.util.shape_builder.ShapeBuilder(tool.Ifc.get()) + unit_scale = ifcopenshell.util.unit.calculate_unit_scale(tool.Ifc.get()) + x_angle = self.x_angle - for obj in selected_objs: + for obj in context.selected_objects: element = tool.Ifc.get_entity(obj) - assert element + if not element: + continue + representation = ifcopenshell.util.representation.get_representation(element, "Model", "Body", "MODEL_VIEW") if not representation: continue extrusion = tool.Model.get_extrusion(representation) if not extrusion: continue + + # Get current object rotation matrix + obj_rotation = obj.matrix_world.to_3x3() + + # Get current extrusion direction in LOCAL coordinates + current_local_direction = Vector(extrusion.ExtrudedDirection.DirectionRatios) + if current_local_direction.length == 0: + current_local_direction = Vector((0, 0, 1)) + current_local_direction_normalized = current_local_direction.normalized() + + # Calculate what the current extrusion direction is in WORLD coordinates + current_world_direction = obj_rotation @ current_local_direction_normalized + existing_x_angle = tool.Model.get_existing_x_angle(extrusion) existing_x_angle = 0 if tool.Cad.is_x(existing_x_angle, 0, tolerance=0.001) else existing_x_angle existing_x_angle = 0 if tool.Cad.is_x(existing_x_angle, pi, tolerance=0.001) else existing_x_angle + + # Calculate the NEW local extrusion direction based on x_angle + new_local_direction = Vector((0.0, sin(x_angle), cos(x_angle))) + + # Check if extrusion direction is actually changing + current_local_norm = current_local_direction_normalized + new_local_norm = new_local_direction.normalized() + + # Compare the LOCAL directions + local_direction_changed = (new_local_norm - current_local_norm).length > 1e-6 + if tool.Model.get_usage_type(element) == "LAYER2": - x, y, z = extrusion.ExtrudedDirection.DirectionRatios depth = extrusion.Depth / abs(1 / cos(existing_x_angle)) perpendicular_depth = depth * abs(1 / cos(x_angle)) - extrusion.ExtrudedDirection.DirectionRatios = (0.0, sin(x_angle), cos(x_angle)) - layer2_objs.append(obj) + + # Update extrusion direction + if local_direction_changed: + extrusion.ExtrudedDirection.DirectionRatios = tuple(new_local_direction) + + # Always update depth extrusion.Depth = perpendicular_depth + layer2_objs.append(obj) + else: if tool.Model.get_usage_type(element) == "LAYER3": - existing_x_angle = obj.rotation_euler.x - existing_x_angle = 0 if tool.Cad.is_x(existing_x_angle, 0, tolerance=0.001) else existing_x_angle - existing_x_angle = 0 if tool.Cad.is_x(existing_x_angle, pi, tolerance=0.001) else existing_x_angle + # For slabs, handle polyline scaling + existing_obj_x_angle = obj.rotation_euler.x + existing_obj_x_angle = 0 if tool.Cad.is_x(existing_obj_x_angle, 0, tolerance=0.001) else existing_obj_x_angle + existing_obj_x_angle = 0 if tool.Cad.is_x(existing_obj_x_angle, pi, tolerance=0.001) else existing_obj_x_angle + # Scale the polyline coordinates coord_list = builder.get_polyline_coords(extrusion.SweptArea.OuterCurve) coord_list = [ (p[0], p[1] * abs(cos(existing_x_angle))) for p in coord_list - ] # Reset the transformation and returns to the original points with 0 degrees + ] # Reset the transformation coord_list = [ (p[0], p[1] * abs(1 / cos(x_angle))) for p in coord_list ] # Apply the transformation for the new x_angle builder.set_polyline_coords(extrusion.SweptArea.OuterCurve, coord_list) - # The extrusion direction calculated previously default to the positive direction - # Here we set the extrusion direction to negative if that's the case - direction_ratios = Vector((0.0, sin(x_angle), cos(x_angle))) - # direction_ratios = Vector(extrusion.ExtrudedDirection.DirectionRatios) + # Calculate new extrusion direction with direction sense + base_local_direction = Vector((0.0, sin(x_angle), cos(x_angle))) layer_params = tool.Model.get_material_layer_parameters(element) perpendicular_depth = layer_params["thickness"] * abs(1 / cos(x_angle)) / unit_scale perpendicular_offset = layer_params["offset"] * abs(1 / cos(x_angle)) / unit_scale - offset_direction = direction_ratios.copy() + offset_direction = base_local_direction.copy() - # Check angle and z direction to determine whether the extrusion direction is positive or negative - if (abs(x_angle) < (pi / 2) and direction_ratios.z > 0) or ( - abs(x_angle) > (pi / 2) and direction_ratios.z < 0 + # Apply direction sense + final_local_direction = base_local_direction.copy() + if (abs(x_angle) < (pi / 2) and base_local_direction.z > 0) or ( + abs(x_angle) > (pi / 2) and base_local_direction.z < 0 ): - # The extrusion direction is positive. If the layer_parameter is set to negative, - # then the we change the extrusion direction. if layer_params["direction_sense"] == "NEGATIVE": - direction_ratios *= -1 - elif ((x_angle) > (pi / 2) and direction_ratios.z > 0) or ( - (x_angle) < (pi / 2) and direction_ratios.z < 0 + final_local_direction *= -1 + elif (x_angle > (pi / 2) and base_local_direction.z > 0) or ( + x_angle < (pi / 2) and base_local_direction.z < 0 ): - # The extrusion direction is negative. If the layer_parameter is set to positive, - # then the we change the extrusion direction. - # then the we change the extrusion direction. And the offset direction should remain positive - # for either direction sense, so we change it. offset_direction *= -1 if layer_params["direction_sense"] == "POSITIVE": - direction_ratios *= -1 - - extrusion.ExtrudedDirection.DirectionRatios = tuple(direction_ratios) + final_local_direction *= -1 + + # Check if extrusion direction actually changed + final_local_norm = final_local_direction.normalized() + local_direction_changed = (final_local_norm - current_local_norm).length > 1e-6 + + # Update extrusion properties + extrusion.ExtrudedDirection.DirectionRatios = tuple(final_local_direction) extrusion.Depth = perpendicular_depth if extrusion.Position or perpendicular_offset != 0: position = offset_direction * perpendicular_offset tool.Model.add_extrusion_position(extrusion, position) + + # Adjust object rotation if extrusion direction changed + if local_direction_changed: + # Calculate what the NEW world direction would be with current object rotation + expected_new_world_direction = obj_rotation @ final_local_norm + + # The rotation needed is from expected_new_world_direction to current_world_direction + rotation_axis = expected_new_world_direction.cross(current_world_direction) + if rotation_axis.length > 1e-6: + rotation_axis.normalize() + dot_product = expected_new_world_direction.dot(current_world_direction) + angle = acos(min(max(dot_product, -1), 1)) + + # Create and apply rotation matrix + rotation_matrix = Matrix.Rotation(angle, 4, rotation_axis) + obj.matrix_world = rotation_matrix @ obj.matrix_world + bpy.context.view_layer.update() bonsai.core.geometry.switch_representation( tool.Ifc, @@ -519,12 +584,6 @@ class ChangeExtrusionXAngle(bpy.types.Operator, tool.Ifc.Operator): representation=representation, ) - # Object rotation - current_z_rot = obj.rotation_euler.z - rot_mat = mathutils.Matrix.Rotation(x_angle, 4, "X") - obj.rotation_euler = rot_mat.to_euler() - obj.rotation_euler.z = current_z_rot - if layer2_objs: tool.Model.recalculate_walls(layer2_objs) return {"FINISHED"} @@ -1022,6 +1081,7 @@ class DumbWallGenerator: obj=obj, representation=representation, ) + pset = ifcopenshell.api.pset.add_pset(self.file, product=element, name="EPset_Parametric") ifcopenshell.api.pset.edit_pset(self.file, pset=pset, properties={"Engine": "Bonsai.DumbLayer2"}) material = ifcopenshell.util.element.get_material(element) diff --git a/src/bonsai/bonsai/tool/collector.py b/src/bonsai/bonsai/tool/collector.py index e91c52d3d0..7c46135b26 100644 --- a/src/bonsai/bonsai/tool/collector.py +++ b/src/bonsai/bonsai/tool/collector.py @@ -44,8 +44,6 @@ class Collector(bonsai.core.tool.Collector): # Note that tool.Geometry.is_locked is only checked within the if # statements for efficiency as it is a slow check. tool.Geometry.lock_scale(obj) - if element.is_a("IfcSlab"): - tool.Geometry.lock_rotation(obj, x=True) if element.is_a("IfcGridAxis"): if tool.Geometry.is_locked(element): diff --git a/src/bonsai/bonsai/tool/loader.py b/src/bonsai/bonsai/tool/loader.py index e28ceacaec..89603372d0 100644 --- a/src/bonsai/bonsai/tool/loader.py +++ b/src/bonsai/bonsai/tool/loader.py @@ -1030,28 +1030,57 @@ class Loader(bonsai.core.tool.Loader): sense_factor = 1 else: return mesh + if len(layer_set.MaterialLayers) == 1: return mesh + bm = bmesh.new() bm.from_mesh(mesh) + prev_co = None + advance_direction = None # Will store direction to advance planes + if not usage: - sense_factor = 1 # Assume the extrusion vector points in the direction sense + sense_factor = 1 no = cls.get_extrusion_vector(element).normalized() co = Vector((0.0, 0.0, offset)) + advance_direction = no elif usage.LayerSetDirection == "AXIS2": co = Vector((0.0, offset, 0.0)) - no = cls.get_extrusion_vector(element).normalized() - no = no.cross(Vector([1.0, 0.0, 0.0])) + + # Get LOCAL extrusion direction + local_extrusion = Vector([0.0, 0.0, 1.0]) + if body := ifcopenshell.util.representation.get_representation(element, "Model", "Body", "MODEL_VIEW"): + for item in ifcopenshell.util.representation.resolve_representation(body).Items: + while item.is_a("IfcBooleanResult"): + item = item.FirstOperand + if item.is_a("IfcExtrudedAreaSolid"): + local_extrusion = Vector(item.ExtrudedDirection.DirectionRatios).normalized() + break + + # Thickness direction: perpendicular to extrusion and length + thickness_dir = local_extrusion.cross(Vector([1.0, 0.0, 0.0])).normalized() + + # Ensure it points in POSITIVE Y (through wall thickness, not backwards) + if thickness_dir.y < 0: + thickness_dir = -thickness_dir + + no = thickness_dir + advance_direction = thickness_dir elif usage.LayerSetDirection == "AXIS3": co = Vector((0.0, 0.0, offset)) no = cls.get_extrusion_vector(element).normalized() no = Vector([0.0, 0.0, 1.0]) + advance_direction = no elif usage.LayerSetDirection == "AXIS1": co = Vector((0.0, 0.0, offset)) no = cls.get_extrusion_vector(element).normalized() no = Vector([1.0, 0.0, 0.0]) + advance_direction = no + no *= sense_factor + advance_direction *= sense_factor + # Cache this body = ifcopenshell.util.representation.get_context(tool.Ifc.get(), "Model", "Body", "MODEL_VIEW") styles = {} @@ -1059,20 +1088,25 @@ class Loader(bonsai.core.tool.Loader): for i, material in enumerate(mesh.materials): if style := tool.Ifc.get_entity(material): styles[style] = i + last_i = len(layer_set.MaterialLayers) - 1 for i, layer in enumerate(layer_set.MaterialLayers): if i != last_i: prev_co = co.copy() - co += no * layer.LayerThickness * cls.unit_scale + # Use advance_direction (not no) to move planes! + co += advance_direction * layer.LayerThickness * cls.unit_scale + bisect_geom = bmesh.ops.bisect_plane( bm, geom=bm.verts[:] + bm.edges[:] + bm.faces[:], dist=0.0001, plane_co=co, plane_no=no ) bmesh.ops.duplicate(bm, geom=bisect_geom["geom_cut"]) + if not (style := ifcopenshell.util.representation.get_material_style(layer.Material, body)): continue if (material_index := styles.get(style, None)) is None: material_index = len(mesh.materials) mesh.materials.append(tool.Ifc.get_object(style)) + if i == last_i: for face in bisect_geom["geom"]: if isinstance(face, bmesh.types.BMFace): @@ -1097,13 +1131,35 @@ class Loader(bonsai.core.tool.Loader): return mesh @classmethod - def get_extrusion_vector(cls, wall): - if body := ifcopenshell.util.representation.get_representation(wall, "Model", "Body", "MODEL_VIEW"): + def get_extrusion_vector(cls, element): + """Get the extrusion direction in WORLD coordinates (accounting for object rotation)""" + if body := ifcopenshell.util.representation.get_representation(element, "Model", "Body", "MODEL_VIEW"): for item in ifcopenshell.util.representation.resolve_representation(body).Items: while item.is_a("IfcBooleanResult"): item = item.FirstOperand if item.is_a("IfcExtrudedAreaSolid"): - return Vector(item.ExtrudedDirection.DirectionRatios) + local_direction = Vector(item.ExtrudedDirection.DirectionRatios) + + # Transform to world coordinates using object rotation + obj = tool.Ifc.get_object(element) + if obj: + # Apply object rotation to get actual world direction + world_direction = obj.matrix_world.to_3x3() @ local_direction + return world_direction + + return local_direction + return Vector([0.0, 0.0, 1.0]) + + @classmethod + def get_local_extrusion_vector(cls, element): + """Get the extrusion direction in LOCAL coordinates (from IFC, no object rotation)""" + if body := ifcopenshell.util.representation.get_representation(element, "Model", "Body", "MODEL_VIEW"): + for item in ifcopenshell.util.representation.resolve_representation(body).Items: + while item.is_a("IfcBooleanResult"): + item = item.FirstOperand + if item.is_a("IfcExtrudedAreaSolid"): + local_direction = Vector(item.ExtrudedDirection.DirectionRatios) + return local_direction return Vector([0.0, 0.0, 1.0]) @classmethod diff --git a/src/ifcopenshell-python/ifcopenshell/api/geometry/add_slab_representation.py b/src/ifcopenshell-python/ifcopenshell/api/geometry/add_slab_representation.py index 284f6e7c30..28025c6624 100644 --- a/src/ifcopenshell-python/ifcopenshell/api/geometry/add_slab_representation.py +++ b/src/ifcopenshell-python/ifcopenshell/api/geometry/add_slab_representation.py @@ -100,35 +100,45 @@ class Usecase: size = self.convert_si_to_unit(1) points = ((0.0, 0.0), (size, 0.0), (size, size), (0.0, size), (0.0, 0.0)) if self.polyline: - points = [ - (self.convert_si_to_unit(p[0]), self.convert_si_to_unit(p[1] * abs(1 / cos(self.x_angle)))) - for p in self.polyline - ] + # Only scale polyline if we have actual slope + if self.x_angle and abs(self.x_angle) > 1e-6: + points = [ + (self.convert_si_to_unit(p[0]), self.convert_si_to_unit(p[1] * abs(1 / cos(self.x_angle)))) + for p in self.polyline + ] + else: + points = [ + (self.convert_si_to_unit(p[0]), self.convert_si_to_unit(p[1])) + for p in self.polyline + ] + if self.file.schema == "IFC2X3": curve = self.file.createIfcPolyline([self.file.createIfcCartesianPoint(p) for p in points]) else: curve = self.file.createIfcIndexedPolyCurve(self.file.createIfcCartesianPointList2D(points)) - + if self.x_angle: direction_ratios = (0.0, sin(self.x_angle), cos(self.x_angle)) else: direction_ratios = (0.0, 0.0, 1.0) - offset_direction = direction_ratios # offset direction doesn't change if direction_sense is negative extrusion_direction = self.file.createIfcDirection(direction_ratios) - if self.direction_sense == "NEGATIVE": - direction_ratios = tuple(-n for n in direction_ratios) - extrusion_direction = self.file.createIfcDirection(direction_ratios) - - perpendicular_offset = self.convert_si_to_unit(self.offset) * abs(1 / cos(self.x_angle)) - perpendicular_depth = self.convert_si_to_unit(self.depth) * abs(1 / cos(self.x_angle)) + + # Calculate depth based on extrusion angle + extrusion_angle = abs(self.x_angle) if self.x_angle else 0 + if extrusion_angle > 1e-6: + perpendicular_depth = self.convert_si_to_unit(self.depth) * abs(1 / cos(extrusion_angle)) + perpendicular_offset = self.convert_si_to_unit(self.offset) * abs(1 / cos(extrusion_angle)) + else: + perpendicular_depth = self.convert_si_to_unit(self.depth) + perpendicular_offset = self.convert_si_to_unit(self.offset) + position = None - # default position for IFC2X3 where .Position is not optional if self.file.schema == "IFC2X3" or self.offset != 0: position_vector = ( - offset_direction[0] * perpendicular_offset, - offset_direction[1] * perpendicular_offset, - offset_direction[2] * perpendicular_offset, + direction_ratios[0] * perpendicular_offset, + direction_ratios[1] * perpendicular_offset, + direction_ratios[2] * perpendicular_offset, ) position = self.file.createIfcAxis2Placement3D( self.file.createIfcCartesianPoint(position_vector), diff --git a/src/ifcopenshell-python/ifcopenshell/api/geometry/add_wall_representation.py b/src/ifcopenshell-python/ifcopenshell/api/geometry/add_wall_representation.py index 4c108dc7df..12a033429d 100644 --- a/src/ifcopenshell-python/ifcopenshell/api/geometry/add_wall_representation.py +++ b/src/ifcopenshell-python/ifcopenshell/api/geometry/add_wall_representation.py @@ -85,7 +85,6 @@ class Usecase: def create_item(self) -> ifcopenshell.entity_instance: length = self.convert_si_to_unit(self.settings["length"]) thickness = self.convert_si_to_unit(self.settings["thickness"]) - thickness *= 1 / cos(self.settings["x_angle"]) if self.settings["direction_sense"] == "NEGATIVE": thickness *= -1 points = ( @@ -113,7 +112,7 @@ class Usecase: self.file.createIfcDirection((1.0, 0.0, 0.0)), ), extrusion_direction, - self.convert_si_to_unit(self.settings["height"]) * abs(1 / cos(self.settings["x_angle"])), + self.convert_si_to_unit(self.settings["height"]), ) if self.settings["booleans"]: extrusion = self.apply_booleans(extrusion) From d29cecab309eb7e78fe92457d11afc478168ec4d Mon Sep 17 00:00:00 2001 From: Ryan Schultz Date: Sun, 11 Jan 2026 20:35:32 -0600 Subject: [PATCH 31/49] Fix aggregate duplication to include parts and preserve hierarchy (#7550) - Auto-include all parts when duplicating aggregates - Preserve nested aggregate relationships during duplication - Select all duplicated objects for immediate moving --- .../bonsai/bim/module/geometry/operator.py | 73 ++++++++++++++++++- 1 file changed, 72 insertions(+), 1 deletion(-) diff --git a/src/bonsai/bonsai/bim/module/geometry/operator.py b/src/bonsai/bonsai/bim/module/geometry/operator.py index 4282cd8625..24ad1cb013 100644 --- a/src/bonsai/bonsai/bim/module/geometry/operator.py +++ b/src/bonsai/bonsai/bim/module/geometry/operator.py @@ -1205,13 +1205,84 @@ class OverrideDuplicateMove(bpy.types.Operator): for obj in objects_to_remove: tool.Blender.deselect_object(obj) + # Expand selection to include all parts of selected aggregates + objects_to_duplicate = set(context.selected_objects) - objects_to_remove + expanded_objects = set(objects_to_duplicate) + + for obj in objects_to_duplicate: + element = tool.Ifc.get_entity(obj) + if element and element.is_a("IfcElementAssembly"): + parts = tool.Aggregate.get_parts_recursively(element) + for part in parts: + part_obj = tool.Ifc.get_object(part) + if part_obj: + expanded_objects.add(part_obj) + + # Store parent aggregate relationships + parent_aggregates = {} + + for obj in expanded_objects: + element = tool.Ifc.get_entity(obj) + if element and element.is_a("IfcElementAssembly"): + parent_aggregate = ifcopenshell.util.element.get_aggregate(element) + if parent_aggregate: + parent_aggregates[element] = parent_aggregate + old_to_new, new_active_obj = tool.Geometry.duplicate_ifc_objects( - set(context.selected_objects) - objects_to_remove, + expanded_objects, linked=linked, active_object=context.active_object, ) + + # Restore parent aggregate relationships, but only for parents that were NOT duplicated + for old_elem, new_elems in old_to_new.items(): + if old_elem in parent_aggregates: + old_parent = parent_aggregates[old_elem] + + # Check if the parent was also duplicated + if old_parent in old_to_new: + # The duplication already created the correct nested relationship + continue + + # Parent was NOT duplicated, so we need to assign to the original parent + for new_elem in new_elems: + new_obj = tool.Ifc.get_object(new_elem) + parent_obj = tool.Ifc.get_object(old_parent) + if new_obj and parent_obj: + bonsai.core.aggregate.assign_object( + tool.Ifc, + tool.Aggregate, + tool.Collector, + relating_obj=parent_obj, + related_obj=new_obj, + ) + + # Select all duplicated objects and their parts + all_objects_to_select = set() + for old_elem, new_elems in old_to_new.items(): + for new_elem in new_elems: + new_obj = tool.Ifc.get_object(new_elem) + if new_obj: + all_objects_to_select.add(new_obj) + + # If it's an aggregate, also select all its parts + if new_elem.is_a("IfcElementAssembly"): + parts = tool.Aggregate.get_parts_recursively(new_elem) + for part in parts: + part_obj = tool.Ifc.get_object(part) + if part_obj: + all_objects_to_select.add(part_obj) + + # Deselect everything first + bpy.ops.object.select_all(action='DESELECT') + + # Select all the duplicated objects + for obj in all_objects_to_select: + obj.select_set(True) + if new_active_obj: context.view_layer.objects.active = new_active_obj + return old_to_new From d7ad0a352e1697ae2617485b723295f2f0fb0665 Mon Sep 17 00:00:00 2001 From: Pierre LeMoine Date: Mon, 12 Jan 2026 07:30:02 +0100 Subject: [PATCH 32/49] vscode config for Bonsai debugging --- .gitignore | 2 + .vscode/launch.json | 24 +++++++++ .vscode/tasks.json | 53 +++++++++++++++++++ .../scripts/dev_environment_vscode_config.py | 24 +++++++++ 4 files changed, 103 insertions(+) create mode 100644 .vscode/launch.json create mode 100644 .vscode/tasks.json create mode 100644 src/bonsai/scripts/dev_environment_vscode_config.py diff --git a/.gitignore b/.gitignore index 504685d332..5625af298a 100644 --- a/.gitignore +++ b/.gitignore @@ -21,6 +21,8 @@ venv # Visual Studio Code files .vscode +!.vscode/launch.json +!.vscode/tasks.json .vs # PyCharm files diff --git a/.vscode/launch.json b/.vscode/launch.json new file mode 100644 index 0000000000..63fa0f352d --- /dev/null +++ b/.vscode/launch.json @@ -0,0 +1,24 @@ +{ + // Use IntelliSense to learn about possible attributes. + // Hover to view descriptions of existing attributes. + // For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387 + "version": "0.2.0", + "configurations": [ + + { + "name": "Python Debugger: Remote Attach", + "type": "debugpy", + "request": "attach", + "connect": { + "host": "localhost", + "port": 5678 + }, + "pathMappings": [ + { + "localRoot": "${config:bonsai.localRoot}", + "remoteRoot": "${config:bonsai.remoteRoot}" + } + ] + } + ] +} \ No newline at end of file diff --git a/.vscode/tasks.json b/.vscode/tasks.json new file mode 100644 index 0000000000..883373e476 --- /dev/null +++ b/.vscode/tasks.json @@ -0,0 +1,53 @@ +{ + // See https://go.microsoft.com/fwlink/?LinkId=733558 + // for the documentation about the tasks.json format + "version": "2.0.0", + "tasks": [ + { + "label": "Configure bonsai/vscode development environment", + "type": "shell", + "command": "${input:blenderPath}", + "args": [ + "--background", + "--python", "${workspaceFolder}/src/bonsai/scripts/dev_environment_vscode_config.py" + ], + "problemMatcher": [] + }, + { + "label": "Launch blender with debugpy", + "type": "shell", + "command": "blender", + "options": { + "cwd": "${config:bonsai.blenderPath}" + }, + "args": [ + "--python-expr", + "import debugpy; debugpy.listen(5678)" + ], + "problemMatcher": [] + }, + { + "label": "Install debugpy in Blender", + "type": "shell", + "command": "blender", + "options": { + "cwd": "${config:bonsai.blenderPath}" + }, + "args": [ + "--background", + "--python-expr", + "import os, sys, subprocess; path=os.path.abspath(sys.executable); subprocess.call([path, '-m', 'ensurepip']); subprocess.call([path, '-m', 'pip', 'install', '--upgrade', 'debugpy'])" + ], + "problemMatcher": [] + } + + ], + "inputs": [ + { + "id": "blenderPath", + "type": "promptString", + "description": "Enter the path to the blender executable", + "default": "blender" + } + ] +} \ No newline at end of file diff --git a/src/bonsai/scripts/dev_environment_vscode_config.py b/src/bonsai/scripts/dev_environment_vscode_config.py new file mode 100644 index 0000000000..e1643b0aea --- /dev/null +++ b/src/bonsai/scripts/dev_environment_vscode_config.py @@ -0,0 +1,24 @@ + +import bonsai, json, bpy +from pathlib import Path + +repo_path = Path(bonsai.__file__).resolve().parent +install_path = Path(bonsai.__file__).absolute().parent +assert repo_path != install_path, "Run `dev_environment.py` to setup the development environment symlinks first." + +repo_root = repo_path.parent.parent.parent +settings_path = repo_root / ".vscode" / "settings.json" +settings_path.parent.mkdir(parents=True, exist_ok=True) + +settings = json.loads(settings_path.read_text()) if settings_path.exists() else {} +settings.update({ + "bonsai.localRoot": repo_path.as_posix(), + "bonsai.remoteRoot": install_path.as_posix(), + "bonsai.blenderPath": Path(bpy.app.binary_path).parent.as_posix(), +}) +json_data = json.dumps(settings, indent=2) + +settings_path.write_text(json_data) + +print("\n\nBonsai/VSCode development environment configured successfully!\n\n") + From 153de70a71b29502e8020f2054b937b5142e1e2e Mon Sep 17 00:00:00 2001 From: Andrej730 Date: Mon, 12 Jan 2026 16:07:34 +0500 Subject: [PATCH 33/49] black . --- src/bonsai/bonsai/bim/import_ifc.py | 1 + .../bonsai/bim/module/drawing/__init__.py | 3 +- .../bonsai/bim/module/drawing/operator.py | 98 +++++++++---------- src/bonsai/bonsai/bim/module/drawing/prop.py | 24 ++--- .../bonsai/bim/module/drawing/svgwriter.py | 34 +++++-- src/bonsai/bonsai/bim/module/drawing/ui.py | 29 +++--- .../bonsai/bim/module/geometry/operator.py | 54 +++++----- src/bonsai/bonsai/bim/module/material/data.py | 12 +-- .../bonsai/bim/module/material/operator.py | 6 +- src/bonsai/bonsai/bim/module/material/ui.py | 28 +++--- src/bonsai/bonsai/bim/module/model/opening.py | 34 +++---- src/bonsai/bonsai/bim/module/model/slab.py | 85 ++++++++-------- src/bonsai/bonsai/bim/module/model/wall.py | 60 ++++++------ .../bonsai/bim/module/model/workspace.py | 6 +- .../bonsai/bim/module/project/operator.py | 13 +-- src/bonsai/bonsai/bim/operator.py | 2 - src/bonsai/bonsai/bim/prop.py | 2 +- src/bonsai/bonsai/core/drawing.py | 2 +- src/bonsai/bonsai/core/root.py | 10 +- src/bonsai/bonsai/core/type.py | 12 +-- src/bonsai/bonsai/tool/blender.py | 9 +- src/bonsai/bonsai/tool/loader.py | 32 +++--- src/bonsai/bonsai/tool/model.py | 27 ++--- src/bonsai/bonsai/tool/project.py | 50 +++++----- src/bonsai/bonsai/tool/unit.py | 4 +- .../scripts/dev_environment_vscode_config.py | 14 +-- .../api/geometry/add_slab_representation.py | 13 +-- .../ifcopenshell/util/selector.py | 8 +- 28 files changed, 352 insertions(+), 320 deletions(-) diff --git a/src/bonsai/bonsai/bim/import_ifc.py b/src/bonsai/bonsai/bim/import_ifc.py index 7669edbd14..58f6a67065 100644 --- a/src/bonsai/bonsai/bim/import_ifc.py +++ b/src/bonsai/bonsai/bim/import_ifc.py @@ -1295,6 +1295,7 @@ class IfcImporter: if element.is_a("IfcSpace"): obj.hide_set(True) + class IfcImportSettings: """ Initialize only using `IfcImportSettings.factory()`. diff --git a/src/bonsai/bonsai/bim/module/drawing/__init__.py b/src/bonsai/bonsai/bim/module/drawing/__init__.py index 1578f4eeed..93316a0d01 100644 --- a/src/bonsai/bonsai/bim/module/drawing/__init__.py +++ b/src/bonsai/bonsai/bim/module/drawing/__init__.py @@ -158,6 +158,7 @@ def menu_func(self, context): if element and element.is_a("IfcAnnotation") and element.ObjectType in ["SECTION", "ELEVATION"]: self.layout.operator("bim.activate_drawing_by_annotation", text="Go to Drawing") + def register(): if not bpy.app.background: bpy.utils.register_tool(workspace.AnnotationTool, after={"bim.bim_tool"}, separator=True, group=False) @@ -170,7 +171,7 @@ def register(): bpy.app.handlers.load_post.append(handler.load_post) bpy.app.handlers.depsgraph_update_pre.append(handler.depsgraph_update_pre_handler) bpy.types.VIEW3D_MT_image_add.append(ui.add_object_button) - bpy.types.VIEW3D_MT_object_context_menu.append(menu_func) + bpy.types.VIEW3D_MT_object_context_menu.append(menu_func) def unregister(): diff --git a/src/bonsai/bonsai/bim/module/drawing/operator.py b/src/bonsai/bonsai/bim/module/drawing/operator.py index 66c417f3ec..936225099b 100644 --- a/src/bonsai/bonsai/bim/module/drawing/operator.py +++ b/src/bonsai/bonsai/bim/module/drawing/operator.py @@ -353,20 +353,20 @@ class CreateDrawing(bpy.types.Operator): # Clear any local camera setup and force viewport to use scene camera for area in context.screen.areas: - if area.type == 'VIEW_3D': + if area.type == "VIEW_3D": for space in area.spaces: - if space.type == 'VIEW_3D': + if space.type == "VIEW_3D": # Clear local camera to ensure we use scene.camera space.use_local_camera = False space.camera = context.scene.camera - space.region_3d.view_perspective = 'CAMERA' + space.region_3d.view_perspective = "CAMERA" print(f"Set viewport camera to: {context.scene.camera.name}") break - + # Force complete scene update context.view_layer.update() context.evaluated_depsgraph_get() - + underlay_svg = self.generate_underlay(context) with profile("Generate linework"): @@ -3078,9 +3078,9 @@ class AddReference(bpy.types.Operator, tool.Ifc.Operator, ImportHelper): filter_glob: bpy.props.StringProperty(default="*.svg", options={"HIDDEN"}) use_relative_path: bpy.props.BoolProperty(name="Use Relative Path", default=True) filename_ext = ".svg" - + files: bpy.props.CollectionProperty(type=bpy.types.OperatorFileListElement) - directory: bpy.props.StringProperty(subtype='DIR_PATH') + directory: bpy.props.StringProperty(subtype="DIR_PATH") def _execute(self, context): # Handle both single and multiple file selection @@ -3355,14 +3355,14 @@ class EnableEditingText(bpy.types.Operator, tool.Ifc.Operator): for i, literal_backup in enumerate(literals_backup): if i < len(props.literals): literal_props = props.literals[i] - + if assigned_product_obj: literal_props.product_used = assigned_product_obj elif "product_used" in literal_backup and literal_backup["product_used"]: product_name = literal_backup["product_used"] if product_name in bpy.data.objects: literal_props.product_used = bpy.data.objects[product_name] - + literal_props.element_value_rows.clear() if "element_value_rows" in literal_backup: for row_data in literal_backup["element_value_rows"]: @@ -4251,63 +4251,62 @@ class ActivateDrawingByAnnotation(bpy.types.Operator, tool.Ifc.Operator): bl_label = "Activate Drawing" bl_description = "Activate the drawing corresponding to the selected annotation" bl_options = {"REGISTER", "UNDO"} - + @classmethod def poll(cls, context): # Check if an annotation object is selected if not context.selected_objects: cls.poll_message_set("No object selected") return False - + active_obj = context.active_object if not active_obj: cls.poll_message_set("No active object") return False - + element = tool.Ifc.get_entity(active_obj) if not element: cls.poll_message_set("Selected object is not an IFC element") return False - + # Check if it's an IfcAnnotation with ObjectType = "SECTION" or "ELEVATION" if not element.is_a("IfcAnnotation") or element.ObjectType not in ["SECTION", "ELEVATION"]: cls.poll_message_set("Selected object is not a drawing annotation") return False - + return True def _execute(self, context): active_obj = context.active_object element = tool.Ifc.get_entity(active_obj) - + if not element or not element.is_a("IfcAnnotation") or element.ObjectType not in ["SECTION", "ELEVATION"]: self.report({"ERROR"}, "Selected object is not a drawing annotation") return {"CANCELLED"} - + # Find the drawing/camera element that this annotation references drawing_element = self.find_drawing_from_annotation(element) - + if not drawing_element: self.report({"ERROR"}, "Could not find drawing element for this annotation") return {"CANCELLED"} - + # Use the existing ActivateDrawing operator with the drawing element's ID bpy.ops.bim.activate_drawing(drawing=drawing_element.id()) - + return {"FINISHED"} - + def find_drawing_from_annotation(self, annotation_element): """Find the drawing/camera element that this annotation references.""" ifc = tool.Ifc.get() - + # Check IfcRelAssignsToProduct relationships for rel in ifc.get_inverse(annotation_element): if rel.is_a("IfcRelAssignsToProduct") and rel.RelatingProduct: if rel.RelatingProduct.is_a("IfcAnnotation"): # Found the drawing element! return rel.RelatingProduct - - + return None @@ -5062,7 +5061,7 @@ class AddElementValueRow(bpy.types.Operator): new_row.category = literal_props.category_for_adding new_row.element_key = "" new_row.formatted_value = "" - + if len(literal_props.element_value_rows) == 1: new_row.separator = "" else: @@ -5106,10 +5105,10 @@ class ElementValueSuggestionsPopup(bpy.types.Operator): row_index: bpy.props.IntProperty() category: bpy.props.StringProperty() search_query: bpy.props.StringProperty(name="Search", description="Search for element values") - + collection_keys: bpy.props.CollectionProperty(type=StrProperty) collection_descriptions: bpy.props.CollectionProperty(type=StrProperty) - + selected_key: bpy.props.StringProperty() def invoke(self, context, event): @@ -5157,13 +5156,13 @@ class ElementValueSuggestionsPopup(bpy.types.Operator): def draw(self, context): layout = self.layout - + layout.prop_search(self, "selected_key", self, "collection_descriptions", text="Value") def execute(self, context): if not self.selected_key: return {"CANCELLED"} - + obj = context.active_object if not obj: return {"CANCELLED"} @@ -5177,7 +5176,7 @@ class ElementValueSuggestionsPopup(bpy.types.Operator): return {"CANCELLED"} value_row = literal_props.element_value_rows[self.row_index] - + for idx, desc_item in enumerate(self.collection_descriptions): if desc_item.name == self.selected_key: actual_key = self.collection_keys[idx].name @@ -5260,10 +5259,7 @@ class FormatElementValueRow(bpy.types.Operator): custom_expression: bpy.props.StringProperty( name="Custom Expression", - description=( - "Custom expression using functions\n" - "Use {{value}} as placeholder for the current row's value." - ), + description=("Custom expression using functions\n" "Use {{value}} as placeholder for the current row's value."), default='concat({{value}}, " - additional text")', ) @@ -5288,51 +5284,51 @@ class FormatElementValueRow(bpy.types.Operator): def _load_formatting_from_row(self, row): """Parse the formatted_value to load existing formatting settings""" import re - + formatted_value = row.formatted_value - + if not formatted_value or formatted_value == f"{{{{{row.element_key}}}}}": self.formatting_type = "NONE" return - + if formatted_value.startswith("``") and formatted_value.endswith("``"): expression = formatted_value[2:-2].strip() else: self.formatting_type = "NONE" return - + if match := re.match(r"upper\(\{\{[^}]+\}\}\)", expression): self.formatting_type = "UPPER" - + elif match := re.match(r"lower\(\{\{[^}]+\}\}\)", expression): self.formatting_type = "LOWER" - + elif match := re.match(r"title\(\{\{[^}]+\}\}\)", expression): self.formatting_type = "TITLE" - + elif match := re.match(r"int\(\{\{[^}]+\}\}\)", expression): self.formatting_type = "INT" - + elif match := re.match(r"round\(\{\{[^}]+\}\},\s*([^)]+)\)", expression): self.formatting_type = "ROUND" self.round_precision = match.group(1).strip() - + elif match := re.match(r"number\(\{\{[^}]+\}\},\s*([^,]+),\s*([^)]+)\)", expression): self.formatting_type = "NUMBER" self.decimal_separator = match.group(1).strip() self.thousands_separator = match.group(2).strip() - + elif match := re.match(r"metric_length\(\{\{[^}]+\}\},\s*([^,]+),\s*([^)]+)\)", expression): self.formatting_type = "METRIC_LENGTH" self.metric_precision = match.group(1).strip() self.metric_decimals = int(match.group(2).strip()) - + elif match := re.match(r'imperial_length\(\{\{[^}]+\}\},\s*(\d+),\s*"([^"]+)",\s*"([^"]+)"\)', expression): self.formatting_type = "IMPERIAL_LENGTH" self.imperial_precision = int(match.group(1).strip()) self.imperial_input_unit = match.group(2).strip() self.imperial_output_unit = match.group(3).strip() - + else: self.formatting_type = "CUSTOM" self.custom_expression = expression @@ -5450,9 +5446,9 @@ class ApplyElementValueRowsToLiteral(bpy.types.Operator): default_format = f"{{{{{row.element_key}}}}}" row.formatted_value = default_format value_part = default_format - + parts.append(row.separator + value_part) - + concatenated_value = "".join(parts) for attr in literal_props.attributes: @@ -5469,12 +5465,12 @@ class ApplyElementValueRowsToLiteral(bpy.types.Operator): This preserves formatting functions like upper(), round(), etc. """ import re - - pattern = r'\{\{[^}]+\}\}' - + + pattern = r"\{\{[^}]+\}\}" + new_base_value = f"{{{{{new_element_key}}}}}" updated_value = re.sub(pattern, new_base_value, old_formatted_value) - + return updated_value diff --git a/src/bonsai/bonsai/bim/module/drawing/prop.py b/src/bonsai/bonsai/bim/module/drawing/prop.py index da04caf7ff..018c0a634a 100644 --- a/src/bonsai/bonsai/bim/module/drawing/prop.py +++ b/src/bonsai/bonsai/bim/module/drawing/prop.py @@ -714,7 +714,9 @@ class ElementValueRow(PropertyGroup): ) element_key: StringProperty( - name="Element Key", description="The element value key (e.g., 'id', 'Name', 'Pset_WallCommon.Reference')", default="" + name="Element Key", + description="The element value key (e.g., 'id', 'Name', 'Pset_WallCommon.Reference')", + default="", ) formatted_value: StringProperty( @@ -756,33 +758,33 @@ def get_category_items_with_counts(self, context): ("Coordinates", "Coordinates", "Coordinate information", "EMPTY_ARROWS"), ("Custom String", "Custom String", "Add custom text (no element key)", "SMALL_CAPS"), ] - + obj = context.active_object - + if obj and tool.Ifc.get_entity(obj): try: element = tool.Ifc.get_entity(obj) text_element = element - - if hasattr(self, 'product_used'): + + if hasattr(self, "product_used"): if self.product_used: element = tool.Ifc.get_entity(self.product_used) else: assigned = tool.Drawing.get_assigned_product(text_element) if assigned: element = assigned - + available_keys = ElementValuesData.get_available_element_value_keys(element) items = [] for i, (identifier, base_name, description, icon) in enumerate(category_metadata): count = len(available_keys.get(identifier, [])) display_name = f"{base_name} ({count})" if count > 0 else base_name items.append((identifier, display_name, description, icon, i)) - + return items except Exception as e: pass - + return [(id, name, desc, icon, i) for i, (id, name, desc, icon) in enumerate(category_metadata)] @@ -843,16 +845,16 @@ class LiteralProps(PropertyGroup): ) element_value_rows: CollectionProperty( - name="Element Value Rows", + name="Element Value Rows", type=ElementValueRow, - description="Collection of element value rows for building the literal value" + description="Collection of element value rows for building the literal value", ) category_for_adding: EnumProperty( name="Category for Adding", items=get_category_items_with_counts, default=0, - description="Category to use when adding a new element value row" + description="Category to use when adding a new element value row", ) if TYPE_CHECKING: diff --git a/src/bonsai/bonsai/bim/module/drawing/svgwriter.py b/src/bonsai/bonsai/bim/module/drawing/svgwriter.py index d7ddd0b9f7..d1a3897a20 100644 --- a/src/bonsai/bonsai/bim/module/drawing/svgwriter.py +++ b/src/bonsai/bonsai/bim/module/drawing/svgwriter.py @@ -115,9 +115,13 @@ def parse_markdown_it(text: str) -> list[dict[str, Union[str, None]]]: if tokens[j].type == "inline": for child in tokens[j].children or []: if child.type == "softbreak": - segments.append({"text": None, "url": None, "break": True, "bold": False, "italic": False}) + segments.append( + {"text": None, "url": None, "break": True, "bold": False, "italic": False} + ) elif child.type == "html_inline" and child.content.strip().lower() == "
": - segments.append({"text": None, "url": None, "break": True, "bold": False, "italic": False}) + segments.append( + {"text": None, "url": None, "break": True, "bold": False, "italic": False} + ) elif child.type == "strong_open": bold = True elif child.type == "strong_close": @@ -133,11 +137,27 @@ def parse_markdown_it(text: str) -> list[dict[str, Union[str, None]]]: elif child.type == "link_close" and link_opening: url = link_opening.attrGet("href") if url and link_text: - segments.append({"text": link_text, "url": url, "break": False, "bold": bold, "italic": italic}) + segments.append( + { + "text": link_text, + "url": url, + "break": False, + "bold": bold, + "italic": italic, + } + ) link_opening = None link_text = None elif child.type == "text" and not link_opening: - segments.append({"text": child.content, "url": None, "break": False, "bold": bold, "italic": italic}) + segments.append( + { + "text": child.content, + "url": None, + "break": False, + "bold": bold, + "italic": italic, + } + ) j += 1 i = j else: @@ -168,7 +188,9 @@ def parse_markdown_it(text: str) -> list[dict[str, Union[str, None]]]: link_opening = None link_text = None elif child.type == "text" and not link_opening: - segments.append({"text": child.content, "url": None, "break": False, "bold": bold, "italic": italic}) + segments.append( + {"text": child.content, "url": None, "break": False, "bold": bold, "italic": italic} + ) i += 1 segments = [seg for seg in segments if seg.get("text") is not None or seg.get("break", False)] if not segments: @@ -260,7 +282,7 @@ class SvgWriter: paths = self.resource_paths["Stylesheet"] if not paths: return - path_list = [p.strip() for p in paths.split(',')] + path_list = [p.strip() for p in paths.split(",")] for path in path_list: if not os.path.exists(path): print(f"WARNING. Couldn't find stylesheet for the drawing by the path: {path}") diff --git a/src/bonsai/bonsai/bim/module/drawing/ui.py b/src/bonsai/bonsai/bim/module/drawing/ui.py index c781d6ea23..d230add8f5 100644 --- a/src/bonsai/bonsai/bim/module/drawing/ui.py +++ b/src/bonsai/bonsai/bim/module/drawing/ui.py @@ -476,7 +476,6 @@ class BIM_PT_sheets(Panel): op = row3.operator("bim.activate_drawing_from_sheet", icon="OUTLINER_OB_CAMERA", text="") - if active_sheet.reference_type == "DRAWING": drawingnamesvg = active_sheet.name drawingname = drawingnamesvg.split(".svg")[0] @@ -680,13 +679,13 @@ class BIM_PT_text(Panel): if len(literal_props.attributes) > 0 and i < len(props.literal_apply_settings): row = box.row(align=True) bonsai.bim.helper.draw_attribute(literal_props.attributes[0], row, enable_search=True) - + expand_icon = "DOWNARROW_HLT" if getattr(literal_props, "show_element_values", False) else "RIGHTARROW" op = row.operator("bim.toggle_element_values_panel", icon=expand_icon, text="") op.literal_prop_id = i - + row.prop(props.literal_apply_settings[i], "apply_text_to_all", text="", icon="COPYDOWN") - + element = tool.Ifc.get_entity(obj) assigned_element = tool.Drawing.get_assigned_product(element) or element resolved_value = tool.Drawing.replace_text_literal_variables( @@ -711,8 +710,10 @@ class BIM_PT_text(Panel): element_values_row.prop(literal_props, "product_used", text="", icon="EYEDROPPER") current_product = get_current_product_for_element_values(obj, literal_props) - - product_name = current_product.name if (current_product and hasattr(current_product, "name")) else "Unknown" + + product_name = ( + current_product.name if (current_product and hasattr(current_product, "name")) else "Unknown" + ) source_row = values_box.row() source_row.label(text=f"Source: {product_name}", icon="OBJECT_DATA") @@ -720,29 +721,29 @@ class BIM_PT_text(Panel): if element: add_row = values_box.row(align=True) add_row.prop(literal_props, "category_for_adding", text="") - + op = add_row.operator("bim.add_element_value_row", text="Add Element", icon="ADD") op.literal_prop_id = i if len(literal_props.element_value_rows) > 0: for row_idx, value_row in enumerate(literal_props.element_value_rows): row = values_box.row(align=True) - + is_custom_string = value_row.category == "Custom String" - + if is_custom_string: category_icon = get_category_icon(value_row.category) row.prop(value_row, "element_key", text="", icon=category_icon) else: split = row.split(factor=0.25, align=True) - + sep_col = split.row(align=True) sep_col.prop(value_row, "separator", text="") - + key_col = split.row(align=True) category_icon = get_category_icon(value_row.category) key_col.prop(value_row, "element_key", text="", icon=category_icon) - + op = row.operator("bim.element_value_suggestions_popup", text="", icon="VIEWZOOM") op.literal_prop_id = i op.row_index = row_idx @@ -758,7 +759,9 @@ class BIM_PT_text(Panel): apply_row = values_box.row() apply_row.scale_y = 1.2 - op = apply_row.operator("bim.apply_element_value_rows_to_literal", text="Apply to Literal", icon="CHECKMARK") + op = apply_row.operator( + "bim.apply_element_value_rows_to_literal", text="Apply to Literal", icon="CHECKMARK" + ) op.literal_prop_id = i else: error_row = values_box.row() diff --git a/src/bonsai/bonsai/bim/module/geometry/operator.py b/src/bonsai/bonsai/bim/module/geometry/operator.py index 24ad1cb013..85e76dc590 100644 --- a/src/bonsai/bonsai/bim/module/geometry/operator.py +++ b/src/bonsai/bonsai/bim/module/geometry/operator.py @@ -1208,7 +1208,7 @@ class OverrideDuplicateMove(bpy.types.Operator): # Expand selection to include all parts of selected aggregates objects_to_duplicate = set(context.selected_objects) - objects_to_remove expanded_objects = set(objects_to_duplicate) - + for obj in objects_to_duplicate: element = tool.Ifc.get_entity(obj) if element and element.is_a("IfcElementAssembly"): @@ -1217,33 +1217,33 @@ class OverrideDuplicateMove(bpy.types.Operator): part_obj = tool.Ifc.get_object(part) if part_obj: expanded_objects.add(part_obj) - + # Store parent aggregate relationships parent_aggregates = {} - + for obj in expanded_objects: element = tool.Ifc.get_entity(obj) if element and element.is_a("IfcElementAssembly"): parent_aggregate = ifcopenshell.util.element.get_aggregate(element) if parent_aggregate: parent_aggregates[element] = parent_aggregate - + old_to_new, new_active_obj = tool.Geometry.duplicate_ifc_objects( expanded_objects, linked=linked, active_object=context.active_object, ) - + # Restore parent aggregate relationships, but only for parents that were NOT duplicated for old_elem, new_elems in old_to_new.items(): if old_elem in parent_aggregates: old_parent = parent_aggregates[old_elem] - + # Check if the parent was also duplicated if old_parent in old_to_new: # The duplication already created the correct nested relationship continue - + # Parent was NOT duplicated, so we need to assign to the original parent for new_elem in new_elems: new_obj = tool.Ifc.get_object(new_elem) @@ -1256,7 +1256,7 @@ class OverrideDuplicateMove(bpy.types.Operator): relating_obj=parent_obj, related_obj=new_obj, ) - + # Select all duplicated objects and their parts all_objects_to_select = set() for old_elem, new_elems in old_to_new.items(): @@ -1264,7 +1264,7 @@ class OverrideDuplicateMove(bpy.types.Operator): new_obj = tool.Ifc.get_object(new_elem) if new_obj: all_objects_to_select.add(new_obj) - + # If it's an aggregate, also select all its parts if new_elem.is_a("IfcElementAssembly"): parts = tool.Aggregate.get_parts_recursively(new_elem) @@ -1272,17 +1272,17 @@ class OverrideDuplicateMove(bpy.types.Operator): part_obj = tool.Ifc.get_object(part) if part_obj: all_objects_to_select.add(part_obj) - + # Deselect everything first - bpy.ops.object.select_all(action='DESELECT') - + bpy.ops.object.select_all(action="DESELECT") + # Select all the duplicated objects for obj in all_objects_to_select: obj.select_set(True) - + if new_active_obj: context.view_layer.objects.active = new_active_obj - + return old_to_new @@ -1614,7 +1614,7 @@ class RefreshLinkedAggregate(bpy.types.Operator, tool.Ifc.Operator): if r.is_a("IfcRelAssignsToGroup") if self.group_name in r.RelatingGroup.Name ).id() - + # Initialize if not exists if group not in original_data: original_data[group] = {} @@ -1666,20 +1666,22 @@ class RefreshLinkedAggregate(bpy.types.Operator, tool.Ifc.Operator): # Get the new group new_group_entity = next( - (r.RelatingGroup - for r in getattr(aggregate, "HasAssignments", []) or [] - if r.is_a("IfcRelAssignsToGroup") - if self.group_name in r.RelatingGroup.Name), - None + ( + r.RelatingGroup + for r in getattr(aggregate, "HasAssignments", []) or [] + if r.is_a("IfcRelAssignsToGroup") + if self.group_name in r.RelatingGroup.Name + ), + None, ) - + if not new_group_entity: return pset = ifcopenshell.util.element.get_pset(element, self.pset_name) if not pset: return - + index = pset["Index"] # Find the matching old group by looking for the same aggregate name @@ -1697,7 +1699,7 @@ class RefreshLinkedAggregate(bpy.types.Operator, tool.Ifc.Operator): if index in group_data: matching_group_id = group_id break - + if matching_group_id is None: return @@ -1709,7 +1711,7 @@ class RefreshLinkedAggregate(bpy.types.Operator, tool.Ifc.Operator): ifc_file.by_id(pset["id"]), properties={"Aggregate_Index": int(original_data[matching_group_id][index]["Aggregate_Index"])}, ) - + # Only assign container if element is not already aggregated under another element # Aggregated elements should not be in the spatial structure if not ifcopenshell.util.element.get_aggregate(element): @@ -1722,7 +1724,7 @@ class RefreshLinkedAggregate(bpy.types.Operator, tool.Ifc.Operator): ) for part in ifcopenshell.util.element.get_parts(tool.Ifc.get_entity(obj)): tool.Collector.assign(tool.Ifc.get_object(part)) - + assignments = original_data[matching_group_id][index]["Assignment"] if assignments: assign_to_annotations(obj, assignments) @@ -1837,7 +1839,7 @@ class RefreshLinkedAggregate(bpy.types.Operator, tool.Ifc.Operator): base_pset = ifcopenshell.util.element.get_pset(base_instance, self.pset_name) base_obj = tool.Ifc.get_object(base_instance) base_obj.name = base_pset["Name"] + "_" + str(base_pset["Aggregate_Index"]) - + for element in instances_to_refresh: if element.GlobalId == base_instance.GlobalId: continue diff --git a/src/bonsai/bonsai/bim/module/material/data.py b/src/bonsai/bonsai/bim/module/material/data.py index 3df1506fdc..b56ff93e33 100644 --- a/src/bonsai/bonsai/bim/module/material/data.py +++ b/src/bonsai/bonsai/bim/module/material/data.py @@ -433,29 +433,29 @@ class ObjectMaterialData: """Load BBIM_MaterialLayer pset data for display in UI.""" if not cls.element: return None - + pset_data = ifcopenshell.util.element.get_pset(cls.element, "BBIM_MaterialLayer") if not pset_data or not pset_data.get("UseCustomOffset", False): return None - + # Keep offset in SI units - format_distance will handle conversion custom_offset_si = pset_data.get("CustomOffset", 0.0) - + # Get the appropriate reference based on usage type usage_type = tool.Model.get_usage_type(cls.element) custom_reference = None reference_label = None - + if usage_type == "LAYER2": custom_reference = pset_data.get("CustomWallReference", "") reference_label = "Wall Reference" elif usage_type == "LAYER3": custom_reference = pset_data.get("CustomSlabReference", "") reference_label = "Slab Reference" - + return { "use_custom_offset": pset_data.get("UseCustomOffset", False), "custom_offset": custom_offset_si, # Store in SI units "custom_reference": custom_reference, "reference_label": reference_label, - } \ No newline at end of file + } diff --git a/src/bonsai/bonsai/bim/module/material/operator.py b/src/bonsai/bonsai/bim/module/material/operator.py index e8243ccf1e..f46f2c7b6a 100644 --- a/src/bonsai/bonsai/bim/module/material/operator.py +++ b/src/bonsai/bonsai/bim/module/material/operator.py @@ -509,10 +509,10 @@ class EnableEditingAssignedMaterial(bpy.types.Operator): bonsai.bim.helper.import_attributes(material[0], props.material_set_attributes) else: bonsai.bim.helper.import_attributes(material, props.material_set_attributes) - + # Load custom offset from BBIM_MaterialLayer pset tool.Model.load_custom_offset_from_pset(element, obj) - + return {"FINISHED"} def import_attributes_callback( @@ -625,7 +625,7 @@ class EditAssignedMaterial(bpy.types.Operator, tool.Ifc.Operator): obj_material_usage.ReferenceExtent = material.ReferenceExtent layer_sets_to_regenerate.add(obj_material_usage.ForLayerSet) - + # Save custom offset to BBIM_MaterialLayer pset tool.Model.save_custom_offset_to_pset(obj_element, obj) diff --git a/src/bonsai/bonsai/bim/module/material/ui.py b/src/bonsai/bonsai/bim/module/material/ui.py index 969661885a..c1b9ac2ae4 100644 --- a/src/bonsai/bonsai/bim/module/material/ui.py +++ b/src/bonsai/bonsai/bim/module/material/ui.py @@ -233,7 +233,7 @@ class BIM_PT_object_material(Panel): # Material Set Attributes Section row = self.layout.row(align=True) box = row.box() - + bonsai.bim.helper.draw_attributes(self.props.material_set_attributes, box) bonsai.bim.helper.draw_attributes(self.props.material_set_usage_attributes, box) @@ -246,7 +246,7 @@ class BIM_PT_object_material(Panel): "layer": "Material Layers", "profile": "Material Profiles", "constituent": "Material Constituents", - "list_item": "Material List Items" + "list_item": "Material List Items", } header_text = header_map.get(set_item_name, "Material Items") self.layout.label(text=header_text) @@ -255,7 +255,7 @@ class BIM_PT_object_material(Panel): row = self.layout.row(align=True) box = row.box() - + # Add Material Section (at the top of this box) if ObjectMaterialData.data["set_item_name"] == "profile" and not self.mprops.profiles: box_row = box.row(align=True) @@ -268,7 +268,7 @@ class BIM_PT_object_material(Panel): prop_with_search(box_row, self.props, "material", icon="MATERIAL", text="") op = box_row.operator(f"bim.add_{ObjectMaterialData.data['set_item_name']}", icon="ADD", text="") setattr(op, f"{ObjectMaterialData.data['set_item_name']}_set", ObjectMaterialData.data["set"]["id"]) - + active_object = bpy.context.active_object self.layerset_bounds(box, active_object, location="Top_Interior") @@ -356,7 +356,7 @@ class BIM_PT_object_material(Panel): # Material Set Information Section row = self.layout.row(align=True) box = row.box() - + if ObjectMaterialData.data["material_class"] != "IfcMaterialList": box_row = box.row(align=True) set_name = ObjectMaterialData.data["set"]["name"] @@ -395,6 +395,7 @@ class BIM_PT_object_material(Panel): if unit_system == "IMPERIAL": precision = prefs.doc.imperial_precision from bonsai.bim.module.drawing.helper import format_distance + formatted_offset = format_distance( offset_value, precision=precision, suppress_zero_inches=True, in_unit_length=True ) @@ -405,10 +406,10 @@ class BIM_PT_object_material(Panel): # BBIM_MaterialLayer Pset Section if pset_data := ObjectMaterialData.data.get("bbim_material_layer_pset"): self.layout.label(text="BBIM_MaterialLayer Pset") - + row = self.layout.row(align=True) box = row.box() - + # Custom Offset value - format using format_distance unit_system = bpy.context.scene.unit_settings.system prefs = tool.Blender.get_addon_preferences() @@ -416,13 +417,14 @@ class BIM_PT_object_material(Panel): if unit_system == "IMPERIAL": precision = prefs.doc.imperial_precision from bonsai.bim.module.drawing.helper import format_distance + formatted_custom_offset = format_distance( - pset_data['custom_offset'], precision=precision, suppress_zero_inches=True, in_unit_length=True + pset_data["custom_offset"], precision=precision, suppress_zero_inches=True, in_unit_length=True ) box_row = box.row(align=True) box_row.label(text="Custom Offset") box_row.label(text=formatted_custom_offset) - + # Reference (if exists) if pset_data["custom_reference"]: box_row = box.row(align=True) @@ -436,12 +438,12 @@ class BIM_PT_object_material(Panel): "layer": "Material Layers", "profile": "Material Profiles", "constituent": "Material Constituents", - "list_item": "Material List Items" + "list_item": "Material List Items", } header_text = header_map.get(set_item_name, "Material Items") else: header_text = "Materials" - + self.layout.label(text=header_text) row = self.layout.row(align=True) box = row.box() @@ -492,11 +494,11 @@ class BIM_PT_object_material(Panel): if layer_set_direction: row = self.layout.row(align=True) row.label(text="BBIM_MaterialLayer Pset") - + # Add indentation with a row that has a separator row = self.layout.row(align=True) # row.separator(factor=2.0) # Adjust factor for more/less indent - + box = row.box() box_row = box.row(align=True) box_row.prop(self.props, "use_custom_offset", text="Use Custom Offset") diff --git a/src/bonsai/bonsai/bim/module/model/opening.py b/src/bonsai/bonsai/bim/module/model/opening.py index b1f91a6398..256251b06c 100644 --- a/src/bonsai/bonsai/bim/module/model/opening.py +++ b/src/bonsai/bonsai/bim/module/model/opening.py @@ -170,7 +170,7 @@ class FilledOpeningGenerator: reuse_mapped_representation = True else: representation = ifcopenshell.util.representation.resolve_representation(representation) - + if not reuse_mapped_representation: # Check for library template before generating from filling template_rep = self.get_opening_template_from_type(filling) @@ -191,25 +191,25 @@ class FilledOpeningGenerator: MappingSource=existing_mapping_source, MappingTarget=tool.Ifc.get().create_entity( "IfcCartesianTransformationOperator3D", - Axis1=tool.Ifc.get().create_entity("IfcDirection", DirectionRatios=(1., 0., 0.)), - Axis2=tool.Ifc.get().create_entity("IfcDirection", DirectionRatios=(0., 1., 0.)), - LocalOrigin=tool.Ifc.get().create_entity("IfcCartesianPoint", Coordinates=(0., 0., 0.)), - Scale=1., - Axis3=tool.Ifc.get().create_entity("IfcDirection", DirectionRatios=(0., 0., 1.)) - ) + Axis1=tool.Ifc.get().create_entity("IfcDirection", DirectionRatios=(1.0, 0.0, 0.0)), + Axis2=tool.Ifc.get().create_entity("IfcDirection", DirectionRatios=(0.0, 1.0, 0.0)), + LocalOrigin=tool.Ifc.get().create_entity("IfcCartesianPoint", Coordinates=(0.0, 0.0, 0.0)), + Scale=1.0, + Axis3=tool.Ifc.get().create_entity("IfcDirection", DirectionRatios=(0.0, 0.0, 1.0)), + ), ) mapped_representation = tool.Ifc.get().create_entity( "IfcShapeRepresentation", ContextOfItems=context, RepresentationIdentifier="Body", RepresentationType="MappedRepresentation", - Items=[new_mapped_item] + Items=[new_mapped_item], ) else: mapped_representation = ifcopenshell.api.geometry.map_representation( tool.Ifc.get(), representation=representation ) - + ifcopenshell.api.geometry.assign_representation( tool.Ifc.get(), product=opening, representation=mapped_representation ) @@ -333,25 +333,25 @@ class FilledOpeningGenerator: MappingSource=existing_mapping_source, MappingTarget=tool.Ifc.get().create_entity( "IfcCartesianTransformationOperator3D", - Axis1=tool.Ifc.get().create_entity("IfcDirection", DirectionRatios=(1., 0., 0.)), - Axis2=tool.Ifc.get().create_entity("IfcDirection", DirectionRatios=(0., 1., 0.)), - LocalOrigin=tool.Ifc.get().create_entity("IfcCartesianPoint", Coordinates=(0., 0., 0.)), - Scale=1., - Axis3=tool.Ifc.get().create_entity("IfcDirection", DirectionRatios=(0., 0., 1.)) - ) + Axis1=tool.Ifc.get().create_entity("IfcDirection", DirectionRatios=(1.0, 0.0, 0.0)), + Axis2=tool.Ifc.get().create_entity("IfcDirection", DirectionRatios=(0.0, 1.0, 0.0)), + LocalOrigin=tool.Ifc.get().create_entity("IfcCartesianPoint", Coordinates=(0.0, 0.0, 0.0)), + Scale=1.0, + Axis3=tool.Ifc.get().create_entity("IfcDirection", DirectionRatios=(0.0, 0.0, 1.0)), + ), ) mapped_representation = tool.Ifc.get().create_entity( "IfcShapeRepresentation", ContextOfItems=context, RepresentationIdentifier="Body", RepresentationType="MappedRepresentation", - Items=[new_mapped_item] + Items=[new_mapped_item], ) else: mapped_representation = ifcopenshell.api.geometry.map_representation( tool.Ifc.get(), representation=representation_to_use ) - + ifcopenshell.api.geometry.assign_representation( tool.Ifc.get(), product=opening, representation=mapped_representation ) diff --git a/src/bonsai/bonsai/bim/module/model/slab.py b/src/bonsai/bonsai/bim/module/model/slab.py index dd65f9c1c1..2b2c3a2984 100644 --- a/src/bonsai/bonsai/bim/module/model/slab.py +++ b/src/bonsai/bonsai/bim/module/model/slab.py @@ -297,13 +297,13 @@ class DumbSlabPlaner: extrusion = tool.Model.get_extrusion(representation) if extrusion: direction_ratios = Vector(extrusion.ExtrudedDirection.DirectionRatios) - + # Calculate the actual extrusion angle from vertical extrusion_angle = 0 if direction_ratios.length > 0: cos_angle = direction_ratios.normalized().dot(Vector((0, 0, 1))) extrusion_angle = acos(min(max(cos_angle, -1), 1)) - + # FIX: Only apply 1/cos factor when there's actual extrusion slope if extrusion_angle > 1e-6: perpendicular_depth = thickness * abs(1 / cos(extrusion_angle)) @@ -311,13 +311,13 @@ class DumbSlabPlaner: else: perpendicular_depth = thickness perpendicular_offset = layer_offset / self.unit_scale - + # Check if direction sense needs to be applied # This should only happen if explicitly requested, not automatically if layer_params.get("apply_direction_sense", False): # Store current direction before potential change old_direction = direction_ratios.copy() - + # Apply direction sense logic existing_x_angle = extrusion_angle if (abs(existing_x_angle) < (pi / 2) and direction_ratios.z > 0) or ( @@ -331,26 +331,26 @@ class DumbSlabPlaner: offset_direction = direction_ratios.copy() * -1 if layer_params["direction_sense"] == "POSITIVE": direction_ratios *= -1 - + # If direction changed, update extrusion with rotation compensation if (direction_ratios.normalized() - old_direction.normalized()).length > 1e-6: update_extrusion_direction(element, tuple(direction_ratios), obj) # After updating direction, get the updated extrusion extrusion = tool.Model.get_extrusion(representation) - + # Update depth extrusion.Depth = perpendicular_depth - + # Update position ifc_position = extrusion.Position if direction_ratios.length > 0: offset_vector = direction_ratios.normalized() * perpendicular_offset position = offset_vector - + material = ifcopenshell.util.element.get_material(element) if material and material.is_a("IfcMaterialLayerSetUsage"): material.OffsetFromReferenceLine = position.z - + if ifc_position: ifc_position.Location.Coordinates = position else: @@ -397,13 +397,12 @@ class DumbSlabPlaner: representation=representation, ) - - def update_extrusion_direction(element: ifcopenshell.entity_instance, - new_direction_ratios: tuple, - obj: bpy.types.Object = None) -> None: + def update_extrusion_direction( + element: ifcopenshell.entity_instance, new_direction_ratios: tuple, obj: bpy.types.Object = None + ) -> None: """ Update extrusion direction while preserving overall object orientation. - + Args: element: The IFC element new_direction_ratios: New extrusion direction ratios (x,y,z) @@ -413,66 +412,66 @@ class DumbSlabPlaner: obj = tool.Ifc.get_object(element) if not obj: return - + representation = ifcopenshell.util.representation.get_representation(element, "Model", "Body", "MODEL_VIEW") if not representation: return - + extrusion = tool.Model.get_extrusion(representation) if not extrusion: return - + # Get current extrusion direction old_direction = Vector(extrusion.ExtrudedDirection.DirectionRatios) if old_direction.length == 0: old_direction = Vector((0, 0, 1)) # Default - + new_direction = Vector(new_direction_ratios) if new_direction.length == 0: new_direction = Vector((0, 0, 1)) # Default - + # Normalize both directions old_direction_normalized = old_direction.normalized() new_direction_normalized = new_direction.normalized() - + # Store current object matrix old_matrix = obj.matrix_world.copy() - + # Calculate the rotation needed to keep same orientation # When extrusion direction changes from A to B relative to local coordinates, # we need to rotate the object by the inverse of that change - + # Calculate rotation from old to new direction rotation_axis = old_direction_normalized.cross(new_direction_normalized) if rotation_axis.length > 1e-6: rotation_axis.normalized() dot_product = old_direction_normalized.dot(new_direction_normalized) angle = acos(min(max(dot_product, -1), 1)) - + # Apply INVERSE rotation to object to compensate rotation_matrix = Matrix.Rotation(-angle, 4, rotation_axis) - + # Update object rotation obj.matrix_world = old_matrix @ rotation_matrix bpy.context.view_layer.update() - + # Update extrusion direction (keeping magnitude) if old_direction.length > 0: # Preserve the magnitude of the original direction vector magnitude = old_direction.length new_direction = new_direction_normalized * magnitude - + extrusion.ExtrudedDirection.DirectionRatios = tuple(new_direction) - + # Update depth based on new extrusion angle extrusion_angle = 0 if new_direction.length > 0: cos_angle = new_direction_normalized.dot(Vector((0, 0, 1))) extrusion_angle = acos(min(max(cos_angle, -1), 1)) - + # Get current depth (perpendicular depth) current_perpendicular_depth = extrusion.Depth - + # If we have material layer info, calculate actual thickness material = ifcopenshell.util.element.get_material(element) actual_thickness = current_perpendicular_depth @@ -481,15 +480,15 @@ class DumbSlabPlaner: actual_thickness = sum([l.LayerThickness for l in layer_set.MaterialLayers]) unit_scale = ifcopenshell.util.unit.calculate_unit_scale(tool.Ifc.get()) actual_thickness *= unit_scale - + # Convert to perpendicular depth if needed if extrusion_angle > 1e-6: new_perpendicular_depth = actual_thickness * abs(1 / cos(extrusion_angle)) else: new_perpendicular_depth = actual_thickness - + extrusion.Depth = new_perpendicular_depth - + # Update position offset if needed if extrusion.Position: # Recalculate offset based on new direction @@ -500,7 +499,7 @@ class DumbSlabPlaner: perpendicular_offset = offset * abs(1 / cos(extrusion_angle)) else: perpendicular_offset = offset - + offset_vector = new_direction_normalized * perpendicular_offset extrusion.Position.Location.Coordinates = tuple(offset_vector) @@ -778,7 +777,7 @@ class EnableEditingExtrusionProfile(bpy.types.Operator, tool.Ifc.Operator): extrusion = tool.Model.get_extrusion(body) existing_x_angle = tool.Model.get_existing_x_angle(extrusion) layer_params = tool.Model.get_material_layer_parameters(element) - + usage_type = tool.Model.get_usage_type(element) if extrusion.Position: @@ -798,7 +797,7 @@ class EnableEditingExtrusionProfile(bpy.types.Operator, tool.Ifc.Operator): # Store original rotation for later restoration original_rotation_x = obj.rotation_euler.x obj["pre_edit_rotation_x"] = original_rotation_x - + # Reset rotation to zero - profile will be horizontal current_z_rot = obj.rotation_euler.z obj.rotation_euler.x = 0.0 @@ -819,12 +818,12 @@ class EnableEditingExtrusionProfile(bpy.types.Operator, tool.Ifc.Operator): # For LAYER3: Use x_angle=0 and scale by cos(rotation) to get horizontal projection obj_x_rotation = original_rotation_x # Use stored original rotation scale_factor = abs(cos(obj_x_rotation)) if abs(obj_x_rotation) > 1e-6 else 1.0 - + # Import with x_angle=0 tool.Model.import_profile(extrusion.SweptArea, obj=obj, position=position, x_angle=0) - + # Scale the Y coordinates by cos(rotation) to get horizontal projection - bpy.ops.object.mode_set(mode='OBJECT') + bpy.ops.object.mode_set(mode="OBJECT") for vert in obj.data.vertices: vert.co.y *= scale_factor else: @@ -835,7 +834,7 @@ class EnableEditingExtrusionProfile(bpy.types.Operator, tool.Ifc.Operator): ProfileDecorator.install(context, exit_edit_mode_callback=lambda: disable_editing_extrusion_profile(context)) if not bpy.app.background: tool.Blender.set_viewport_tool("bim.cad_tool") - + return {"FINISHED"} @@ -858,7 +857,7 @@ class EditExtrusionProfile(bpy.types.Operator, tool.Ifc.Operator): existing_x_angle = tool.Model.get_existing_x_angle(extrusion) layer_params = tool.Model.get_material_layer_parameters(element) usage_type = tool.Model.get_usage_type(element) - + if extrusion.Position: position = Matrix(ifcopenshell.util.placement.get_axis2placement(extrusion.Position).tolist()) position.translation *= self.unit_scale @@ -895,16 +894,17 @@ class EditExtrusionProfile(bpy.types.Operator, tool.Ifc.Operator): # Scale Y coordinates back up before exporting obj_x_rotation = obj.rotation_euler.x scale_factor = abs(cos(obj_x_rotation)) if abs(obj_x_rotation) > 1e-6 else 1.0 - + # Un-scale the profile before exporting for vert in obj.data.vertices: vert.co.y /= scale_factor # Inverse of import scaling - + profile = tool.Model.export_profile(obj, position=position, x_angle=0) else: profile = tool.Model.export_profile(obj, position=position, x_angle=existing_x_angle) if not profile: + def msg(self, context): self.layout.label(text="INVALID PROFILE") @@ -953,7 +953,6 @@ class EditExtrusionProfile(bpy.types.Operator, tool.Ifc.Operator): tool.Ifc.get(), product=element, representation=new_footprint ) - footprint_context = ifcopenshell.util.representation.get_context( tool.Ifc.get(), "Plan", "FootPrint", "SKETCH_VIEW" ) diff --git a/src/bonsai/bonsai/bim/module/model/wall.py b/src/bonsai/bonsai/bim/module/model/wall.py index 8557ae6ccb..d894aa71d1 100644 --- a/src/bonsai/bonsai/bim/module/model/wall.py +++ b/src/bonsai/bonsai/bim/module/model/wall.py @@ -397,28 +397,28 @@ class ChangeExtrusionDepth(bpy.types.Operator, tool.Ifc.Operator): for obj in selected_objs: element = tool.Ifc.get_entity(obj) assert element - + representation = ifcopenshell.util.representation.get_representation(element, "Model", "Body", "MODEL_VIEW") if not representation: continue - + extrusion = tool.Model.get_extrusion(representation) if not extrusion: continue - + # Get extrusion direction x, y, z = extrusion.ExtrudedDirection.DirectionRatios - + # Calculate angle from vertical x_angle = Vector((0, 1)).angle_signed(Vector((y, z))) - + # For sloped walls, compensate so VERTICAL height = target depth cos_angle = cos(x_angle) compensation_factor = abs(1 / cos_angle) if abs(cos_angle) > 1e-6 else 1.0 new_depth_ifc = (self.depth / si_conversion) * compensation_factor - + extrusion.Depth = new_depth_ifc - + # IMPORTANT: Refresh the geometry to reflect the IFC changes bonsai.core.geometry.switch_representation( tool.Ifc, @@ -426,7 +426,7 @@ class ChangeExtrusionDepth(bpy.types.Operator, tool.Ifc.Operator): obj=obj, representation=representation, ) - + if tool.Model.get_usage_type(element) == "LAYER2": for rel in element.ConnectedFrom: if rel.is_a() == "IfcRelConnectsElements": @@ -436,7 +436,7 @@ class ChangeExtrusionDepth(bpy.types.Operator, tool.Ifc.Operator): if layer2_objs: tool.Model.recalculate_walls(layer2_objs) - + return {"FINISHED"} @@ -471,51 +471,55 @@ class ChangeExtrusionXAngle(bpy.types.Operator, tool.Ifc.Operator): extrusion = tool.Model.get_extrusion(representation) if not extrusion: continue - + # Get current object rotation matrix obj_rotation = obj.matrix_world.to_3x3() - + # Get current extrusion direction in LOCAL coordinates current_local_direction = Vector(extrusion.ExtrudedDirection.DirectionRatios) if current_local_direction.length == 0: current_local_direction = Vector((0, 0, 1)) current_local_direction_normalized = current_local_direction.normalized() - + # Calculate what the current extrusion direction is in WORLD coordinates current_world_direction = obj_rotation @ current_local_direction_normalized - + existing_x_angle = tool.Model.get_existing_x_angle(extrusion) existing_x_angle = 0 if tool.Cad.is_x(existing_x_angle, 0, tolerance=0.001) else existing_x_angle existing_x_angle = 0 if tool.Cad.is_x(existing_x_angle, pi, tolerance=0.001) else existing_x_angle - + # Calculate the NEW local extrusion direction based on x_angle new_local_direction = Vector((0.0, sin(x_angle), cos(x_angle))) - + # Check if extrusion direction is actually changing current_local_norm = current_local_direction_normalized new_local_norm = new_local_direction.normalized() - + # Compare the LOCAL directions local_direction_changed = (new_local_norm - current_local_norm).length > 1e-6 - + if tool.Model.get_usage_type(element) == "LAYER2": depth = extrusion.Depth / abs(1 / cos(existing_x_angle)) perpendicular_depth = depth * abs(1 / cos(x_angle)) - + # Update extrusion direction if local_direction_changed: extrusion.ExtrudedDirection.DirectionRatios = tuple(new_local_direction) - + # Always update depth extrusion.Depth = perpendicular_depth layer2_objs.append(obj) - + else: if tool.Model.get_usage_type(element) == "LAYER3": # For slabs, handle polyline scaling existing_obj_x_angle = obj.rotation_euler.x - existing_obj_x_angle = 0 if tool.Cad.is_x(existing_obj_x_angle, 0, tolerance=0.001) else existing_obj_x_angle - existing_obj_x_angle = 0 if tool.Cad.is_x(existing_obj_x_angle, pi, tolerance=0.001) else existing_obj_x_angle + existing_obj_x_angle = ( + 0 if tool.Cad.is_x(existing_obj_x_angle, 0, tolerance=0.001) else existing_obj_x_angle + ) + existing_obj_x_angle = ( + 0 if tool.Cad.is_x(existing_obj_x_angle, pi, tolerance=0.001) else existing_obj_x_angle + ) # Scale the polyline coordinates coord_list = builder.get_polyline_coords(extrusion.SweptArea.OuterCurve) @@ -547,11 +551,11 @@ class ChangeExtrusionXAngle(bpy.types.Operator, tool.Ifc.Operator): offset_direction *= -1 if layer_params["direction_sense"] == "POSITIVE": final_local_direction *= -1 - + # Check if extrusion direction actually changed final_local_norm = final_local_direction.normalized() local_direction_changed = (final_local_norm - current_local_norm).length > 1e-6 - + # Update extrusion properties extrusion.ExtrudedDirection.DirectionRatios = tuple(final_local_direction) extrusion.Depth = perpendicular_depth @@ -559,19 +563,19 @@ class ChangeExtrusionXAngle(bpy.types.Operator, tool.Ifc.Operator): if extrusion.Position or perpendicular_offset != 0: position = offset_direction * perpendicular_offset tool.Model.add_extrusion_position(extrusion, position) - + # Adjust object rotation if extrusion direction changed if local_direction_changed: # Calculate what the NEW world direction would be with current object rotation expected_new_world_direction = obj_rotation @ final_local_norm - + # The rotation needed is from expected_new_world_direction to current_world_direction rotation_axis = expected_new_world_direction.cross(current_world_direction) if rotation_axis.length > 1e-6: rotation_axis.normalize() dot_product = expected_new_world_direction.dot(current_world_direction) angle = acos(min(max(dot_product, -1), 1)) - + # Create and apply rotation matrix rotation_matrix = Matrix.Rotation(angle, 4, rotation_axis) obj.matrix_world = rotation_matrix @ obj.matrix_world @@ -1081,7 +1085,7 @@ class DumbWallGenerator: obj=obj, representation=representation, ) - + pset = ifcopenshell.api.pset.add_pset(self.file, product=element, name="EPset_Parametric") ifcopenshell.api.pset.edit_pset(self.file, pset=pset, properties={"Engine": "Bonsai.DumbLayer2"}) material = ifcopenshell.util.element.get_material(element) diff --git a/src/bonsai/bonsai/bim/module/model/workspace.py b/src/bonsai/bonsai/bim/module/model/workspace.py index be02f3f4c8..578fbefa2a 100644 --- a/src/bonsai/bonsai/bim/module/model/workspace.py +++ b/src/bonsai/bonsai/bim/module/model/workspace.py @@ -399,7 +399,7 @@ class EditItemUI: assert obj mesh_props = tool.Geometry.get_mesh_props(obj.data) - + # Get the parent element from representation_obj to check for layer set usage has_layer_set_usage = False props = tool.Geometry.get_geometry_props() @@ -408,7 +408,7 @@ class EditItemUI: if parent_element: material_usage = tool.Model.get_usage_type(parent_element) has_layer_set_usage = material_usage == "LAYER3" - + if AuthoringData.data["is_representation_item_swept_solid"]: # TODO: support EndSweptArea for IfcRevolvedAreaSolidTapered, # will need to add second attribute for this. @@ -427,7 +427,7 @@ class EditItemUI: continue row = cls.layout.row() draw_attribute(item_attribute, cls.layout) - + if len(mesh_props.item_attributes) or AuthoringData.data["is_representation_item_swept_solid"]: row = cls.layout.row() row.operator("bim.update_item_attributes", icon="FILE_REFRESH", text="") diff --git a/src/bonsai/bonsai/bim/module/project/operator.py b/src/bonsai/bonsai/bim/module/project/operator.py index a549f7d69e..b0f74cfb40 100644 --- a/src/bonsai/bonsai/bim/module/project/operator.py +++ b/src/bonsai/bonsai/bim/module/project/operator.py @@ -1062,7 +1062,7 @@ class LoadProject(bpy.types.Operator, IFCFileSelector, ImportHelper): and not self.is_advanced ): filepath = self.get_filepath() - + # First, load the IFC file temporarily to check for metadata document temp_ifc = None has_metadata_doc = False @@ -1076,7 +1076,7 @@ class LoadProject(bpy.types.Operator, IFCFileSelector, ImportHelper): pass finally: temp_ifc = None - + if has_metadata_doc: suffix = tool.Blender.get_addon_preferences().metadata_blend_file_suffix if str(filepath).lower().endswith(".ifc"): @@ -1137,10 +1137,10 @@ class LoadProject(bpy.types.Operator, IFCFileSelector, ImportHelper): props.is_loading = True props.total_elements = len(tool.Ifc.get().by_type("IfcElement")) props.use_relative_project_path = self.use_relative_path - + metadata_doc = tool.Project.get_metadata_document_information() props.should_save_metadata_for_this_file = metadata_doc is not None - + tool.Blender.register_toolbar() tool.Project.add_recent_ifc_project(self.get_filepath_abs()) @@ -1776,7 +1776,7 @@ class ExportIFC(bpy.types.Operator, ExportHelper): metadata_filename = os.path.basename(output_file)[:-4] + suffix else: metadata_filename = os.path.basename(output_file) + suffix - + if not tool.Project.get_metadata_document_information(): tool.Project.create_metadata_document_information(metadata_filename) else: @@ -3165,6 +3165,7 @@ class ImageScalingTool(bpy.types.Operator, PolylineOperator): return {"FINISHED"} + class LoadBlendMetadataAndIFC(bpy.types.Operator): bl_idname = "bim.load_blend_metadata_and_ifc" bl_label = "Load Blend Metadata and IFC" @@ -3202,4 +3203,4 @@ class LoadBlendMetadataAndIFC(bpy.types.Operator): bpy.app.handlers.load_post.append(load_handler) bpy.ops.wm.open_mainfile(filepath=metadata_path) - return {"FINISHED"} \ No newline at end of file + return {"FINISHED"} diff --git a/src/bonsai/bonsai/bim/operator.py b/src/bonsai/bonsai/bim/operator.py index d8c1cab7d1..8cfc3fb0d4 100644 --- a/src/bonsai/bonsai/bim/operator.py +++ b/src/bonsai/bonsai/bim/operator.py @@ -364,8 +364,6 @@ bpy.ops.wm.save_as_mainfile(filepath=r'{blendmetadata_path}') return {"FINISHED"} - - # TODO: Unused operator. # Is there a need for this or 'DIR_PATH' propety subtype does almost the same, # but also has alt+click? diff --git a/src/bonsai/bonsai/bim/prop.py b/src/bonsai/bonsai/bim/prop.py index f799076727..75f12e49d8 100644 --- a/src/bonsai/bonsai/bim/prop.py +++ b/src/bonsai/bonsai/bim/prop.py @@ -638,7 +638,7 @@ class BIMProperties(PropertyGroup): ], name="Time Unit", default="HOUR", - ) + ) tab_visibilities: CollectionProperty(type=BIMTabVisibility, name="Tab Visibilities") panel_properties: CollectionProperty(type=BIMPanelProperties, name="Panel Properties") diff --git a/src/bonsai/bonsai/core/drawing.py b/src/bonsai/bonsai/core/drawing.py index 407bb19cb0..6ce74fe394 100644 --- a/src/bonsai/bonsai/core/drawing.py +++ b/src/bonsai/bonsai/core/drawing.py @@ -402,7 +402,7 @@ def update_drawing_name( camera = ifc.get_object(drawing) if camera and camera.name != name: camera.name = name - + group = drawing_tool.get_drawing_group(drawing) if drawing_tool.get_name(group) != name: ifc.run("attribute.edit_attributes", product=group, attributes={"Name": name}) diff --git a/src/bonsai/bonsai/core/root.py b/src/bonsai/bonsai/core/root.py index 957298c044..b8f960247c 100644 --- a/src/bonsai/bonsai/core/root.py +++ b/src/bonsai/bonsai/core/root.py @@ -63,20 +63,20 @@ def copy_class( def _has_material_styles(ifc: type[tool.Ifc], element: ifcopenshell.entity_instance) -> bool: """Check if element has styles defined through its material. - + Returns True if any constituent material has a style representation, which means styles should NOT be applied directly to the geometry. """ materials = ifcopenshell.util.element.get_materials(element) - + if not materials: return False - + # Check if any of the constituent materials have styles for material in materials: - if hasattr(material, 'HasRepresentation') and material.HasRepresentation: + if hasattr(material, "HasRepresentation") and material.HasRepresentation: return True - + return False diff --git a/src/bonsai/bonsai/core/type.py b/src/bonsai/bonsai/core/type.py index 7926c53ff1..2918e841f7 100644 --- a/src/bonsai/bonsai/core/type.py +++ b/src/bonsai/bonsai/core/type.py @@ -33,17 +33,16 @@ def assign_type( element: ifcopenshell.entity_instance, type: ifcopenshell.entity_instance, ) -> None: - - + # Get the instance's current CardinalPoint before type assignment instance_cardinal_point = None instance_material = ifcopenshell.util.element.get_material(element) if instance_material and instance_material.is_a("IfcMaterialProfileSetUsage"): instance_cardinal_point = instance_material.CardinalPoint - + ifc.run("type.assign_type", related_objects=[element], relating_type=type) obj = ifc.get_object(element) - + if type_tool.has_material_usage(element): # Restore the instance's CardinalPoint to the new material usage if instance_cardinal_point is not None: @@ -51,16 +50,17 @@ def assign_type( if new_instance_material and new_instance_material.is_a("IfcMaterialProfileSetUsage"): if new_instance_material.CardinalPoint != instance_cardinal_point: new_instance_material.CardinalPoint = instance_cardinal_point - + # Force representation regeneration from bonsai.bim.module.model.profile import DumbProfileRecalculator + DumbProfileRecalculator().recalculate([obj]) # for now, representation regeneration handled by API listeners else: type_data = type_tool.get_object_data(ifc.get_object(type)) if type_data: type_tool.change_object_data(obj, type_data, is_global=False) - + type_tool.disable_editing(obj) diff --git a/src/bonsai/bonsai/tool/blender.py b/src/bonsai/bonsai/tool/blender.py index 09bfcf29cc..e45e2b1206 100644 --- a/src/bonsai/bonsai/tool/blender.py +++ b/src/bonsai/bonsai/tool/blender.py @@ -382,17 +382,14 @@ class Blender(bonsai.core.tool.Blender): assert isinstance(space, bpy.types.SpaceNodeEditor) if space.tree_type == "ShaderNodeTree": context_override = {"area": area, "space": space, "screen": screen} - + # Add window if screen differs from current context context = bpy.context if context and context.screen != screen: - window = next( - (w for w in context.window_manager.windows if w.screen == screen), - None - ) + window = next((w for w in context.window_manager.windows if w.screen == screen), None) if window: context_override["window"] = window - + return context_override @classmethod diff --git a/src/bonsai/bonsai/tool/loader.py b/src/bonsai/bonsai/tool/loader.py index 89603372d0..a15a3f8a29 100644 --- a/src/bonsai/bonsai/tool/loader.py +++ b/src/bonsai/bonsai/tool/loader.py @@ -1030,16 +1030,16 @@ class Loader(bonsai.core.tool.Loader): sense_factor = 1 else: return mesh - + if len(layer_set.MaterialLayers) == 1: return mesh - + bm = bmesh.new() bm.from_mesh(mesh) - + prev_co = None advance_direction = None # Will store direction to advance planes - + if not usage: sense_factor = 1 no = cls.get_extrusion_vector(element).normalized() @@ -1047,7 +1047,7 @@ class Loader(bonsai.core.tool.Loader): advance_direction = no elif usage.LayerSetDirection == "AXIS2": co = Vector((0.0, offset, 0.0)) - + # Get LOCAL extrusion direction local_extrusion = Vector([0.0, 0.0, 1.0]) if body := ifcopenshell.util.representation.get_representation(element, "Model", "Body", "MODEL_VIEW"): @@ -1057,14 +1057,14 @@ class Loader(bonsai.core.tool.Loader): if item.is_a("IfcExtrudedAreaSolid"): local_extrusion = Vector(item.ExtrudedDirection.DirectionRatios).normalized() break - + # Thickness direction: perpendicular to extrusion and length thickness_dir = local_extrusion.cross(Vector([1.0, 0.0, 0.0])).normalized() - + # Ensure it points in POSITIVE Y (through wall thickness, not backwards) if thickness_dir.y < 0: thickness_dir = -thickness_dir - + no = thickness_dir advance_direction = thickness_dir elif usage.LayerSetDirection == "AXIS3": @@ -1077,10 +1077,10 @@ class Loader(bonsai.core.tool.Loader): no = cls.get_extrusion_vector(element).normalized() no = Vector([1.0, 0.0, 0.0]) advance_direction = no - + no *= sense_factor advance_direction *= sense_factor - + # Cache this body = ifcopenshell.util.representation.get_context(tool.Ifc.get(), "Model", "Body", "MODEL_VIEW") styles = {} @@ -1088,25 +1088,25 @@ class Loader(bonsai.core.tool.Loader): for i, material in enumerate(mesh.materials): if style := tool.Ifc.get_entity(material): styles[style] = i - + last_i = len(layer_set.MaterialLayers) - 1 for i, layer in enumerate(layer_set.MaterialLayers): if i != last_i: prev_co = co.copy() # Use advance_direction (not no) to move planes! co += advance_direction * layer.LayerThickness * cls.unit_scale - + bisect_geom = bmesh.ops.bisect_plane( bm, geom=bm.verts[:] + bm.edges[:] + bm.faces[:], dist=0.0001, plane_co=co, plane_no=no ) bmesh.ops.duplicate(bm, geom=bisect_geom["geom_cut"]) - + if not (style := ifcopenshell.util.representation.get_material_style(layer.Material, body)): continue if (material_index := styles.get(style, None)) is None: material_index = len(mesh.materials) mesh.materials.append(tool.Ifc.get_object(style)) - + if i == last_i: for face in bisect_geom["geom"]: if isinstance(face, bmesh.types.BMFace): @@ -1139,14 +1139,14 @@ class Loader(bonsai.core.tool.Loader): item = item.FirstOperand if item.is_a("IfcExtrudedAreaSolid"): local_direction = Vector(item.ExtrudedDirection.DirectionRatios) - + # Transform to world coordinates using object rotation obj = tool.Ifc.get_object(element) if obj: # Apply object rotation to get actual world direction world_direction = obj.matrix_world.to_3x3() @ local_direction return world_direction - + return local_direction return Vector([0.0, 0.0, 1.0]) diff --git a/src/bonsai/bonsai/tool/model.py b/src/bonsai/bonsai/tool/model.py index 2250cbe9c0..e7da203a85 100644 --- a/src/bonsai/bonsai/tool/model.py +++ b/src/bonsai/bonsai/tool/model.py @@ -620,12 +620,11 @@ class Model(bonsai.core.tool.Model): if not openings[i].obj: openings.remove(i) - @classmethod def save_custom_offset_to_pset(cls, element: ifcopenshell.entity_instance, obj: bpy.types.Object) -> None: """Save custom offset settings to BBIM_MaterialLayer pset.""" props = tool.Material.get_object_material_props(obj) - + if not props.use_custom_offset: # Remove pset if custom offset is disabled pset = ifcopenshell.util.element.get_pset(element, "BBIM_MaterialLayer") @@ -633,24 +632,24 @@ class Model(bonsai.core.tool.Model): pset_entity = tool.Ifc.get().by_id(pset["id"]) ifcopenshell.api.pset.remove_pset(tool.Ifc.get(), product=element, pset=pset_entity) return - + # Determine which reference to save based on usage type usage_type = tool.Model.get_usage_type(element) custom_wall_reference = None custom_slab_reference = None - + if usage_type == "LAYER2": custom_wall_reference = props.custom_wall_reference elif usage_type == "LAYER3": custom_slab_reference = props.custom_slab_reference - + # Get or create pset pset_data = ifcopenshell.util.element.get_pset(element, "BBIM_MaterialLayer") if pset_data: pset = tool.Ifc.get().by_id(pset_data["id"]) else: pset = ifcopenshell.api.pset.add_pset(tool.Ifc.get(), product=element, name="BBIM_MaterialLayer") - + # Save properties (store in SI units) unit_scale = ifcopenshell.util.unit.calculate_unit_scale(tool.Ifc.get()) properties = { @@ -667,14 +666,14 @@ class Model(bonsai.core.tool.Model): pset = ifcopenshell.util.element.get_pset(element, "BBIM_MaterialLayer") if not pset: return - + props = tool.Material.get_object_material_props(obj) unit_scale = ifcopenshell.util.unit.calculate_unit_scale(tool.Ifc.get()) - + # Load properties props.use_custom_offset = pset.get("UseCustomOffset", False) props.custom_offset = pset.get("CustomOffset", 0.0) * unit_scale # Convert from SI - + # Load the appropriate reference based on usage type usage_type = tool.Model.get_usage_type(element) if usage_type == "LAYER2": @@ -718,14 +717,16 @@ class Model(bonsai.core.tool.Model): ) @classmethod - def get_material_layer_custom_offset(cls, element: ifcopenshell.entity_instance, obj: bpy.types.Object) -> Optional[float]: + def get_material_layer_custom_offset( + cls, element: ifcopenshell.entity_instance, obj: bpy.types.Object + ) -> Optional[float]: """Get custom offset value, reading from pset if props are not set.""" unit_scale = ifcopenshell.util.unit.calculate_unit_scale(tool.Ifc.get()) layer_params = tool.Model.get_material_layer_parameters(element) layer_offset = layer_params["offset"] thickness = layer_params["thickness"] / unit_scale props = tool.Material.get_object_material_props(obj) - + # Try to load from pset if not already in props if not props.use_custom_offset: pset = ifcopenshell.util.element.get_pset(element, "BBIM_MaterialLayer") @@ -733,7 +734,7 @@ class Model(bonsai.core.tool.Model): # Load from pset custom_offset = pset.get("CustomOffset", 0.0) usage_type = tool.Model.get_usage_type(element) - + if usage_type == "LAYER2": custom_offset_reference = pset.get("CustomWallReference", "CENTER") elif usage_type == "LAYER3": @@ -753,7 +754,7 @@ class Model(bonsai.core.tool.Model): return None direction_sense = layer_params["direction_sense"] - + if direction_sense == "POSITIVE" and custom_offset_reference in {"INTERIOR", "TOP"}: layer_offset = custom_offset - thickness * unit_scale if direction_sense == "POSITIVE" and custom_offset_reference in {"CENTER", "MIDDLE"}: diff --git a/src/bonsai/bonsai/tool/project.py b/src/bonsai/bonsai/tool/project.py index fec4f6a42b..11c4bef3c9 100644 --- a/src/bonsai/bonsai/tool/project.py +++ b/src/bonsai/bonsai/tool/project.py @@ -529,26 +529,34 @@ class Project(bonsai.core.tool.Project): ifc_file = tool.Ifc.get() if not ifc_file: raise Exception("No IFC file loaded") - + doc = tool.Ifc.run("document.add_information", parent=None) - + if ifc_file.schema == "IFC2X3": - tool.Ifc.run("document.edit_information", information=doc, attributes={ - "DocumentId": "BLEND_METADATA", - "Name": "Blend Metadata", - "Scope": "BLEND_METADATA", - "Description": "References to blend metadata file for this IFC project", - "Location": metadata_filename - }) + tool.Ifc.run( + "document.edit_information", + information=doc, + attributes={ + "DocumentId": "BLEND_METADATA", + "Name": "Blend Metadata", + "Scope": "BLEND_METADATA", + "Description": "References to blend metadata file for this IFC project", + "Location": metadata_filename, + }, + ) else: - tool.Ifc.run("document.edit_information", information=doc, attributes={ - "Identification": "BLEND_METADATA", - "Name": "Blend Metadata", - "Scope": "BLEND_METADATA", - "Description": "References to blend metadata file for this IFC project", - "Location": metadata_filename - }) - + tool.Ifc.run( + "document.edit_information", + information=doc, + attributes={ + "Identification": "BLEND_METADATA", + "Name": "Blend Metadata", + "Scope": "BLEND_METADATA", + "Description": "References to blend metadata file for this IFC project", + "Location": metadata_filename, + }, + ) + return doc @classmethod @@ -556,14 +564,12 @@ class Project(bonsai.core.tool.Project): doc = cls.get_metadata_document_information() if not doc: return - + ifc_file = tool.Ifc.get() if not ifc_file: return - - tool.Ifc.run("document.edit_information", information=doc, attributes={ - "Location": metadata_filename - }) + + tool.Ifc.run("document.edit_information", information=doc, attributes={"Location": metadata_filename}) @classmethod def remove_metadata_document_information(cls) -> None: diff --git a/src/bonsai/bonsai/tool/unit.py b/src/bonsai/bonsai/tool/unit.py index dbfcc3cd3e..f7c9ce300b 100644 --- a/src/bonsai/bonsai/tool/unit.py +++ b/src/bonsai/bonsai/tool/unit.py @@ -166,11 +166,11 @@ def parse_distance_string(input_string: str, use_project_unit: bool = True) -> t break if inches is None: inches = 0 - + # If feet is negative, inches should also be negative (subtractive) if feet < 0: inches = -inches - + # Convert to meters total_meters = (feet * 0.3048) + (inches * 0.0254) return total_meters diff --git a/src/bonsai/scripts/dev_environment_vscode_config.py b/src/bonsai/scripts/dev_environment_vscode_config.py index e1643b0aea..b574ba9bf4 100644 --- a/src/bonsai/scripts/dev_environment_vscode_config.py +++ b/src/bonsai/scripts/dev_environment_vscode_config.py @@ -1,4 +1,3 @@ - import bonsai, json, bpy from pathlib import Path @@ -11,14 +10,15 @@ settings_path = repo_root / ".vscode" / "settings.json" settings_path.parent.mkdir(parents=True, exist_ok=True) settings = json.loads(settings_path.read_text()) if settings_path.exists() else {} -settings.update({ - "bonsai.localRoot": repo_path.as_posix(), - "bonsai.remoteRoot": install_path.as_posix(), - "bonsai.blenderPath": Path(bpy.app.binary_path).parent.as_posix(), -}) +settings.update( + { + "bonsai.localRoot": repo_path.as_posix(), + "bonsai.remoteRoot": install_path.as_posix(), + "bonsai.blenderPath": Path(bpy.app.binary_path).parent.as_posix(), + } +) json_data = json.dumps(settings, indent=2) settings_path.write_text(json_data) print("\n\nBonsai/VSCode development environment configured successfully!\n\n") - diff --git a/src/ifcopenshell-python/ifcopenshell/api/geometry/add_slab_representation.py b/src/ifcopenshell-python/ifcopenshell/api/geometry/add_slab_representation.py index 28025c6624..d3a6daaad6 100644 --- a/src/ifcopenshell-python/ifcopenshell/api/geometry/add_slab_representation.py +++ b/src/ifcopenshell-python/ifcopenshell/api/geometry/add_slab_representation.py @@ -107,23 +107,20 @@ class Usecase: for p in self.polyline ] else: - points = [ - (self.convert_si_to_unit(p[0]), self.convert_si_to_unit(p[1])) - for p in self.polyline - ] - + points = [(self.convert_si_to_unit(p[0]), self.convert_si_to_unit(p[1])) for p in self.polyline] + if self.file.schema == "IFC2X3": curve = self.file.createIfcPolyline([self.file.createIfcCartesianPoint(p) for p in points]) else: curve = self.file.createIfcIndexedPolyCurve(self.file.createIfcCartesianPointList2D(points)) - + if self.x_angle: direction_ratios = (0.0, sin(self.x_angle), cos(self.x_angle)) else: direction_ratios = (0.0, 0.0, 1.0) extrusion_direction = self.file.createIfcDirection(direction_ratios) - + # Calculate depth based on extrusion angle extrusion_angle = abs(self.x_angle) if self.x_angle else 0 if extrusion_angle > 1e-6: @@ -132,7 +129,7 @@ class Usecase: else: perpendicular_depth = self.convert_si_to_unit(self.depth) perpendicular_offset = self.convert_si_to_unit(self.offset) - + position = None if self.file.schema == "IFC2X3" or self.offset != 0: position_vector = ( diff --git a/src/ifcopenshell-python/ifcopenshell/util/selector.py b/src/ifcopenshell-python/ifcopenshell/util/selector.py index e14b82c47d..72ff60c0a1 100644 --- a/src/ifcopenshell-python/ifcopenshell/util/selector.py +++ b/src/ifcopenshell-python/ifcopenshell/util/selector.py @@ -212,7 +212,7 @@ class FormatTransformer(lark.Transformer): """Handle variable substitution like {{z}} or {{Pset_Wall.FireRating}}""" if self.element is None: return "0" # Default value if no element context - + query_path = args[0] try: value = get_element_value(self.element, query_path) @@ -399,11 +399,11 @@ class GetElementTransformer(lark.Transformer): def format(query: str, element: Optional[ifcopenshell.entity_instance] = None) -> str: """Format a query string with optional element context for variable substitution. - + :param query: Format query string (can include {{variable}} placeholders) :param element: Optional IFC element for variable substitution :return: Formatted string - + Example: format("{{z}} / 2", element) # Substitutes element's z value format("imperial_length({{z}} / 2, 4)", element) # Uses z in calculation @@ -1257,4 +1257,4 @@ class FacetTransformer(lark.Transformer): if comparison.startswith("!"): return not result - return result \ No newline at end of file + return result From 7d491134074e93bf884481f2ea5f75cb72ce0bd2 Mon Sep 17 00:00:00 2001 From: Andrej730 Date: Mon, 12 Jan 2026 16:36:05 +0500 Subject: [PATCH 34/49] typing --- src/bonsai/bonsai/bim/prop.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/bonsai/bonsai/bim/prop.py b/src/bonsai/bonsai/bim/prop.py index 75f12e49d8..21386c4544 100644 --- a/src/bonsai/bonsai/bim/prop.py +++ b/src/bonsai/bonsai/bim/prop.py @@ -656,8 +656,8 @@ class BIMProperties(PropertyGroup): volume_unit: str mass_unit: str time_unit: str - tab_visibilities: bpy.types.bpy_prop_collection[BIMTabVisibility] - panel_properties: bpy.types.bpy_prop_collection[BIMPanelProperties] + tab_visibilities: bpy.types.bpy_prop_collection_idprop[BIMTabVisibility] + panel_properties: bpy.types.bpy_prop_collection_idprop[BIMPanelProperties] class IfcParameter(PropertyGroup): From c8ac61a9d0fdbe6ba45b68efe8615bedcd12b846 Mon Sep 17 00:00:00 2001 From: Andrej730 Date: Mon, 12 Jan 2026 16:13:14 +0500 Subject: [PATCH 35/49] CI - bump Blender we use for testing to 5.0 --- .github/workflows/ci-bonsai-daily.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/ci-bonsai-daily.yml b/.github/workflows/ci-bonsai-daily.yml index 4185574b2d..eea09da5a7 100644 --- a/.github/workflows/ci-bonsai-daily.yml +++ b/.github/workflows/ci-bonsai-daily.yml @@ -104,7 +104,7 @@ jobs: # Ensure Bonsai and ifcsverchok enable/disable works before uploading to extensions repo. # Download Blender. - wget -q -O blender.tar.xz https://download.blender.org/release/Blender4.5/blender-4.5.0-linux-x64.tar.xz + wget -q -O blender.tar.xz https://download.blender.org/release/Blender5.0/blender-5.0.1-linux-x64.tar.xz tar -xf blender.tar.xz # Setup Blender. From cc341b5ffd8e859a961c884259ba0a997d404b2b Mon Sep 17 00:00:00 2001 From: Andrej730 Date: Mon, 12 Jan 2026 16:29:09 +0500 Subject: [PATCH 36/49] Remove debugging print statements --- src/bonsai/bonsai/core/unit.py | 2 -- 1 file changed, 2 deletions(-) diff --git a/src/bonsai/bonsai/core/unit.py b/src/bonsai/bonsai/core/unit.py index 9594c2b566..fde75297b1 100644 --- a/src/bonsai/bonsai/core/unit.py +++ b/src/bonsai/bonsai/core/unit.py @@ -66,8 +66,6 @@ def assign_scene_units(ifc: type[tool.Ifc], unit: type[tool.Unit]) -> None: else: timeunit = ifc.run("unit.add_conversion_based_unit", name=time_unit_name.lower()) units += [massunit, timeunit] - print("Add mass and time units:", unit.add_mass_and_time_units()) - print("Assigning units:", units) ifc.run("unit.assign_unit", units=units) From 18a25ffb0ef38f4ca73e87777c541c7eb57363a2 Mon Sep 17 00:00:00 2001 From: Andrej730 Date: Mon, 12 Jan 2026 16:29:59 +0500 Subject: [PATCH 37/49] Remove redundant label in project info UI --- src/bonsai/bonsai/bim/ui.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/src/bonsai/bonsai/bim/ui.py b/src/bonsai/bonsai/bim/ui.py index 82d387e53d..514aa168ce 100644 --- a/src/bonsai/bonsai/bim/ui.py +++ b/src/bonsai/bonsai/bim/ui.py @@ -1185,8 +1185,7 @@ class BIM_PT_tab_project_info(Panel): return tool.Blender.is_tab(context, "BOOKMARK") def draw(self, context): - layout = self.layout - layout.label(text="This is the Project Info panel.") + pass class BIM_PT_tab_spatial(Panel): From 3c8ab262747392ecf3a3cfa76a75cca7fef1218b Mon Sep 17 00:00:00 2001 From: Dion Moult Date: Tue, 13 Jan 2026 09:30:31 +1100 Subject: [PATCH 38/49] Do not scale georef WCS decorator if it is <1km (i.e. small site definition) Previously it was scaled which misled users into thinking the point was in the wrong location. --- .../bim/module/georeference/decorator.py | 28 +++++++++++-------- .../bonsai/bim/module/georeference/prop.py | 2 +- 2 files changed, 17 insertions(+), 13 deletions(-) diff --git a/src/bonsai/bonsai/bim/module/georeference/decorator.py b/src/bonsai/bonsai/bim/module/georeference/decorator.py index 55fcbc1d64..01818e9805 100644 --- a/src/bonsai/bonsai/bim/module/georeference/decorator.py +++ b/src/bonsai/bonsai/bim/module/georeference/decorator.py @@ -53,12 +53,13 @@ class GeoreferenceDecorator: pass cls.is_installed = False - def draw_batch(self, shader_type, content_pos, color, indices=None): + def draw_batch(self, shader_type, content_pos, color, indices=None, should_scale=True): if not tool.Blender.validate_shader_batch_data(content_pos, indices): return props = tool.Georeference.get_georeference_props() - self.scale = props.visualization_scale - content_pos = [v * self.scale for v in content_pos] + if should_scale: + scale = tool.Georeference.get_georeference_props().visualization_scale + content_pos = [v * scale for v in content_pos] shader = self.line_shader if shader_type == "LINES" else self.shader batch = batch_for_shader(shader, shader_type, {"pos": content_pos}, indices=indices) shader.uniform_float("color", color) @@ -141,12 +142,13 @@ class GeoreferenceDecorator: if wcs["blender_location"].length < 1000: position = wcs["blender_location"].copy() + position -= Vector((0, 0.1, 0)) + self.draw_text_at_position(context, text, position, should_scale=False) else: position = wcs["blender_location"].normalized() * 3 text += "\n(Warning: Actual XYZ Not Shown)" - position -= Vector((0, 0.1, 0)) - - self.draw_text_at_position(context, text, position) + position -= Vector((0, 0.1, 0)) + self.draw_text_at_position(context, text, position) if props.has_blender_offset: text = "IFC Local Origin" @@ -165,8 +167,10 @@ class GeoreferenceDecorator: self.draw_text_at_position(context, text, location) blf.disable(self.font_id, blf.SHADOW) - def draw_text_at_position(self, context, text, position): - position = [v * self.scale for v in position] + def draw_text_at_position(self, context, text, position, should_scale=True): + if should_scale: + scale = tool.Georeference.get_georeference_props().visualization_scale + position = [v * scale for v in position] coords_2d = location_3d_to_region_2d(context.region, context.region_data, position) if not coords_2d: return @@ -309,8 +313,8 @@ class GeoreferenceDecorator: if wcs["blender_location"].length < 1000: verts = [Vector((0, 0, 0)), wcs["blender_location"]] edges = [[0, 1]] - self.draw_batch("LINES", verts, decorator_color_special, edges) - self.draw_batch("POINTS", verts[1:], decorator_color_special) + self.draw_batch("LINES", verts, decorator_color_special, edges, should_scale=False) + self.draw_batch("POINTS", verts[1:], decorator_color_special, should_scale=False) else: location = wcs["blender_location"].normalized() edges = [[0, 1]] @@ -332,7 +336,7 @@ class GeoreferenceDecorator: self.draw_batch("LINES", verts, decorator_color_special, edges) self.draw_dashed_line(location * 3, location * 6, decorator_color_error) - def draw_dashed_line(self, start, end, colour): + def draw_dashed_line(self, start, end, colour, should_scale=True): direction = (end - start).normalized() distance = (end - start).length current_distance = Vector((0, 0, 0)) @@ -347,7 +351,7 @@ class GeoreferenceDecorator: edges = [[i, i + 1] for i in range(0, len(points), 2)] verts = points - self.draw_batch("LINES", verts, colour, edges) + self.draw_batch("LINES", verts, colour, edges, should_scale=should_scale) def calculate_angles(self, context): self.pn_angle = 0.0 diff --git a/src/bonsai/bonsai/bim/module/georeference/prop.py b/src/bonsai/bonsai/bim/module/georeference/prop.py index 0a5eecf4c1..1b6088556b 100644 --- a/src/bonsai/bonsai/bim/module/georeference/prop.py +++ b/src/bonsai/bonsai/bim/module/georeference/prop.py @@ -215,7 +215,7 @@ class BIMGeoreferenceProperties(PropertyGroup): description="Affects the georeference decorator size", default=1, soft_min=0.1, - soft_max=50, + soft_max=100, ) grid_north_angle: StringProperty(name="Grid North Angle", update=update_grid_north_angle) x_axis_abscissa: StringProperty(name="X Axis Abscissa", update=update_grid_north_vector) From 249e68f16395b5b273466930dd21ffefc0ac512a Mon Sep 17 00:00:00 2001 From: Ryan Schultz Date: Mon, 12 Jan 2026 19:37:25 -0600 Subject: [PATCH 39/49] fix to https://github.com/IfcOpenShell/IfcOpenShell/commit/7f87f1fb89fb001320223a4d85e6267f342bf13c: have rotation around the object's origin, not the world origin --- src/bonsai/bonsai/bim/module/model/wall.py | 16 +++++++++++++--- 1 file changed, 13 insertions(+), 3 deletions(-) diff --git a/src/bonsai/bonsai/bim/module/model/wall.py b/src/bonsai/bonsai/bim/module/model/wall.py index d894aa71d1..0d0b88339e 100644 --- a/src/bonsai/bonsai/bim/module/model/wall.py +++ b/src/bonsai/bonsai/bim/module/model/wall.py @@ -575,10 +575,20 @@ class ChangeExtrusionXAngle(bpy.types.Operator, tool.Ifc.Operator): rotation_axis.normalize() dot_product = expected_new_world_direction.dot(current_world_direction) angle = acos(min(max(dot_product, -1), 1)) - - # Create and apply rotation matrix + + # Rotate around object's own origin + # Decompose the matrix to get translation, rotation, scale + translation, rotation, scale = obj.matrix_world.decompose() + + # Create rotation matrix and convert to quaternion rotation_matrix = Matrix.Rotation(angle, 4, rotation_axis) - obj.matrix_world = rotation_matrix @ obj.matrix_world + rotation_quat = rotation_matrix.to_quaternion() + + # Apply rotation to existing rotation (quaternion multiplication) + new_rotation = rotation_quat @ rotation + + # Reconstruct matrix_world with same translation, new rotation, same scale + obj.matrix_world = Matrix.Translation(translation) @ new_rotation.to_matrix().to_4x4() @ Matrix.Scale(1, 4) bpy.context.view_layer.update() bonsai.core.geometry.switch_representation( From 9adbd4718103b1a9fef2737a3a798bb66daa1ebc Mon Sep 17 00:00:00 2001 From: Ryan Schultz Date: Tue, 13 Jan 2026 11:45:34 -0600 Subject: [PATCH 40/49] feat: auto-assign containers to root aggregates and organize parts in outliner When assigning a container to an aggregated element, automatically promote the operation to the root aggregate and move all nested parts to the container's collection in the Blender outliner. Changes: - AssignContainer now traverses the aggregate hierarchy to find the root aggregate when a user selects any nested part - All parts and sub-aggregates are moved to the container's collection in the outliner while preserving IFC aggregate relationships - Parts remain aggregated in IFC (not directly contained), only their Blender collection membership changes - RefreshLinkedAggregate now also moves all parts to the correct container collection when restoring original data This provides a more intuitive UX - users can select any part and the entire assembly moves together, properly organized under the spatial container. Fixes the previous behavior where: - Aggregated elements were skipped with a warning - Parts weren't organized under the container in the outliner - Aggregate nesting was broken after container assignment --- .../bonsai/bim/module/geometry/operator.py | 35 ++++++-- .../bonsai/bim/module/spatial/operator.py | 81 +++++++++++++++---- 2 files changed, 95 insertions(+), 21 deletions(-) diff --git a/src/bonsai/bonsai/bim/module/geometry/operator.py b/src/bonsai/bonsai/bim/module/geometry/operator.py index 85e76dc590..4927c9cfab 100644 --- a/src/bonsai/bonsai/bim/module/geometry/operator.py +++ b/src/bonsai/bonsai/bim/module/geometry/operator.py @@ -1715,19 +1715,40 @@ class RefreshLinkedAggregate(bpy.types.Operator, tool.Ifc.Operator): # Only assign container if element is not already aggregated under another element # Aggregated elements should not be in the spatial structure if not ifcopenshell.util.element.get_aggregate(element): + container = original_data[matching_group_id][index]["Container"] bonsai.core.spatial.assign_container( tool.Ifc, tool.Collector, tool.Spatial, - container=original_data[matching_group_id][index]["Container"], + container=container, element_obj=obj, ) - for part in ifcopenshell.util.element.get_parts(tool.Ifc.get_entity(obj)): - tool.Collector.assign(tool.Ifc.get_object(part)) - - assignments = original_data[matching_group_id][index]["Assignment"] - if assignments: - assign_to_annotations(obj, assignments) + + # Get the container's collection for moving parts in the outliner + container_obj = tool.Ifc.get_object(container) + container_collection = container_obj.BIMObjectProperties.collection if container_obj else None + + # Move all parts to the container's collection in the outliner + if container_collection: + for part in ifcopenshell.util.element.get_parts(element): + part_obj = tool.Ifc.get_object(part) + if part_obj: + # Remove from all previous collections + for col in part_obj.users_collection[:]: + col.objects.unlink(part_obj) + + # Link to container collection + if part_obj.name not in container_collection.objects: + container_collection.objects.link(part_obj) + + # Recursively handle nested parts + for nested_part in ifcopenshell.util.element.get_parts(part): + nested_part_obj = tool.Ifc.get_object(nested_part) + if nested_part_obj: + for col in nested_part_obj.users_collection[:]: + col.objects.unlink(nested_part_obj) + if nested_part_obj.name not in container_collection.objects: + container_collection.objects.link(nested_part_obj) else: try: obj.name = original_data[matching_group_id][index]["Name"] diff --git a/src/bonsai/bonsai/bim/module/spatial/operator.py b/src/bonsai/bonsai/bim/module/spatial/operator.py index e79cfc7d47..67189666e6 100644 --- a/src/bonsai/bonsai/bim/module/spatial/operator.py +++ b/src/bonsai/bonsai/bim/module/spatial/operator.py @@ -175,31 +175,84 @@ class AssignContainer(bpy.types.Operator, tool.Ifc.Operator): else: return + def get_root_aggregate(element): + """Traverse up the aggregate hierarchy to find the top-most aggregate""" + current = element + root = None + while aggregate := ifcopenshell.util.element.get_aggregate(current): + root = aggregate + current = aggregate + return root + + def get_all_parts_recursive(element): + """Recursively get all parts of an aggregate""" + parts = [] + for part in ifcopenshell.util.element.get_parts(element): + parts.append(part) + # Recursively get nested parts + parts.extend(get_all_parts_recursive(part)) + return parts + objs: list[bpy.types.Object] = [] - # In IFC element can be either contained of aggregated, - # tehrefore we skip aggregated elements here to prevent confusion. - # Can't handle it in `poll` since user might just select bunch of elements - # and try to assign a container to them - # and excluding aggregates because of the `poll` failing might get awkward. - skipped_aggregates = 0 + processed_elements = set() # Track elements we've already handled (by IFC ID) + promoted_parts = 0 # Count how many parts were promoted to their root aggregate + for obj in tool.Blender.get_selected_objects(): if not (element := tool.Ifc.get_entity(obj)): continue - if ifcopenshell.util.element.get_aggregate(element): - skipped_aggregates += 1 - continue - objs.append(obj) + + # Check if element is part of an aggregate (at any level) + if root_aggregate := get_root_aggregate(element): + # Skip if we've already processed this root aggregate + if root_aggregate.id() in processed_elements: + continue + + # Get the root aggregate object and add it instead + if root_aggregate_obj := tool.Ifc.get_object(root_aggregate): + objs.append(root_aggregate_obj) + processed_elements.add(root_aggregate.id()) + if root_aggregate != element: # Only count as promoted if different from selected + promoted_parts += 1 + else: + # Element is not part of any aggregate + if element.id() not in processed_elements: + objs.append(obj) + processed_elements.add(element.id()) + + # Get the container's collection + container_obj = tool.Ifc.get_object(container) + container_collection = container_obj.BIMObjectProperties.collection if container_obj else None for element_obj in objs: + element = tool.Ifc.get_entity(element_obj) + + # Only assign container to the ROOT aggregate (this updates IFC relationships) if self.remove_from_other_containers: for col in element_obj.users_collection[:]: col.objects.unlink(element_obj) core.assign_container(tool.Ifc, tool.Collector, tool.Spatial, container=container, element_obj=element_obj) + + # For parts, only move them in Blender collections (don't change IFC relationships) + if container_collection: + all_parts = get_all_parts_recursive(element) + for part in all_parts: + if part_obj := tool.Ifc.get_object(part): + # Always remove from ALL previous collections when moving to new container + for col in part_obj.users_collection[:]: + col.objects.unlink(part_obj) + + # Link to new container collection (Blender-only, no IFC change) + if part_obj.name not in container_collection.objects: + container_collection.objects.link(part_obj) - aggregates_msg = "" - if skipped_aggregates: - aggregates_msg = f" {skipped_aggregates} aggregated elements skipped." - self.report({"INFO"}, f"{len(objs)} elements assigned.{aggregates_msg}") + # Disable editing mode for all selected objects + for obj in tool.Blender.get_selected_objects(): + core.disable_editing_container(tool.Spatial, obj=obj) + + promoted_msg = "" + if promoted_parts: + promoted_msg = f" {promoted_parts} nested parts promoted to their root aggregates." + self.report({"INFO"}, f"{len(objs)} elements assigned.{promoted_msg}") class EnableEditingContainer(bpy.types.Operator): From 46a6356f93f1b38fe89765d5c9324cebeed166f3 Mon Sep 17 00:00:00 2001 From: Ryan Schultz Date: Tue, 13 Jan 2026 12:33:54 -0600 Subject: [PATCH 41/49] update the name of the blender collection, as well, when updating the name of the spatial container. --- src/bonsai/bonsai/bim/module/spatial/prop.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/bonsai/bonsai/bim/module/spatial/prop.py b/src/bonsai/bonsai/bim/module/spatial/prop.py index b7ae727a26..3d54581551 100644 --- a/src/bonsai/bonsai/bim/module/spatial/prop.py +++ b/src/bonsai/bonsai/bim/module/spatial/prop.py @@ -98,6 +98,8 @@ def update_name(self: "BIMContainer", context: bpy.types.Context) -> None: tool.Spatial.edit_container_name(element, self.name) if obj := tool.Ifc.get_object(element): tool.Root.set_object_name(obj, element) + if collection := tool.Blender.get_object_bim_props(obj).collection: + collection.name = f"{element.is_a()}/{element.Name or 'Unnamed'}" bonsai.bim.handler.refresh_ui_data() From df7318973d5f5d03a6dd597b4705bab9d1989cc9 Mon Sep 17 00:00:00 2001 From: Richard Brice <37087370+RickBrice@users.noreply.github.com> Date: Wed, 14 Jan 2026 13:28:07 -0800 Subject: [PATCH 42/49] Adds support for IfcOpenCrossProfileDef and branching with IfcSectionedSurface --- src/ifcgeom/infra_sweep_helper.cpp | 244 +++++++++++++++-- src/ifcgeom/kernels/opencascade/loft.cpp | 256 ++++++++++++++++-- .../mapping/IfcOpenCrossProfileDef.cpp | 10 +- src/ifcgeom/taxonomy.cpp | 12 +- src/ifcgeom/taxonomy.h | 3 +- 5 files changed, 462 insertions(+), 63 deletions(-) diff --git a/src/ifcgeom/infra_sweep_helper.cpp b/src/ifcgeom/infra_sweep_helper.cpp index b9c9e598d9..58f6d3108a 100644 --- a/src/ifcgeom/infra_sweep_helper.cpp +++ b/src/ifcgeom/infra_sweep_helper.cpp @@ -14,6 +14,27 @@ namespace { } } +namespace { +template > +bool has_intersection(const std::set& A, + const std::set& B) { + auto itA = A.begin(); + auto itB = B.begin(); + + while (itA != A.end() && itB != B.end()) { + if (Cmp()(*itA, *itB)) { + ++itA; + } else if (Cmp()(*itB, *itA)) { + ++itB; + } else { + return true; + } + } + return false; +} + +} + taxonomy::loft::ptr ifcopenshell::geometry::make_loft(const Settings& settings_, const IfcUtil::IfcBaseClass* inst, const taxonomy::function_item::ptr& fn, std::vector& cross_sections) { std::sort(cross_sections.begin(), cross_sections.end()); @@ -25,7 +46,7 @@ taxonomy::loft::ptr ifcopenshell::geometry::make_loft(const Settings& settings_, // @todo currently only the case is handled where directrix returns a function_item // @todo this "if" statement is not really required because the function returns at the start if the Directrix is not a function_item function if (fn) { - function_item_evaluator evaluator(settings_,fn); + function_item_evaluator evaluator(settings_, fn); double start = std::max(0., cross_sections.front().dist_along); double end = std::min(fn->length(), cross_sections.back().dist_along); @@ -45,6 +66,7 @@ taxonomy::loft::ptr ifcopenshell::geometry::make_loft(const Settings& settings_, // parameter is minimum number of steps num_steps = (size_t)std::ceil(param); } + auto delta_step = curve_length / num_steps; std::vector longitudes; for (auto& x : cross_sections) { longitudes.push_back(x.dist_along); @@ -52,7 +74,7 @@ taxonomy::loft::ptr ifcopenshell::geometry::make_loft(const Settings& settings_, longitudes.push_back(std::numeric_limits::infinity()); auto profile_index = longitudes.begin(); for (size_t i = 0; i <= num_steps; ++i) { - auto dist_along = start + curve_length / num_steps * i; + auto dist_along = start + delta_step * i; while (dist_along > *(profile_index + 1)) { profile_index++; if (profile_index == longitudes.end()) { @@ -60,6 +82,8 @@ taxonomy::loft::ptr ifcopenshell::geometry::make_loft(const Settings& settings_, } } + const bool is_last_placement_of_this_profile = profile_index + 1 >= longitudes.end() ? false : ((start + delta_step * (i+1)) > *(profile_index + 1)); + auto relative_dist_along = (dist_along - *profile_index) / (*(profile_index + 1) - *profile_index); const auto& profile_a = cross_sections[std::distance(longitudes.begin(), profile_index)].section_geometry; const auto& offset_a = cross_sections[std::distance(longitudes.begin(), profile_index)].offset; @@ -143,28 +167,203 @@ taxonomy::loft::ptr ifcopenshell::geometry::make_loft(const Settings& settings_, } else if (rotation_a != rotation_b) { Logger::Error("Direction vectors on cross section placements only supported when used consistently"); } + taxonomy::loop::ptr w1, w2; taxonomy::edge::ptr e1, e2; + taxonomy::point3::ptr p1, p2; + for (auto tmp_ : boost::combine(loops_a, loops_b)) { boost::tie(w1, w2) = tmp_; - if (w1->children.size() != w2->children.size()) { - Logger::Warning("Mismatching number of edges: " + - std::to_string(w1->children.size()) + " vs " + - std::to_string(w2->children.size()), - inst - ); - return nullptr; - } - std::vector points; - for (auto tmp__ : boost::combine(w1->children, w2->children)) { - boost::tie(e1, e2) = tmp__; - auto& p1 = boost::get(e1->start); - auto& p2 = boost::get(e2->start); - auto p3 = (lerp(p1->ccomponents(), p2->ccomponents(), relative_dist_along) + interpolated_offset).eval(); - // auto p4 = (interpolated_rotation * p3).eval(); - points.push_back(taxonomy::make(p3)); + if (w1->closed != w2->closed) { + Logger::Warning("Mismatching closed property on loops", inst); + return nullptr; + } + + if (w1->tags.is_initialized() != w2->tags.is_initialized()) { + Logger::Warning("Mismatching availability tags on loops", inst); + return nullptr; + } + + if (w1->tags) { + // check uniqueness + std::set tags_seen; + for (const auto& t : *w1->tags) { + if (tags_seen.find(t) != tags_seen.end()) { + Logger::Warning("Duplicate tag '" + t + "' on loft profile", inst); + return nullptr; + } + tags_seen.insert(t); + } } + + if (w2->tags) { + // check uniqueness + std::set tags_seen; + for (const auto& t : *w2->tags) { + if (tags_seen.find(t) != tags_seen.end()) { + Logger::Warning("Duplicate tag '" + t + "' on loft profile", inst); + return nullptr; + } + tags_seen.insert(t); + } + } + + std::map tag_to_point_on_w1, tag_to_point_on_w2; + + auto loop_to_points = [](const taxonomy::loop::ptr& loop, const boost::optional>& input_tags) -> std::pair, std::vector>> { + std::vector points; + std::vector> tags; + std::vector::const_iterator tag_it; + + if (!loop->closed.get_value_or(false)) { + points = {boost::get(loop->children[0]->start)}; + if (input_tags) { + tags = {{input_tags->front()}}; + tag_it = ++input_tags->begin(); + } + } + for (auto& e : loop->children) { + const auto& p1_ = boost::get(e->start); + const auto& p2_ = boost::get(e->end); + if (input_tags && p1_->ccomponents() == p2_->ccomponents()) { + tags.back().insert(*tag_it); + ++tag_it; + } else { + points.push_back(p2_); + if (input_tags) { + tags.emplace_back(); + tags.back().insert(*tag_it); + ++tag_it; + } + } + } + if (!input_tags) { + if (loop->closed.get_value_or(false)) { + // close polygon by referencing first point + points.push_back(points.front()); + } + } + return {points, tags}; + }; + + auto combine_tags = [](const std::vector>& tag_sets) -> std::set { + return std::accumulate( + tag_sets.begin(), tag_sets.end(), std::set{}, + [](std::set acc, + const std::set& m) { + acc.insert(m.begin(), m.end()); + return acc; + }); + }; + + auto join_tags = [](const std::set& tag_set) -> std::string { + std::string result; + for (auto it = tag_set.begin(); it != tag_set.end(); ++it) { + if (it != tag_set.begin()) { + result += ", "; + } + result += *it; + } + return result; + }; + + auto [w1_points, w1_tags] = loop_to_points(w1, w1->tags); + auto [w2_points, w2_tags] = loop_to_points(w2, w2->tags); + + if (w1->tags && w2->tags) { + { + auto it = w1_points.begin(); + auto jt = w1_tags.begin(); + while (it != w1_points.end() && jt != w1_tags.end()) { + for (auto& t : *jt) { + tag_to_point_on_w1[t] = *it; + } + ++it; + ++jt; + } + } + + { + auto it = w2_points.begin(); + auto jt = w2_tags.begin(); + while (it != w2_points.end() && jt != w2_tags.end()) { + for (auto& t : *jt) { + tag_to_point_on_w2[t] = *it; + } + ++it; + ++jt; + } + } + + auto w1_tags_combined = combine_tags(w1_tags); + auto w2_tags_combined = combine_tags(w2_tags); + + // For every point (which can have multiple tags in case of 0-width edges) there needs to be a corresponding point on the other profile + + for (auto& p1_tags : w1_tags) { + if (!has_intersection(p1_tags, w2_tags_combined)) { + Logger::Warning("No matching tags found on loft profiles: " + join_tags(p1_tags) + " not in " + join_tags(w2_tags_combined), inst); + return nullptr; + } + } + + for (auto& p2_tags : w2_tags) { + if (!has_intersection(p2_tags, w1_tags_combined)) { + Logger::Warning("No matching tags found on loft profiles: " + join_tags(p2_tags) + " not in " + join_tags(w1_tags_combined), inst); + return nullptr; + } + } + } else { + if (w1->children.size() != w2->children.size()) { + Logger::Warning("Mismatching number of edges: " + + std::to_string(w1->children.size()) + " vs " + + std::to_string(w2->children.size()), + inst); + return nullptr; + } + } + + std::vector points; + + std::vector common_tags_vec; + if (w1->tags) { + std::set common_tags; + for (const auto& t : *w1->tags) { + if (tag_to_point_on_w2.find(t) == tag_to_point_on_w2.end()) { + continue; + } + + const auto& p1_ = tag_to_point_on_w1[t]; + const auto& p2_ = tag_to_point_on_w2[t]; + + auto p3 = (lerp(p1_->ccomponents(), p2_->ccomponents(), relative_dist_along) + interpolated_offset).eval(); + + std::set tags_for_this_point_on_subsequent_profile = {t}; + + if (is_last_placement_of_this_profile) { + for (auto& ts : w2_tags) { + if (ts.find(t) != ts.end()) { + tags_for_this_point_on_subsequent_profile = ts; + } + } + } + + for (auto& x : tags_for_this_point_on_subsequent_profile) { + points.push_back(taxonomy::make(p3)); + common_tags_vec.push_back(x); + } + } + } else { + for (auto tmp__ : boost::combine(w1_points, w2_points)) { + boost::tie(p1, p2) = tmp__; + auto p3 = (lerp(p1->ccomponents(), p2->ccomponents(), relative_dist_along) + interpolated_offset).eval(); + points.push_back(taxonomy::make(p3)); + } + } + + /* + // This is handled in the loop_to_points() function above if (!points.empty()) { if (!w1->closed.get_value_or(true) && !w2->closed.get_value_or(true)) { // open polygon, add last point @@ -178,12 +377,17 @@ taxonomy::loft::ptr ifcopenshell::geometry::make_loft(const Settings& settings_, points.push_back(points.front()); } } + */ auto interpolated_loop = polygon_from_points(points); - interpolated_loop->external = w1->external; if (interpolated->kind() == taxonomy::FACE) { - std::static_pointer_cast(interpolated)->children.push_back(interpolated_loop); + interpolated_loop->external = w1->external; + std::static_pointer_cast(interpolated)->children.push_back(interpolated_loop); } else { + if (w1->tags) { + std::static_pointer_cast(interpolated)->tags = common_tags_vec; + } + std::static_pointer_cast(interpolated)->closed = w1->closed; std::static_pointer_cast(interpolated)->children = interpolated_loop->children; } } diff --git a/src/ifcgeom/kernels/opencascade/loft.cpp b/src/ifcgeom/kernels/opencascade/loft.cpp index 188150b614..60997d24ad 100644 --- a/src/ifcgeom/kernels/opencascade/loft.cpp +++ b/src/ifcgeom/kernels/opencascade/loft.cpp @@ -27,12 +27,34 @@ #include #include #include +#include using namespace ifcopenshell::geometry; using namespace ifcopenshell::geometry::kernels; using namespace IfcGeom; using namespace IfcGeom::util; +// @todo duplicated +namespace { +template > +bool has_intersection(const std::set& A, + const std::set& B) { + auto itA = A.begin(); + auto itB = B.begin(); + + while (itA != A.end() && itB != B.end()) { + if (Cmp()(*itA, *itB)) { + ++itA; + } else if (Cmp()(*itB, *itA)) { + ++itB; + } else { + return true; + } + } + return false; +} +} + bool OpenCascadeKernel::convert(const taxonomy::loft::ptr loft, TopoDS_Shape& result) { if (loft->children.size() < 2) { return false; @@ -110,37 +132,125 @@ bool OpenCascadeKernel::convert(const taxonomy::loft::ptr loft, TopoDS_Shape& re BRep_Builder BB; BB.MakeCompound(comp); - // @todo this approach is - // potentially incorrect as there is no guarantee that the wires for - // subsequently placed profiles are traversed from an equivalent start vertex. + std::vector shps(loft->children.size()); + std::vector>> all_tags; - for (auto it = loft->children.begin(); it < loft->children.end() - 1; ++it) { + + std::ostringstream oss; + loft->children[0]->print(oss); + loft->children[1]->print(oss); + auto s = oss.str(); + std::wcout << s.c_str() << std::endl; + + // First convert all taxonomy items to TopoDS_Wire/Face + for (auto it = loft->children.begin(); it < loft->children.end(); ++it) { + auto i = std::distance(loft->children.begin(), it); + if ((*it)->kind() == taxonomy::FACE) { + if (!convert(std::static_pointer_cast((*it)), shps[i])) { + return false; + } + } + if ((*it)->kind() == taxonomy::LOOP) { + + // @todo duplicated with infra_sweep_helper + // I think make_loft() where should just return a shell instead, because + // this faceted lofting does not depend on any functionality in the geometry library + // and the branching with tags needs to be solved twice otherwise + auto loop_to_points = [](const taxonomy::loop::ptr& loop, const boost::optional>& input_tags) -> std::pair, std::vector>> { + std::vector points; + std::vector> tags; + std::vector::const_iterator tag_it; + + if (!loop->closed.get_value_or(false)) { + points = {boost::get(loop->children[0]->start)}; + if (input_tags) { + tags = {{input_tags->front()}}; + tag_it = ++input_tags->begin(); + } + } + for (auto& e : loop->children) { + const auto& p1 = boost::get(e->start); + const auto& p2 = boost::get(e->end); + if (input_tags && p1->ccomponents() == p2->ccomponents()) { + tags.back().insert(*tag_it); + ++tag_it; + } else { + points.push_back(p2); + if (input_tags) { + tags.emplace_back(); + tags.back().insert(*tag_it); + ++tag_it; + } + } + } + if (!input_tags) { + if (loop->closed.get_value_or(false)) { + // close polygon by referencing first point + points.push_back(points.front()); + } + } + return {points, tags}; + }; + + auto lp = std::static_pointer_cast(*it); + TopoDS_Wire w; + + if (lp->tags) { + auto [points, tags] = loop_to_points(lp, lp->tags); + BRepBuilderAPI_MakePolygon mp; + for (auto& p : points) { + const auto& xyz = p->ccomponents(); + mp.Add(gp_Pnt(xyz(0), xyz(1), xyz(2))); + } + w = mp.Wire(); + + if (lp->matrix && !lp->matrix->is_identity()) { + const auto& m = lp->matrix->ccomponents(); + gp_Trsf tr; + tr.SetValues( + m(0, 0), m(0, 1), m(0, 2), m(0, 3), m(1, 0), m(1, 1), m(1, 2), m(1, 3), m(2, 0), m(2, 1), m(2, 2), m(2, 3)); + w = TopoDS::Wire(BRepBuilderAPI_Transform(w, tr).Shape()); + } + + all_tags.push_back(tags); + } else { + if (!convert(std::static_pointer_cast((*it)), w)) { + return false; + } + } + + shps[i] = w; + } + if (shps[i].ShapeType() != TopAbs_FACE && shps[i].ShapeType() != TopAbs_WIRE) { + return false; + } + } + + /* + // With --dimensionality CURVES_SURFACES_AND_SOLIDS this will give the interpolated profiles as line geometry + { + for (auto& f : shps) { + BB.Add(comp, f); + } + } + result = comp; + return true; + */ + + // @todo this approach is + // potentially incorrect as there is no guarantee that the wires for + // subsequently placed profiles are traversed from an equivalent start vertex. + for (auto it = shps.begin(); it < shps.end() - 1; ++it) { + auto ii = std::distance(shps.begin(), it); auto jt = it + 1; - std::array fa = { *it, *jt }; - std::array shps; + std::array::const_iterator, 2> fa = { it, jt }; std::vector> ws; ws.emplace_back(); for (int i = 0; i < 2; ++i) { - if (fa[i]->kind() == taxonomy::FACE) { - if (!convert(std::static_pointer_cast(fa[i]), shps[i])) { - return false; - } - } - if (fa[i]->kind() == taxonomy::LOOP) { - TopoDS_Wire w; - if (!convert(std::static_pointer_cast(fa[i]), w)) { - return false; - } - shps[i] = w; - } - if (shps[i].ShapeType() != TopAbs_FACE && shps[i].ShapeType() != TopAbs_WIRE) { - return false; - } - - if (shps[i].ShapeType() == TopAbs_FACE) { - ws[0][i] = BRepTools::OuterWire(TopoDS::Face(shps[i])); + if (fa[i]->ShapeType() == TopAbs_FACE) { + ws[0][i] = BRepTools::OuterWire(TopoDS::Face(*fa[i])); size_t j = 1; - for (TopExp_Explorer exp(shps[i], TopAbs_WIRE); exp.More(); exp.Next()) { + for (TopExp_Explorer exp(*fa[i], TopAbs_WIRE); exp.More(); exp.Next()) { if (exp.Current() != ws[0][i]) { while (ws.size() <= j) { ws.emplace_back(); @@ -149,22 +259,110 @@ bool OpenCascadeKernel::convert(const taxonomy::loft::ptr loft, TopoDS_Shape& re } } } else { - ws[0][i] = TopoDS::Wire(shps[i]); + ws[0][i] = TopoDS::Wire(*fa[i]); } } - if (shps[0].ShapeType() == TopAbs_FACE) { + if (it->ShapeType() == TopAbs_FACE) { // When processing a sectioned *surface* there are no // begin and end caps that need to be added. - if (it == loft->children.begin()) { + if (it == shps.begin()) { // faces.Append(shps[0]); BB.Add(comp, shps[0]); } - if (jt == loft->children.end() - 1) { + if (jt == shps.end() - 1) { // faces.Append(shps[1]); BB.Add(comp, shps[1]); } } + if (!all_tags.empty()) { + // only open profiles have tags for now, so there is only one wire, no inner wires + const auto& wp = ws[0]; + std::array, 2> profile_points; + std::array>>::const_iterator, 2> tag_pairs = { + all_tags.begin() + std::distance(shps.begin(), it), + all_tags.begin() + std::distance(shps.begin(), jt)}; + + for (size_t i = 0; i < 2; ++i) { + TopTools_IndexedDataMapOfShapeListOfShape ancestors; + const auto& wire = wp[i]; + auto& result = profile_points[i]; + + TopExp::MapShapesAndAncestors( + wire, + TopAbs_VERTEX, + TopAbs_EDGE, + ancestors); + + TopoDS_Vertex v0, vn, previous; + TopExp::Vertices(wire, v0, vn); + + TopoDS_Vertex curr = v0; + result.push_back(BRep_Tool::Pnt(curr)); + + while (true) { + if (curr.IsSame(vn)) { + break; + } + + const TopTools_ListOfShape& incidentEdges = ancestors.FindFromKey(curr); + + for (TopTools_ListIteratorOfListOfShape it(incidentEdges); it.More(); it.Next()) { + const TopoDS_Edge& e = TopoDS::Edge(it.Value()); + + TopoDS_Vertex ev0, ev1; + TopExp::Vertices(e, ev0, ev1); + + TopoDS_Vertex other_on_edge = curr.IsSame(ev0) ? ev1 : ev0; + if (other_on_edge.IsSame(previous)) { + continue; + } else { + previous = curr; + curr = other_on_edge; + result.push_back(BRep_Tool::Pnt(curr)); + break; + } + } + } + } + + auto a = profile_points[0].begin(); + auto b = profile_points[1].begin(); + auto c = tag_pairs[0]->begin(); + auto d = tag_pairs[1]->begin(); + + if (!has_intersection(*c, *d)) { + throw std::runtime_error("Starting vertices do not have corresponding tags"); + } + + auto emit_triangle = [&](const gp_Pnt& p1, const gp_Pnt& p2, const gp_Pnt& p3) { + BB.Add(comp, BRepBuilderAPI_MakeFace(BRepBuilderAPI_MakePolygon(p1, p2, p3, true).Wire()).Face()); + }; + + while (c != (tag_pairs[0]->end() - 1) && d != (tag_pairs[0]->end() - 1)) { + if (c != (tag_pairs[0]->end() - 1) && has_intersection(*(c + 1), *d)) { + emit_triangle(*a, *(a + 1), *b); + ++a; + ++c; + } else if (d != (tag_pairs[1]->end() - 1) && has_intersection(*c, *(d + 1))) { + emit_triangle(*a, *(b + 1), *b); + ++b; + ++d; + } else if (c != (tag_pairs[0]->end() - 1) && d != (tag_pairs[1]->end() - 1) && has_intersection(*(c + 1), *(d + 1))) { + emit_triangle(*a, *(a + 1), *b); + emit_triangle(*(a + 1), *(b + 1), *b); + ++a; + ++b; + ++c; + ++d; + } else { + throw std::runtime_error("Unable to construct surface"); + } + } + + continue; + } + for (auto& wp : ws) { BRepTools_WireExplorer a(wp[0]); BRepTools_WireExplorer b(wp[1]); diff --git a/src/ifcgeom/mapping/IfcOpenCrossProfileDef.cpp b/src/ifcgeom/mapping/IfcOpenCrossProfileDef.cpp index 334be1324a..7f3c4f43c1 100644 --- a/src/ifcgeom/mapping/IfcOpenCrossProfileDef.cpp +++ b/src/ifcgeom/mapping/IfcOpenCrossProfileDef.cpp @@ -50,7 +50,6 @@ taxonomy::ptr mapping::map_impl(const IfcSchema::IfcOpenCrossProfileDef* inst) { if (tags.has_value() && !tags.get().empty()) { tag = tags.get()[0]; } - // start->tag = tag; auto widths = inst->Widths(); auto angles = inst->Slopes(); // these are actually angles, but the attribute is called Slopes @@ -79,16 +78,13 @@ taxonomy::ptr mapping::map_impl(const IfcSchema::IfcOpenCrossProfileDef* inst) { tag = tags.get()[i+1]; } - // points.push_back(taxonomy::make(x, y, z, tag)); points.push_back(taxonomy::make(x, y, z)); } auto mapped = polygon_from_points(points); - if (mapped->kind() == taxonomy::LOOP) { - auto r = taxonomy::loop::ptr((taxonomy::loop*)mapped->clone_()); - r->closed = false; - return r; - } + mapped->closed = false; + mapped->tags = tags; + return mapped; } diff --git a/src/ifcgeom/taxonomy.cpp b/src/ifcgeom/taxonomy.cpp index e03c1010a3..7d509db8cb 100644 --- a/src/ifcgeom/taxonomy.cpp +++ b/src/ifcgeom/taxonomy.cpp @@ -822,11 +822,11 @@ namespace { boost::optional ifcopenshell::geometry::taxonomy::loop_to_function_item_upgrade_impl(ptr item) { - boost::optional fi_; + boost::optional function_item_; auto loop_ = dcast(item); if (loop_) { - if (loop_->fi.is_initialized()) { - fi_ = loop_->fi; + if (loop_->function_item.is_initialized()) { + function_item_ = loop_->function_item; } else { // piecewise_function is a specialization of function_item - callers don't need to know this detail piecewise_function::spans_t spans; @@ -880,9 +880,9 @@ boost::optional ifcopenshell::geometry::taxonomy::loop_to_fu return boost::none; } } - fi_ = make(0.0,spans); - loop_->fi = fi_; + function_item_ = make(0.0, spans); + loop_->function_item = function_item_; } } - return fi_; + return function_item_; } diff --git a/src/ifcgeom/taxonomy.h b/src/ifcgeom/taxonomy.h index d3ec5c5e17..ab8a222aee 100644 --- a/src/ifcgeom/taxonomy.h +++ b/src/ifcgeom/taxonomy.h @@ -928,7 +928,8 @@ typedef item const* ptr; DECLARE_PTR(loop) boost::optional external, closed; - boost::optional fi; + boost::optional function_item; + boost::optional> tags; bool is_polyhedron() const { for (auto& e : children) { From bb50be389e3010fe85bed02ab3aeb96afba30a35 Mon Sep 17 00:00:00 2001 From: Dion Moult Date: Thu, 15 Jan 2026 09:15:13 +1100 Subject: [PATCH 43/49] Refactor should_show_panel into tool.Blender (avoid helper.py) and merge into is_tab This moves the logic from ui.py into tool.Blender. In general, helper.py is a bit generic (and historic) and we should use tool instead. --- src/bonsai/bonsai/bim/helper.py | 17 -- src/bonsai/bonsai/bim/module/material/ui.py | 2 - src/bonsai/bonsai/bim/ui.py | 185 ++++---------------- src/bonsai/bonsai/tool/blender.py | 17 +- 4 files changed, 51 insertions(+), 170 deletions(-) diff --git a/src/bonsai/bonsai/bim/helper.py b/src/bonsai/bonsai/bim/helper.py index e76b512de3..e6589fe672 100644 --- a/src/bonsai/bonsai/bim/helper.py +++ b/src/bonsai/bonsai/bim/helper.py @@ -813,16 +813,6 @@ def get_panel_tab_name(panel_class): return "PROJECT" # Default fallback -def should_show_panel(panel_id, panel_tab_name, context): - if tool.Blender.is_tab(context, "BOOKMARK"): - return is_panel_bookmarked(panel_id) and get_panel_visibility(panel_id, "BOOKMARK") - - if tool.Blender.is_tab(context, panel_tab_name): - return get_tab_visibility(panel_tab_name) and get_panel_visibility(panel_id, panel_tab_name) - - return False - - def get_tab_visibility(tab_name): bim_props = tool.Blender.get_bim_props() tab_vis = bim_props.tab_visibilities.get(tab_name) @@ -850,13 +840,6 @@ def get_panel_visibility(panel_id, current_tab=None): return True -def is_panel_bookmarked(panel_id): - panel_config = get_panel_config(panel_id) - if panel_config: - return panel_config.is_bookmarked - return False - - def get_panel_config(panel_id, create_if_missing=False): try: bim_props = tool.Blender.get_bim_props() diff --git a/src/bonsai/bonsai/bim/module/material/ui.py b/src/bonsai/bonsai/bim/module/material/ui.py index c1b9ac2ae4..db05276d9b 100644 --- a/src/bonsai/bonsai/bim/module/material/ui.py +++ b/src/bonsai/bonsai/bim/module/material/ui.py @@ -136,8 +136,6 @@ class BIM_PT_object_material(Panel): @classmethod def poll(cls, context): - if not tool.Blender.is_tab(context, "GEOMETRY"): - return False if not (obj := context.active_object): return False ifc_id = tool.Blender.get_ifc_definition_id(obj) diff --git a/src/bonsai/bonsai/bim/ui.py b/src/bonsai/bonsai/bim/ui.py index 514aa168ce..ce53858871 100644 --- a/src/bonsai/bonsai/bim/ui.py +++ b/src/bonsai/bonsai/bim/ui.py @@ -43,7 +43,6 @@ from bonsai.bim.helper import ( get_tab_visibility, set_tab_visibility, get_panel_visibility, - is_panel_bookmarked, get_panel_config, get_all_tab_panels, initialize_tab_visibilities, @@ -1147,9 +1146,7 @@ class BIM_PT_tab_new_project_wizard(Panel): @classmethod def poll(cls, context): - if not should_show_panel(cls.bl_idname, cls.bim_tab_name, context): - return False - if not tool.Blender.is_tab(context, cls.bim_tab_name): + if not tool.Blender.should_show_panel(context, cls.bim_tab_name, cls.bl_idname): return False bim_props = tool.Blender.get_bim_props() pprops = tool.Project.get_project_props() @@ -1173,16 +1170,13 @@ class BIM_PT_tab_project_info(Panel): @classmethod def poll(cls, context): - if not should_show_panel(cls.bl_idname, cls.bim_tab_name, context): - return False - if tool.Blender.is_tab(context, cls.bim_tab_name): + if tool.Blender.should_show_panel(context, cls.bim_tab_name, cls.bl_idname): bim_props = tool.Blender.get_bim_props() pprops = tool.Project.get_project_props() if pprops.is_loading: return True elif tool.Ifc.get() or bim_props.ifc_file: return True - return tool.Blender.is_tab(context, "BOOKMARK") def draw(self, context): pass @@ -1198,11 +1192,8 @@ class BIM_PT_tab_spatial(Panel): @classmethod def poll(cls, context): - if not should_show_panel(cls.bl_idname, cls.bim_tab_name, context): - return False - if tool.Blender.is_tab(context, cls.bim_tab_name) and tool.Ifc.get(): + if tool.Blender.should_show_panel(context, cls.bim_tab_name, cls.bl_idname) and tool.Ifc.get(): return True - return tool.Blender.is_tab(context, "BOOKMARK") def draw(self, context): pass @@ -1218,11 +1209,8 @@ class BIM_PT_tab_project_setup(Panel): @classmethod def poll(cls, context): - if not should_show_panel(cls.bl_idname, cls.bim_tab_name, context): - return False - if tool.Blender.is_tab(context, cls.bim_tab_name): + if tool.Blender.should_show_panel(context, cls.bim_tab_name, cls.bl_idname): return True - return tool.Blender.is_tab(context, "BOOKMARK") def draw(self, context): pass @@ -1239,11 +1227,8 @@ class BIM_PT_tab_stakeholders(Panel): @classmethod def poll(cls, context): - if not should_show_panel(cls.bl_idname, cls.bim_tab_name, context): - return False - if tool.Blender.is_tab(context, cls.bim_tab_name) and tool.Ifc.get(): + if tool.Blender.should_show_panel(context, cls.bim_tab_name, cls.bl_idname) and tool.Ifc.get(): return True - return tool.Blender.is_tab(context, "BOOKMARK") def draw(self, context): pass @@ -1259,11 +1244,8 @@ class BIM_PT_tab_collaboration(Panel): @classmethod def poll(cls, context): - if not should_show_panel(cls.bl_idname, cls.bim_tab_name, context): - return False - if tool.Blender.is_tab(context, cls.bim_tab_name): + if tool.Blender.should_show_panel(context, cls.bim_tab_name, cls.bl_idname): return True - return tool.Blender.is_tab(context, "BOOKMARK") def draw(self, context): pass @@ -1280,11 +1262,8 @@ class BIM_PT_tab_grouping_and_filtering(Panel): @classmethod def poll(cls, context): - if not should_show_panel(cls.bl_idname, cls.bim_tab_name, context): - return False - if tool.Blender.is_tab(context, cls.bim_tab_name) and tool.Ifc.get(): + if tool.Blender.should_show_panel(context, cls.bim_tab_name, cls.bl_idname) and tool.Ifc.get(): return True - return tool.Blender.is_tab(context, "BOOKMARK") def draw(self, context): pass @@ -1308,11 +1287,8 @@ class BIM_PT_tab_geometry(Panel): @classmethod def poll(cls, context): - if not should_show_panel(cls.bl_idname, cls.bim_tab_name, context): - return False - if tool.Blender.is_tab(context, cls.bim_tab_name) and tool.Ifc.get(): + if tool.Blender.should_show_panel(context, cls.bim_tab_name, cls.bl_idname) and tool.Ifc.get(): return True - return tool.Blender.is_tab(context, "BOOKMARK") def draw(self, context): pass @@ -1328,11 +1304,8 @@ class BIM_PT_tab_status(Panel): @classmethod def poll(cls, context): - if not should_show_panel(cls.bl_idname, cls.bim_tab_name, context): - return False - if tool.Blender.is_tab(context, cls.bim_tab_name) and tool.Ifc.get(): + if tool.Blender.should_show_panel(context, cls.bim_tab_name, cls.bl_idname) and tool.Ifc.get(): return True - return tool.Blender.is_tab(context, "BOOKMARK") def draw(self, context): pass @@ -1348,11 +1321,8 @@ class BIM_PT_tab_qto(Panel): @classmethod def poll(cls, context): - if not should_show_panel(cls.bl_idname, cls.bim_tab_name, context): - return False - if tool.Blender.is_tab(context, cls.bim_tab_name) and tool.Ifc.get(): + if tool.Blender.should_show_panel(context, cls.bim_tab_name, cls.bl_idname) and tool.Ifc.get(): return True - return tool.Blender.is_tab(context, "BOOKMARK") def draw(self, context): pass @@ -1368,11 +1338,8 @@ class BIM_PT_tab_resources(Panel): @classmethod def poll(cls, context): - if not should_show_panel(cls.bl_idname, cls.bim_tab_name, context): - return False - if tool.Blender.is_tab(context, cls.bim_tab_name) and tool.Ifc.get(): + if tool.Blender.should_show_panel(context, cls.bim_tab_name, cls.bl_idname) and tool.Ifc.get(): return True - return tool.Blender.is_tab(context, "BOOKMARK") def draw(self, context): pass @@ -1388,11 +1355,8 @@ class BIM_PT_tab_cost(Panel): @classmethod def poll(cls, context): - if not should_show_panel(cls.bl_idname, cls.bim_tab_name, context): - return False - if tool.Blender.is_tab(context, cls.bim_tab_name) and tool.Ifc.get(): + if tool.Blender.should_show_panel(context, cls.bim_tab_name, cls.bl_idname) and tool.Ifc.get(): return True - return tool.Blender.is_tab(context, "BOOKMARK") def draw(self, context): pass @@ -1408,11 +1372,8 @@ class BIM_PT_tab_sequence(Panel): @classmethod def poll(cls, context): - if not should_show_panel(cls.bl_idname, cls.bim_tab_name, context): - return False - if tool.Blender.is_tab(context, cls.bim_tab_name) and tool.Ifc.get(): + if tool.Blender.should_show_panel(context, cls.bim_tab_name, cls.bl_idname) and tool.Ifc.get(): return True - return tool.Blender.is_tab(context, "BOOKMARK") def draw(self, context): pass @@ -1428,11 +1389,8 @@ class BIM_PT_tab_structural(Panel): @classmethod def poll(cls, context): - if not should_show_panel(cls.bl_idname, cls.bim_tab_name, context): - return False - if tool.Blender.is_tab(context, cls.bim_tab_name) and tool.Ifc.get(): + if tool.Blender.should_show_panel(context, cls.bim_tab_name, cls.bl_idname) and tool.Ifc.get(): return True - return tool.Blender.is_tab(context, "BOOKMARK") def draw(self, context): pass @@ -1448,11 +1406,8 @@ class BIM_PT_tab_services(Panel): @classmethod def poll(cls, context): - if not should_show_panel(cls.bl_idname, cls.bim_tab_name, context): - return False - if tool.Blender.is_tab(context, cls.bim_tab_name) and tool.Ifc.get(): + if tool.Blender.should_show_panel(context, cls.bim_tab_name, cls.bl_idname) and tool.Ifc.get(): return True - return tool.Blender.is_tab(context, "BOOKMARK") def draw(self, context): pass @@ -1468,11 +1423,8 @@ class BIM_PT_tab_lighting(Panel): @classmethod def poll(cls, context): - if not should_show_panel(cls.bl_idname, cls.bim_tab_name, context): - return False - if tool.Blender.is_tab(context, cls.bim_tab_name) and tool.Ifc.get(): + if tool.Blender.should_show_panel(context, cls.bim_tab_name, cls.bl_idname) and tool.Ifc.get(): return True - return tool.Blender.is_tab(context, "BOOKMARK") def draw(self, context): pass @@ -1488,11 +1440,8 @@ class BIM_PT_tab_zones(Panel): @classmethod def poll(cls, context): - if not should_show_panel(cls.bl_idname, cls.bim_tab_name, context): - return False - if tool.Blender.is_tab(context, cls.bim_tab_name) and tool.Ifc.get(): + if tool.Blender.should_show_panel(context, cls.bim_tab_name, cls.bl_idname) and tool.Ifc.get(): return True - return tool.Blender.is_tab(context, "BOOKMARK") def draw(self, context): pass @@ -1508,11 +1457,8 @@ class BIM_PT_tab_solar_analysis(Panel): @classmethod def poll(cls, context): - if not should_show_panel(cls.bl_idname, cls.bim_tab_name, context): - return False - if tool.Blender.is_tab(context, cls.bim_tab_name) and tool.Ifc.get(): + if tool.Blender.should_show_panel(context, cls.bim_tab_name, cls.bl_idname) and tool.Ifc.get(): return True - return tool.Blender.is_tab(context, "BOOKMARK") def draw(self, context): pass @@ -1528,11 +1474,8 @@ class BIM_PT_tab_quality_control(Panel): @classmethod def poll(cls, context): - if not should_show_panel(cls.bl_idname, cls.bim_tab_name, context): - return False - if tool.Blender.is_tab(context, cls.bim_tab_name): + if tool.Blender.should_show_panel(context, cls.bim_tab_name, cls.bl_idname): return True - return tool.Blender.is_tab(context, "BOOKMARK") def draw(self, context): pass @@ -1548,11 +1491,8 @@ class BIM_PT_tab_clash_detection(Panel): @classmethod def poll(cls, context): - if not should_show_panel(cls.bl_idname, cls.bim_tab_name, context): - return False - if tool.Blender.is_tab(context, cls.bim_tab_name): + if tool.Blender.should_show_panel(context, cls.bim_tab_name, cls.bl_idname): return True - return tool.Blender.is_tab(context, "BOOKMARK") def draw(self, context): pass @@ -1569,11 +1509,8 @@ class BIM_PT_tab_sandbox(Panel): @classmethod def poll(cls, context): - if not should_show_panel(cls.bl_idname, cls.bim_tab_name, context): - return False - if tool.Blender.is_tab(context, cls.bim_tab_name): + if tool.Blender.should_show_panel(context, cls.bim_tab_name, cls.bl_idname): return True - return tool.Blender.is_tab(context, "BOOKMARK") def draw(self, context): row = self.layout.row() @@ -1592,11 +1529,9 @@ class BIM_PT_tab_object_metadata(Panel): @classmethod def poll(cls, context): - if not should_show_panel(cls.bl_idname, cls.bim_tab_name, context): - return False props = tool.Project.get_project_props() if ( - tool.Blender.is_tab(context, cls.bim_tab_name) + tool.Blender.should_show_panel(context, cls.bim_tab_name, cls.bl_idname) and tool.Ifc.get() and (obj := context.active_object) # Hide links empty handles. @@ -1607,7 +1542,6 @@ class BIM_PT_tab_object_metadata(Panel): ) ): return True - return tool.Blender.is_tab(context, "BOOKMARK") def draw(self, context): pass @@ -1624,16 +1558,13 @@ class BIM_PT_tab_placement(Panel): @classmethod def poll(cls, context): - if not should_show_panel(cls.bl_idname, cls.bim_tab_name, context): - return False if ( - tool.Blender.is_tab(context, cls.bim_tab_name) + tool.Blender.should_show_panel(context, cls.bim_tab_name, cls.bl_idname) and tool.Ifc.get() and (obj := context.active_object) and tool.Ifc.get_entity(obj) ): return True - return tool.Blender.is_tab(context, "BOOKMARK") def draw(self, context): pass @@ -1650,11 +1581,8 @@ class BIM_PT_tab_representations(Panel): @classmethod def poll(cls, context): - if not should_show_panel(cls.bl_idname, cls.bim_tab_name, context): - return False - if tool.Blender.is_tab(context, cls.bim_tab_name) and tool.Ifc.get(): + if tool.Blender.should_show_panel(context, cls.bim_tab_name, cls.bl_idname) and tool.Ifc.get(): return True - return tool.Blender.is_tab(context, "BOOKMARK") def draw(self, context): pass @@ -1672,11 +1600,8 @@ class BIM_PT_tab_geometric_relationships(Panel): @classmethod def poll(cls, context): - if not should_show_panel(cls.bl_idname, cls.bim_tab_name, context): - return False - if tool.Blender.is_tab(context, cls.bim_tab_name) and tool.Ifc.get(): + if tool.Blender.should_show_panel(context, cls.bim_tab_name, cls.bl_idname) and tool.Ifc.get(): return True - return tool.Blender.is_tab(context, "BOOKMARK") def draw(self, context): pass @@ -1694,11 +1619,8 @@ class BIM_PT_tab_parametric_geometry(Panel): @classmethod def poll(cls, context): - if not should_show_panel(cls.bl_idname, cls.bim_tab_name, context): - return False - if tool.Blender.is_tab(context, cls.bim_tab_name) and tool.Ifc.get(): + if tool.Blender.should_show_panel(context, cls.bim_tab_name, cls.bl_idname) and tool.Ifc.get(): return True - return tool.Blender.is_tab(context, "BOOKMARK") def draw(self, context): pass @@ -1715,11 +1637,8 @@ class BIM_PT_tab_object_materials(Panel): @classmethod def poll(cls, context): - if not should_show_panel(cls.bl_idname, cls.bim_tab_name, context): - return False - if tool.Blender.is_tab(context, cls.bim_tab_name) and tool.Ifc.get(): + if tool.Blender.should_show_panel(context, cls.bim_tab_name, cls.bl_idname) and tool.Ifc.get(): return True - return tool.Blender.is_tab(context, "BOOKMARK") def draw(self, context): pass @@ -1736,11 +1655,8 @@ class BIM_PT_tab_materials(Panel): @classmethod def poll(cls, context): - if not should_show_panel(cls.bl_idname, cls.bim_tab_name, context): - return False - if tool.Blender.is_tab(context, cls.bim_tab_name) and tool.Ifc.get(): + if tool.Blender.should_show_panel(context, cls.bim_tab_name, cls.bl_idname) and tool.Ifc.get(): return True - return tool.Blender.is_tab(context, "BOOKMARK") def draw(self, context): pass @@ -1757,11 +1673,8 @@ class BIM_PT_tab_styles(Panel): @classmethod def poll(cls, context): - if not should_show_panel(cls.bl_idname, cls.bim_tab_name, context): - return False - if tool.Blender.is_tab(context, cls.bim_tab_name) and tool.Ifc.get(): + if tool.Blender.should_show_panel(context, cls.bim_tab_name, cls.bl_idname) and tool.Ifc.get(): return True - return tool.Blender.is_tab(context, "BOOKMARK") def draw(self, context): pass @@ -1778,11 +1691,8 @@ class BIM_PT_tab_profiles(Panel): @classmethod def poll(cls, context): - if not should_show_panel(cls.bl_idname, cls.bim_tab_name, context): - return False - if tool.Blender.is_tab(context, cls.bim_tab_name) and tool.Ifc.get(): + if tool.Blender.should_show_panel(context, cls.bim_tab_name, cls.bl_idname) and tool.Ifc.get(): return True - return tool.Blender.is_tab(context, "BOOKMARK") def draw(self, context): pass @@ -1799,11 +1709,8 @@ class BIM_PT_tab_sheets(Panel): @classmethod def poll(cls, context): - if not should_show_panel(cls.bl_idname, cls.bim_tab_name, context): - return False - if tool.Blender.is_tab(context, cls.bim_tab_name) and tool.Ifc.get(): + if tool.Blender.should_show_panel(context, cls.bim_tab_name, cls.bl_idname) and tool.Ifc.get(): return True - return tool.Blender.is_tab(context, "BOOKMARK") def draw(self, context): pass @@ -1820,11 +1727,8 @@ class BIM_PT_tab_drawings(Panel): @classmethod def poll(cls, context): - if not should_show_panel(cls.bl_idname, cls.bim_tab_name, context): - return False - if tool.Blender.is_tab(context, cls.bim_tab_name) and tool.Ifc.get(): + if tool.Blender.should_show_panel(context, cls.bim_tab_name, cls.bl_idname) and tool.Ifc.get(): return True - return tool.Blender.is_tab(context, "BOOKMARK") def draw(self, context): pass @@ -1841,11 +1745,8 @@ class BIM_PT_tab_schedules(Panel): @classmethod def poll(cls, context): - if not should_show_panel(cls.bl_idname, cls.bim_tab_name, context): - return False - if tool.Blender.is_tab(context, cls.bim_tab_name) and tool.Ifc.get(): + if tool.Blender.should_show_panel(context, cls.bim_tab_name, cls.bl_idname) and tool.Ifc.get(): return True - return tool.Blender.is_tab(context, "BOOKMARK") def draw(self, context): pass @@ -1862,11 +1763,8 @@ class BIM_PT_tab_references(Panel): @classmethod def poll(cls, context): - if not should_show_panel(cls.bl_idname, cls.bim_tab_name, context): - return False - if tool.Blender.is_tab(context, cls.bim_tab_name) and tool.Ifc.get(): + if tool.Blender.should_show_panel(context, cls.bim_tab_name, cls.bl_idname) and tool.Ifc.get(): return True - return tool.Blender.is_tab(context, "BOOKMARK") def draw(self, context): pass @@ -1884,11 +1782,8 @@ class BIM_PT_tab_misc(Panel): @classmethod def poll(cls, context): - if not should_show_panel(cls.bl_idname, cls.bim_tab_name, context): - return False - if tool.Blender.is_tab(context, cls.bim_tab_name) and tool.Ifc.get(): + if tool.Blender.should_show_panel(context, cls.bim_tab_name, cls.bl_idname) and tool.Ifc.get(): return True - return tool.Blender.is_tab(context, "BOOKMARK") def draw(self, context): pass @@ -1905,11 +1800,8 @@ class BIM_PT_tab_handover(Panel): @classmethod def poll(cls, context): - if not should_show_panel(cls.bl_idname, cls.bim_tab_name, context): - return False - if tool.Blender.is_tab(context, cls.bim_tab_name): + if tool.Blender.should_show_panel(context, cls.bim_tab_name, cls.bl_idname): return True - return tool.Blender.is_tab(context, "BOOKMARK") def draw(self, context): pass @@ -1926,11 +1818,8 @@ class BIM_PT_tab_operations(Panel): @classmethod def poll(cls, context): - if not should_show_panel(cls.bl_idname, cls.bim_tab_name, context): - return False - if tool.Blender.is_tab(context, cls.bim_tab_name): + if tool.Blender.should_show_panel(context, cls.bim_tab_name, cls.bl_idname): return True - return tool.Blender.is_tab(context, "BOOKMARK") def draw(self, context): pass diff --git a/src/bonsai/bonsai/tool/blender.py b/src/bonsai/bonsai/tool/blender.py index e45e2b1206..2f6190c448 100644 --- a/src/bonsai/bonsai/tool/blender.py +++ b/src/bonsai/bonsai/tool/blender.py @@ -161,11 +161,19 @@ class Blender(bonsai.core.tool.Blender): screen.BIMAreaProperties.add() @classmethod - def is_tab(cls, context: bpy.types.Context, tab: str) -> bool: + def should_show_panel(cls, context: bpy.types.Context, tab: str, panel: str) -> bool: aprops = cls.get_area_props(context) if aprops.path_from_id() == "BIMAreaProperties" and context.area.spaces.active.search_filter: return True - return aprops.tab == tab + if (is_bookmark_tab := aprops.tab == "BOOKMARK") or aprops.tab == tab: + bprops = tool.Blender.get_bim_props() + if not (panel_visibility := bprops.panel_properties.get(panel)): + return not is_bookmark_tab + if is_bookmark_tab: + if panel_visibility.is_bookmarked and panel_visibility.is_visible_in_bookmarks: + return True + elif panel_visibility.is_visible_in_tab: + return True @classmethod def is_default_scene(cls) -> bool: @@ -1461,7 +1469,10 @@ class Blender(bonsai.core.tool.Blender): def override_scene_panel(cls, original_panel: bpy.types.Panel) -> None: @classmethod def poll_check_blender_tab(cls, context): - return tool.Blender.is_tab(context, "BLENDER") + aprops = tool.Blender.get_area_props(context) + if aprops.path_from_id() == "BIMAreaProperties" and context.area.spaces.active.search_filter: + return True + return aprops.tab == "BLENDER" polls = bonsai.bim.original_scene_panels_polls From 26fb6ebd3c28b4d1950dbca3e50afb4a9381e856 Mon Sep 17 00:00:00 2001 From: Dion Moult Date: Thu, 15 Jan 2026 11:30:28 +1100 Subject: [PATCH 44/49] Refactor tab visibility to use data cache Previously calculation of visibility was done on every draw (3x 10tabs x 10 collection items). The data class is intended to calculate UI data once only which is more efficient. This also removes all helper calls from the UI. --- src/bonsai/bonsai/bim/helper.py | 16 ------ src/bonsai/bonsai/bim/prop.py | 6 ++- src/bonsai/bonsai/bim/ui.py | 91 ++++++++++++++------------------- 3 files changed, 42 insertions(+), 71 deletions(-) diff --git a/src/bonsai/bonsai/bim/helper.py b/src/bonsai/bonsai/bim/helper.py index e6589fe672..aed0e09ca4 100644 --- a/src/bonsai/bonsai/bim/helper.py +++ b/src/bonsai/bonsai/bim/helper.py @@ -830,16 +830,6 @@ def set_tab_visibility(tab_name, visible): new_tab.is_visible = visible -def get_panel_visibility(panel_id, current_tab=None): - panel_config = get_panel_config(panel_id) - if panel_config: - if current_tab == "BOOKMARK": - return panel_config.is_visible_in_bookmarks - else: - return panel_config.is_visible_in_tab - return True - - def get_panel_config(panel_id, create_if_missing=False): try: bim_props = tool.Blender.get_bim_props() @@ -854,9 +844,6 @@ def get_panel_config(panel_id, create_if_missing=False): try: prop = bim_props.panel_properties.add() prop.name = panel_id - prop.is_visible_in_tab = True - prop.is_visible_in_bookmarks = True - prop.is_bookmarked = False return prop except AttributeError: pass @@ -918,6 +905,3 @@ def initialize_panel_properties(): prop = bim_props.panel_properties.add() prop.name = panel_id - prop.is_visible_in_tab = True - prop.is_visible_in_bookmarks = True - prop.is_bookmarked = False diff --git a/src/bonsai/bonsai/bim/prop.py b/src/bonsai/bonsai/bim/prop.py index 21386c4544..0b513882c6 100644 --- a/src/bonsai/bonsai/bim/prop.py +++ b/src/bonsai/bonsai/bim/prop.py @@ -60,6 +60,10 @@ def update_tab(self: "BIMAreaProperties", context: bpy.types.Context) -> None: self.previous_tab = self.tab +def update_is_visible(self: "BIMTabVisibility", context: bpy.types.Context) -> None: + bonsai.bim.handler.refresh_ui_data() + + def update_global_tab(self: "BIMTabProperties", context: bpy.types.Context) -> None: tool.Blender.setup_tabs() screen = context.id_data @@ -537,7 +541,7 @@ class BIMTabProperties(PropertyGroup): class BIMTabVisibility(PropertyGroup): name: StringProperty(name="Tab Name") - is_visible: BoolProperty(name="Is Visible", default=True) + is_visible: BoolProperty(name="Is Visible", default=True, update=update_is_visible) if TYPE_CHECKING: name: str diff --git a/src/bonsai/bonsai/bim/ui.py b/src/bonsai/bonsai/bim/ui.py index ce53858871..a5eb91521b 100644 --- a/src/bonsai/bonsai/bim/ui.py +++ b/src/bonsai/bonsai/bim/ui.py @@ -36,18 +36,6 @@ import bonsai.bim import bonsai.tool as tool from ifcopenshell.util.file import IfcHeaderExtractor from bonsai.bim.prop import Attribute -from bonsai.bim.helper import ( - get_tab_names, - get_panel_tab_name, - should_show_panel, - get_tab_visibility, - set_tab_visibility, - get_panel_visibility, - get_panel_config, - get_all_tab_panels, - initialize_tab_visibilities, - initialize_panel_properties, -) from bonsai.bim.module.bsdd.prop import BIMBSDDProperties, BSDDProperty from bonsai.bim.module.pset.prop import IfcProperty from bonsai.bim.module.model.prop import ( @@ -998,41 +986,13 @@ class BIM_PT_tabs(Panel): is_ifc_project = bool(tool.Ifc.get()) aprops = tool.Blender.get_area_props(context) addon_prefs = tool.Blender.get_addon_preferences() - ifc_icon = f"{UIData.data['tabs_icon_color_mode']}_ifc" split = self.layout.split(factor=0.9) col_left = split.column(align=True) row_left = col_left.row(align=True) row_left.alignment = "CENTER" - if get_tab_visibility("PROJECT"): - row_left.operator( - "bim.set_tab", - text="", - emboss=aprops.tab == "PROJECT", - icon_value=bonsai.bim.icons[ifc_icon].icon_id, - ).tab = "PROJECT" - if get_tab_visibility("OBJECT"): - self.draw_tab_entry(row_left, "FILE_3D", "OBJECT", is_ifc_project, aprops.tab == "OBJECT") - if get_tab_visibility("GEOMETRY"): - self.draw_tab_entry(row_left, "MATERIAL", "GEOMETRY", is_ifc_project, aprops.tab == "GEOMETRY") - if get_tab_visibility("DRAWINGS"): - self.draw_tab_entry(row_left, "DOCUMENTS", "DRAWINGS", is_ifc_project, aprops.tab == "DRAWINGS") - if get_tab_visibility("SERVICES"): - self.draw_tab_entry(row_left, "NETWORK_DRIVE", "SERVICES", is_ifc_project, aprops.tab == "SERVICES") - if get_tab_visibility("STRUCTURE"): - self.draw_tab_entry(row_left, "EDITMODE_HLT", "STRUCTURE", is_ifc_project, aprops.tab == "STRUCTURE") - if get_tab_visibility("SCHEDULING"): - self.draw_tab_entry(row_left, "NLA", "SCHEDULING", is_ifc_project, aprops.tab == "SCHEDULING") - if get_tab_visibility("FM"): - self.draw_tab_entry(row_left, "PACKAGE", "FM", True, aprops.tab == "FM") - if get_tab_visibility("QUALITY"): - self.draw_tab_entry(row_left, "COMMUNITY", "QUALITY", True, aprops.tab == "QUALITY") - if ( - addon_prefs.save_metadata_blend_file - and addon_prefs.user_ui_customization - and get_tab_visibility("BOOKMARK") - ): - self.draw_tab_entry(row_left, "SOLO_ON", "BOOKMARK", True, aprops.tab == "BOOKMARK") + for tab in UIData.data["tabs"]: + self.draw_tab_entry(row_left, tab[1], tab[0], tab[2], aprops.tab == tab[0]) row_left.operator("bim.switch_tab", text="", emboss=False, icon="UV_SYNC_SELECT") row_left = col_left.row(align=True) @@ -1043,13 +1003,12 @@ class BIM_PT_tabs(Panel): if not (addon_prefs.save_metadata_blend_file and addon_prefs.user_ui_customization): row_left.prop(aprops, "inactive_tab", text="", icon="BLANK1", emboss=False) - for tab in get_tab_names(): + for tab in UIData.data["tabs"]: # Draw a little underscore below the active tab icon. - if get_tab_visibility(tab): - if aprops.tab == tab: - row_left.prop(aprops, "active_tab", text="", icon="BLANK1") - else: - row_left.prop(aprops, "inactive_tab", text="", icon="BLANK1", emboss=False) + if aprops.tab == tab: + row_left.prop(aprops, "active_tab", text="", icon="BLANK1") + else: + row_left.prop(aprops, "inactive_tab", text="", icon="BLANK1", emboss=False) row_left.prop(aprops, "inactive_tab", text="", icon="BLANK1", emboss=False) # space for Switch col_right = split.column(align=True) row_right = col_right.row(align=True) @@ -1062,10 +1021,9 @@ class BIM_PT_tabs(Panel): row.prop(aprops, "tab", text="") if addon_prefs.save_metadata_blend_file and addon_prefs.user_ui_customization: - for tab in get_tab_names(): - if get_tab_visibility(tab): - if aprops.tab == tab: - row.operator("bim.manage_tab_panels", text="", icon="PREFERENCES").tab_name = tab + for tab in UIData.data["tabs"]: + if aprops.tab == tab: + row.operator("bim.manage_tab_panels", text="", icon="PREFERENCES").tab_name = tab if bonsai.REINSTALLED_BBIM_VERSION: box = self.layout.box() @@ -1132,7 +1090,10 @@ class BIM_PT_tabs(Panel): def draw_tab_entry(self, row, icon, tab_name, enabled=True, highlight=True): tab_entry = row.row(align=True) - tab_entry.operator("bim.set_tab", text="", emboss=highlight, icon=icon).tab = tab_name + if isinstance(icon, int): + tab_entry.operator("bim.set_tab", text="", emboss=highlight, icon_value=icon).tab = tab_name + else: + tab_entry.operator("bim.set_tab", text="", emboss=highlight, icon=icon).tab = tab_name tab_entry.enabled = enabled @@ -1860,8 +1821,8 @@ class UIData: def load(cls): cls.data = { "version": cls.version(), - "tabs_icon_color_mode": cls.icon_color_mode("user_interface.wcol_regular.text"), "menu_icon_color_mode": cls.icon_color_mode("user_interface.wcol_menu.text"), + "tabs": cls.tabs(), } cls.is_loaded = True @@ -1873,6 +1834,28 @@ class UIData: def icon_color_mode(cls, color_path): return tool.Blender.detect_icon_color_mode(color_path) + @classmethod + def tabs(cls): + hidden_tabs = [t.name for t in tool.Blender.get_bim_props().tab_visibilities if not t.is_visible] + color_mode = cls.icon_color_mode("user_interface.wcol_regular.text") + is_ifc_project = bool(tool.Ifc.get()) + return [ + tab + for tab in [ + ("PROJECT", bonsai.bim.icons[f"{color_mode}_ifc"].icon_id, True), + ("OBJECT", "FILE_3D", is_ifc_project), + ("GEOMETRY", "MATERIAL", is_ifc_project), + ("DRAWINGS", "DOCUMENTS", is_ifc_project), + ("SERVICES", "NETWORK_DRIVE", is_ifc_project), + ("STRUCTURE", "EDITMODE_HLT", is_ifc_project), + ("SCHEDULING", "NLA", is_ifc_project), + ("FM", "PACKAGE", True), + ("QUALITY", "COMMUNITY", True), + ("BOOKMARK", "SOLO_ON", is_ifc_project), + ] + if tab[0] not in hidden_tabs + ] + def draw_statusbar(self, context): if not UIData.is_loaded: From ed81a0a4b3a3a637a82808b13aca4c992380beee Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Christoph=20Mell=C3=BCh?= <74136980+c-mellueh@users.noreply.github.com> Date: Thu, 15 Jan 2026 10:52:06 +0100 Subject: [PATCH 45/49] update api to V5 (#7409) --- src/bsdd/bsdd.py | 10 ++++------ 1 file changed, 4 insertions(+), 6 deletions(-) diff --git a/src/bsdd/bsdd.py b/src/bsdd/bsdd.py index 17480c87f5..87154855ae 100644 --- a/src/bsdd/bsdd.py +++ b/src/bsdd/bsdd.py @@ -184,7 +184,7 @@ class ClassPropertyContractV1(TypedDict): qudtCodes: NotRequired[list[str]] -class PropertyContractV4(TypedDict): +class PropertyContractV5(TypedDict): dictionaryUri: NotRequired[str] activationDateUtc: str code: str @@ -710,16 +710,14 @@ class Client: params = {k: v for k, v in params.items() if v is not None} return self.get(endpoint, params) - def get_property(self, uri, include_classes=False, language_code="", version: int = 4) -> PropertyContractV4: + def get_property(self, uri, language_code="", version: int = 5) -> PropertyContractV5: """ - Get Property Detail - this API replaces Property + Get Property details. + If you also need the list of classes using the property, then use api/Property/Classes """ - endpoint = f"Property/v{version}" params = { "uri": uri, - "includeClasses": include_classes, "LanguageCode": language_code, } return self.get(endpoint, params) From 47a2c1d21abfb0536dada48e0a268481d3ddf457 Mon Sep 17 00:00:00 2001 From: Dion Moult Date: Thu, 15 Jan 2026 21:59:53 +1100 Subject: [PATCH 46/49] Refactor panel visibility to not use any helpers, operators, and shift config UI into add-on settings To be consistent with all other settings, I've moved the visibility config UI from inline into the add-on settings. This restores the previous tab layout and no longer needs the "settings" icons to be there. This also removes the need for a "enable UI config" checkbox. Most of the code previously had dedicated operators to toggle booleans. This has been removed. This new approach also means helpers aren't needed. --- src/bonsai/bonsai/bim/__init__.py | 17 +- src/bonsai/bonsai/bim/helper.py | 113 -------------- src/bonsai/bonsai/bim/operator.py | 247 +++--------------------------- src/bonsai/bonsai/bim/prop.py | 24 +-- src/bonsai/bonsai/bim/ui.py | 103 ++++++++----- src/bonsai/bonsai/tool/blender.py | 6 +- 6 files changed, 107 insertions(+), 403 deletions(-) diff --git a/src/bonsai/bonsai/bim/__init__.py b/src/bonsai/bonsai/bim/__init__.py index 785d7eac66..a7dc729881 100644 --- a/src/bonsai/bonsai/bim/__init__.py +++ b/src/bonsai/bonsai/bim/__init__.py @@ -130,12 +130,7 @@ classes = [ prop.StrProperty, operator.BIM_OT_enum_property_search, # /!\ Register AFTER prop.StrProperty operator.BIM_OT_attribute_search_values, - operator.BIM_UL_tab_panels, - operator.BIM_OT_toggle_panel_visibility, - operator.BIM_OT_bookmark_panel, - operator.BIM_OT_manage_tab_panels, operator.BIM_OT_manage_tab_visibility, - operator.BIM_OT_toggle_tab_visibility, operator.BIM_OT_reset_ui_layout, prop.ObjProperty, prop.MultipleFileSelect, @@ -144,7 +139,7 @@ classes = [ prop.BIMAreaProperties, prop.BIMTabProperties, prop.BIMTabVisibility, # Must be registered before BIMProperties - prop.BIMPanelProperties, # Must be registered before BIMProperties + prop.BIMPanelVisibility, # Must be registered before BIMProperties prop.BIMProperties, prop.IfcParameter, prop.PsetQto, @@ -157,6 +152,8 @@ classes = [ prop.BIMSnapGroups, ui.BIM_UL_clipping_plane, ui.BIM_UL_generic, + ui.BIM_UL_tab_visibilities, + ui.BIM_UL_panel_visibilities, ui.DocPreferences, ui.GizmoPreferencesDoor, # Register before GizmoPreferences ui.GizmoPreferencesWindow, # Register before GizmoPreferences @@ -318,10 +315,6 @@ def register(): # RestrictedContext doesn't allow accessing scene attribute, postpone it for a bit. bpy.app.timers.register(tool.Blender.setup_user_data_dir, first_interval=0.1) - bpy.types.Scene.active_tab_name = bpy.props.StringProperty() - bpy.types.Scene.tab_panels = bpy.props.CollectionProperty(type=bpy.types.PropertyGroup) - bpy.types.Scene.active_tab_panel_index = bpy.props.IntProperty() - def unregister(): global icons @@ -363,7 +356,3 @@ def unregister(): tool.Blender.remove_scene_panel_override(panel) bpy.app.translations.unregister("bonsai") - - del bpy.types.Scene.active_tab_name - del bpy.types.Scene.tab_panels - del bpy.types.Scene.active_tab_panel_index diff --git a/src/bonsai/bonsai/bim/helper.py b/src/bonsai/bonsai/bim/helper.py index aed0e09ca4..1a92078be3 100644 --- a/src/bonsai/bonsai/bim/helper.py +++ b/src/bonsai/bonsai/bim/helper.py @@ -792,116 +792,3 @@ def draw_filter( op.group_index = i op.index = j op.module = module - - -# ============================================================================ -# UI Panel Visibility Helpers -# ============================================================================ - - -def get_tab_names(): - from bonsai.bim.prop import get_tab - - enum_items = get_tab(None, None) - # Exclude None separators and the BLENDER tab (not part of BIM tab system) - return [item[0] for item in enum_items if item is not None and item[0] != "BLENDER"] - - -def get_panel_tab_name(panel_class): - if hasattr(panel_class, "bim_tab_name"): - return panel_class.bim_tab_name - return "PROJECT" # Default fallback - - -def get_tab_visibility(tab_name): - bim_props = tool.Blender.get_bim_props() - tab_vis = bim_props.tab_visibilities.get(tab_name) - return tab_vis.is_visible if tab_vis else True - - -def set_tab_visibility(tab_name, visible): - bim_props = tool.Blender.get_bim_props() - tab_vis = bim_props.tab_visibilities.get(tab_name) - if tab_vis: - tab_vis.is_visible = visible - else: - new_tab = bim_props.tab_visibilities.add() - new_tab.name = tab_name - new_tab.is_visible = visible - - -def get_panel_config(panel_id, create_if_missing=False): - try: - bim_props = tool.Blender.get_bim_props() - except (AttributeError, AssertionError): - return None - - for prop in bim_props.panel_properties: - if prop.name == panel_id: - return prop - - if create_if_missing: - try: - prop = bim_props.panel_properties.add() - prop.name = panel_id - return prop - except AttributeError: - pass - - return None - - -def get_all_tab_panels(force_refresh=False): - panels = {tab_name: [] for tab_name in get_tab_names() if tab_name != "BOOKMARK"} - panels["BOOKMARK"] = [] - - bim_props = tool.Blender.get_bim_props() - for prop in bim_props.panel_properties: - panel_class = getattr(bpy.types, prop.name, None) - if panel_class: - tab_name = get_panel_tab_name(panel_class) - if tab_name and tab_name != "BOOKMARK": - bl_label = getattr(panel_class, "bl_label", prop.name) - panels[tab_name].append({"bl_idname": prop.name, "bl_label": bl_label}) - - if prop.is_bookmarked: - panel_class = getattr(bpy.types, prop.name, None) - if panel_class: - bl_label = getattr(panel_class, "bl_label", prop.name) - panels["BOOKMARK"].append({"bl_idname": prop.name, "bl_label": bl_label}) - - if not panels["BOOKMARK"]: - panels["BOOKMARK"] = [{}] - - return panels - - -def initialize_tab_visibilities(): - bim_props = tool.Blender.get_bim_props() - - if len(bim_props.tab_visibilities) > 0: - return - - for tab_name in get_tab_names(): - tab_vis = bim_props.tab_visibilities.add() - tab_vis.name = tab_name - tab_vis.is_visible = True - - -def initialize_panel_properties(): - - bim_props = tool.Blender.get_bim_props() - - if len(bim_props.panel_properties) > 0: - return - - for attr_name in dir(bpy.types): - if attr_name.startswith("BIM_PT_tab_"): - panel_class = getattr(bpy.types, attr_name) - if not hasattr(panel_class, "bl_idname"): - continue - - panel_id = panel_class.bl_idname - - prop = bim_props.panel_properties.add() - prop.name = panel_id diff --git a/src/bonsai/bonsai/bim/operator.py b/src/bonsai/bonsai/bim/operator.py index 8cfc3fb0d4..3621344542 100644 --- a/src/bonsai/bonsai/bim/operator.py +++ b/src/bonsai/bonsai/bim/operator.py @@ -33,15 +33,6 @@ import bonsai.bim import bonsai.tool as tool import bonsai.bim.handler from enum import Enum -from bonsai.bim.helper import ( - get_all_tab_panels, - get_tab_visibility, - set_tab_visibility, - get_tab_names, - get_panel_config, - initialize_panel_properties, - initialize_tab_visibilities, -) from bpy_extras.io_utils import ImportHelper from bonsai.bim import import_ifc from bonsai.bim.prop import StrProperty @@ -1735,156 +1726,6 @@ class BIM_OT_attribute_remove_subitem(bpy.types.Operator): return {"FINISHED"} -class BIM_UL_tab_panels(bpy.types.UIList): - """UIList for Tab Panels""" - - def draw_item(self, context, layout, data, item, icon, active_data, active_propname, index): - row = layout.row(align=True) - row.label(text=item["bl_label"]) - - row.operator( - "bim.toggle_panel_visibility", - text="", - icon="HIDE_OFF" if item.get("visible", True) else "HIDE_ON", - ).action = f"TOGGLE_VISIBILITY_{item.name}" - - row.operator( - "bim.bookmark_panel", - text="", - icon="SOLO_ON" if item.get("bookmarked", False) else "SOLO_OFF", - ).action = f"BOOKMARK_{item.name}" - - -class BIM_OT_toggle_panel_visibility(bpy.types.Operator): - """Toggle Panel Visibility""" - - bl_idname = "bim.toggle_panel_visibility" - bl_label = "Toggle Panel Visibility" - bl_options = {"REGISTER", "UNDO"} - - action: bpy.props.StringProperty() - - def execute(self, context): - panel_name = self.action.replace("TOGGLE_VISIBILITY_", "") - active_tab = getattr(context.scene, "active_tab_name", None) or getattr( - tool.Blender.get_bim_props(), "tab", None - ) - is_bookmark_tab = active_tab == "BOOKMARK" - - panel_config = get_panel_config(panel_name, create_if_missing=True) - if panel_config: - if is_bookmark_tab: - panel_config.is_visible_in_bookmarks = not panel_config.is_visible_in_bookmarks - new_value = panel_config.is_visible_in_bookmarks - else: - panel_config.is_visible_in_tab = not panel_config.is_visible_in_tab - new_value = panel_config.is_visible_in_tab - - for item in context.scene.tab_panels: - if item.name == panel_name: - item["visible"] = new_value - break - - for area in bpy.context.window.screen.areas: - if area.type == "PROPERTIES": - area.tag_redraw() - - tab_context = "Bookmarks" if is_bookmark_tab else "Tab" - self.report({"INFO"}, f"Toggled visibility for {panel_name} in {tab_context}.") - return {"FINISHED"} - - -class BIM_OT_bookmark_panel(bpy.types.Operator): - """Bookmark Panel""" - - bl_idname = "bim.bookmark_panel" - bl_label = "Bookmark Panel" - bl_options = {"REGISTER", "UNDO"} - - action: bpy.props.StringProperty() - - def execute(self, context): - panel_name = self.action.replace("BOOKMARK_", "") - panel_config = get_panel_config(panel_name, create_if_missing=True) - - if panel_config: - panel_config.is_bookmarked = not panel_config.is_bookmarked - - for item in context.scene.tab_panels: - if item.name == panel_name: - item["bookmarked"] = panel_config.is_bookmarked - break - - for area in bpy.context.window.screen.areas: - if area.type == "PROPERTIES": - area.tag_redraw() - - self.report({"INFO"}, f"Toggled bookmark for {panel_name}.") - return {"FINISHED"} - - -class BIM_OT_manage_tab_panels(bpy.types.Operator): - """Manage Tab Panels""" - - bl_idname = "bim.manage_tab_panels" - bl_label = "Manage Tab Panels" - bl_options = {"REGISTER", "UNDO"} - - tab_name: bpy.props.StringProperty() - - def invoke(self, context, event): - - context.scene.active_tab_name = self.tab_name - context.scene.tab_panels.clear() - - initialize_tab_visibilities() - initialize_panel_properties() - all_panels = get_all_tab_panels(force_refresh=True) - - for panel_data in all_panels.get(self.tab_name, []): - panel_name = panel_data.get("bl_idname", "") - panel_label = panel_data.get("bl_label", "") - if not panel_name or not panel_label: - continue - - item = context.scene.tab_panels.add() - item.name = panel_name - item["bl_label"] = panel_label - - panel_config = get_panel_config(panel_name, create_if_missing=True) - if panel_config: - if self.tab_name == "BOOKMARK": - item["visible"] = panel_config.is_visible_in_bookmarks - else: - item["visible"] = panel_config.is_visible_in_tab - item["bookmarked"] = panel_config.is_bookmarked - else: - item["visible"] = True - item["bookmarked"] = False - - return context.window_manager.invoke_popup(self) - - def draw(self, context): - layout = self.layout - layout.label(text=f"Manage Panels for {self.tab_name} Tab") - - row = layout.row() - row.template_list("BIM_UL_tab_panels", "", context.scene, "tab_panels", context.scene, "active_tab_panel_index") - - def execute(self, context): - for item in context.scene.tab_panels: - panel_config = get_panel_config(item.name, create_if_missing=True) - if panel_config: - if self.tab_name == "BOOKMARK": - panel_config.is_visible_in_bookmarks = item["visible"] - else: - panel_config.is_visible_in_tab = item["visible"] - panel_config.is_bookmarked = item["bookmarked"] - - self.report({"INFO"}, f"Panels for {self.tab_name} managed successfully.") - return {"FINISHED"} - - class BIM_OT_manage_tab_visibility(bpy.types.Operator): """Manage Tab Visibility""" @@ -1892,51 +1733,26 @@ class BIM_OT_manage_tab_visibility(bpy.types.Operator): bl_label = "Manage Tab Visibility" bl_options = {"REGISTER", "UNDO"} - def draw(self, context): - layout = self.layout - row = layout.row() - row = self.layout.row(align=True) - row.alignment = "RIGHT" - - row.operator("bim.reset_ui_layout", icon="FILE_REFRESH", text="") - row = layout.row() - row = self.layout.row(align=True) - row.alignment = "CENTER" - - for tab_name in get_tab_names(): - row = layout.row() - row.label(text=tab_name) - is_visible = get_tab_visibility(tab_name) - icon = "HIDE_OFF" if is_visible else "HIDE_ON" - op = row.operator("bim.toggle_tab_visibility", text="", icon=icon) - op.tab_name = tab_name - def execute(self, context): - return {"FINISHED"} + from bonsai.bim.prop import get_tab - def invoke(self, context, event): - return context.window_manager.invoke_popup(self) + bprops = tool.Blender.get_bim_props() + bprops.tab_visibilities.clear() + bprops.panel_visibilities.clear() + tabs = [item[0] for item in get_tab(None, None) if item and item[0] != "BLENDER"] + for tab in tabs: + new = bprops.tab_visibilities.add() + new.name = tab - -class BIM_OT_toggle_tab_visibility(bpy.types.Operator): - """Toggle Tab Visibility""" - - bl_idname = "bim.toggle_tab_visibility" - bl_label = "Toggle Tab Visibility" - bl_options = {"REGISTER", "UNDO"} - - tab_name: bpy.props.StringProperty() - - def execute(self, context): - if self.tab_name in get_tab_names(): - current_visibility = get_tab_visibility(self.tab_name) - set_tab_visibility(self.tab_name, not current_visibility) - - for area in bpy.context.window.screen.areas: - if area.type == "PROPERTIES": - area.tag_redraw() - - self.report({"INFO"}, f"Toggled visibility for {self.tab_name}.") + for attr_name in dir(bpy.types): + if attr_name.startswith("BIM_PT_tab_"): + panel_class = getattr(bpy.types, attr_name) + if not hasattr(panel_class, "bl_idname"): + assert False, panel_class + new = bprops.panel_visibilities.add() + new.name = panel_class.bl_idname + new.label = panel_class.bl_label + new.tab_name = panel_class.bim_tab_name return {"FINISHED"} @@ -1948,29 +1764,8 @@ class BIM_OT_reset_ui_layout(bpy.types.Operator): bl_options = {"REGISTER", "UNDO"} def execute(self, context): - - for tab_name in get_tab_names(): - set_tab_visibility(tab_name, True) - - get_all_tab_panels()["BOOKMARK"] = [{}] - - for tab_name, panels in get_all_tab_panels().items(): - for panel in panels: - panel_name = panel.get("bl_idname", "") - if not panel_name: - continue - - show_prop_name = f"show_{panel_name.lower()}" - if hasattr(context.scene, show_prop_name): - setattr(context.scene, show_prop_name, True) - - bookmark_prop_name = f"bookmark_{panel_name.lower()}" - if hasattr(context.scene, bookmark_prop_name): - setattr(context.scene, bookmark_prop_name, False) - - for area in bpy.context.window.screen.areas: - if area.type == "PROPERTIES": - area.tag_redraw() - - self.report({"INFO"}, "UI layout reset to default.") + bprops = tool.Blender.get_bim_props() + bprops.tab_visibilities.clear() + bprops.panel_visibilities.clear() + bonsai.bim.handler.refresh_ui_data() return {"FINISHED"} diff --git a/src/bonsai/bonsai/bim/prop.py b/src/bonsai/bonsai/bim/prop.py index 0b513882c6..ec8154b167 100644 --- a/src/bonsai/bonsai/bim/prop.py +++ b/src/bonsai/bonsai/bim/prop.py @@ -548,14 +548,17 @@ class BIMTabVisibility(PropertyGroup): is_visible: bool -class BIMPanelProperties(PropertyGroup): - is_visible_in_tab: BoolProperty(name="Is Visible in Tab", default=True) - is_visible_in_bookmarks: BoolProperty(name="Is Visible in Bookmarks", default=True) - is_bookmarked: BoolProperty(name="Is Bookmarked", default=False) +class BIMPanelVisibility(PropertyGroup): + name: StringProperty(name="Name") + label: StringProperty(name="Label") + tab_name: StringProperty(name="Tab Name") + is_visible: BoolProperty(name="Is Visible in Tab", default=True, update=update_is_visible) + is_bookmarked: BoolProperty(name="Is Bookmarked", default=False, update=update_is_visible) if TYPE_CHECKING: - is_visible_in_tab: bool - is_visible_in_bookmarks: bool + name: str + tab_name: str + is_visible: bool is_bookmarked: bool @@ -632,7 +635,6 @@ class BIMProperties(PropertyGroup): name="Mass Unit", default="KILOGRAM", ) - time_unit: EnumProperty( items=[ ("SECOND", "Second", "Seconds"), @@ -644,7 +646,9 @@ class BIMProperties(PropertyGroup): default="HOUR", ) tab_visibilities: CollectionProperty(type=BIMTabVisibility, name="Tab Visibilities") - panel_properties: CollectionProperty(type=BIMPanelProperties, name="Panel Properties") + active_tab_visibility_index: IntProperty(name="Active Tab Visibility Index") + panel_visibilities: CollectionProperty(type=BIMPanelVisibility, name="Panel Properties") + active_panel_visibility_index: IntProperty(name="Active Panel Property Index") if TYPE_CHECKING: is_dirty: bool @@ -661,7 +665,9 @@ class BIMProperties(PropertyGroup): mass_unit: str time_unit: str tab_visibilities: bpy.types.bpy_prop_collection_idprop[BIMTabVisibility] - panel_properties: bpy.types.bpy_prop_collection_idprop[BIMPanelProperties] + active_tab_visibility_index: int + panel_visibilities: bpy.types.bpy_prop_collection_idprop[BIMPanelVisibility] + active_panel_visibility_index: int class IfcParameter(PropertyGroup): diff --git a/src/bonsai/bonsai/bim/ui.py b/src/bonsai/bonsai/bim/ui.py index a5eb91521b..c550c00872 100644 --- a/src/bonsai/bonsai/bim/ui.py +++ b/src/bonsai/bonsai/bim/ui.py @@ -242,6 +242,39 @@ class BIM_UL_generic(bpy.types.UIList): layout.label(text="", translate=False) +class BIM_UL_tab_visibilities(bpy.types.UIList): + def draw_item( + self, + context, + layout: bpy.types.UILayout, + data: bpy.types.PropertyGroup, + item: bpy.types.PropertyGroup, + icon, + active_data, + active_propname, + ) -> None: + row = layout.row() + row.prop(item, "name", text="", emboss=False) + row.prop(item, "is_visible", text="", icon="HIDE_OFF" if item.is_visible else "HIDE_ON", emboss=False) + + +class BIM_UL_panel_visibilities(bpy.types.UIList): + def draw_item( + self, + context, + layout: bpy.types.UILayout, + data: bpy.types.PropertyGroup, + item: bpy.types.PropertyGroup, + icon, + active_data, + active_propname, + ) -> None: + row = layout.row() + row.prop(item, "label", text="", emboss=False) + row.prop(item, "is_visible", text="", icon="HIDE_OFF" if item.is_visible else "HIDE_ON", emboss=False) + row.prop(item, "is_bookmarked", text="", icon="SOLO_ON" if item.is_bookmarked else "SOLO_OFF", emboss=False) + + class GizmoPreferencesDoor(bpy.types.PropertyGroup): """Property group for door gizmo visibility settings.""" @@ -711,11 +744,6 @@ class BIM_ADDON_preferences(bpy.types.AddonPreferences): description="Custom suffix for the metadata blend file. Will be appended to the filename (without .ifc).", default=".ifc.metadata.blend", ) - user_ui_customization: BoolProperty( - name="User UI Customization", - description="Enable user interface customization features (hide/show tabs and panels, bookmark panels) and save the session settings as part of the metadata blend file", - default=False, - ) if TYPE_CHECKING: svg2pdf_command: str @@ -757,7 +785,6 @@ class BIM_ADDON_preferences(bpy.types.AddonPreferences): chain_filter_with_set_operations: bool default_filter_with_set_operations_for_globalid_and_class: bool save_metadata_blend_file: bool - user_ui_customization: bool def draw(self, context: bpy.types.Context) -> None: layout = self.layout @@ -965,9 +992,26 @@ class BIM_ADDON_preferences(bpy.types.AddonPreferences): row = layout.row() row.separator() row.prop(self, "metadata_blend_file_suffix") - row = layout.row() - row.separator() - row.prop(self, "user_ui_customization") + + bprops = tool.Blender.get_bim_props() + if tab_visibilities := bprops.tab_visibilities: + row = layout.row() + row.operator("bim.reset_ui_layout", icon="LOOP_BACK") + row = layout.row(align=True) + row.template_list( + "BIM_UL_tab_visibilities", "", bprops, "tab_visibilities", bprops, "active_tab_visibility_index" + ) + row.template_list( + "BIM_UL_panel_visibilities", + "", + bprops, + "panel_visibilities", + bprops, + "active_panel_visibility_index", + ) + else: + row = layout.row() + row.operator("bim.manage_tab_visibility", icon="PREFERENCES") # Scene panel groups @@ -983,48 +1027,31 @@ class BIM_PT_tabs(Panel): def draw(self, context): if not UIData.is_loaded: UIData.load() - is_ifc_project = bool(tool.Ifc.get()) aprops = tool.Blender.get_area_props(context) addon_prefs = tool.Blender.get_addon_preferences() - split = self.layout.split(factor=0.9) - col_left = split.column(align=True) - row_left = col_left.row(align=True) - row_left.alignment = "CENTER" + row = self.layout.row() + row.alignment = "CENTER" for tab in UIData.data["tabs"]: - self.draw_tab_entry(row_left, tab[1], tab[0], tab[2], aprops.tab == tab[0]) - row_left.operator("bim.switch_tab", text="", emboss=False, icon="UV_SYNC_SELECT") + self.draw_tab_entry(row, tab[1], tab[0], tab[2], aprops.tab == tab[0]) + row.operator("bim.switch_tab", text="", emboss=False, icon="UV_SYNC_SELECT") - row_left = col_left.row(align=True) + row = self.layout.row() # Yes, that's right. - row_left.alignment = "CENTER" - row_left.scale_y = 0.2 - - if not (addon_prefs.save_metadata_blend_file and addon_prefs.user_ui_customization): - row_left.prop(aprops, "inactive_tab", text="", icon="BLANK1", emboss=False) + row.alignment = "CENTER" + row.scale_y = 0.2 for tab in UIData.data["tabs"]: # Draw a little underscore below the active tab icon. - if aprops.tab == tab: - row_left.prop(aprops, "active_tab", text="", icon="BLANK1") + if aprops.tab == tab[0]: + row.prop(aprops, "active_tab", text="", icon="BLANK1") else: - row_left.prop(aprops, "inactive_tab", text="", icon="BLANK1", emboss=False) - row_left.prop(aprops, "inactive_tab", text="", icon="BLANK1", emboss=False) # space for Switch - col_right = split.column(align=True) - row_right = col_right.row(align=True) - row_right.alignment = "RIGHT" + row.prop(aprops, "inactive_tab", text="", icon="BLANK1", emboss=False) + row.prop(aprops, "inactive_tab", text="", icon="BLANK1", emboss=False) # space for Switch - if addon_prefs.save_metadata_blend_file and addon_prefs.user_ui_customization: - row_right.operator("bim.manage_tab_visibility", icon="PREFERENCES", text="") - - row = self.layout.row(align=True) + row = self.layout.row() row.prop(aprops, "tab", text="") - if addon_prefs.save_metadata_blend_file and addon_prefs.user_ui_customization: - for tab in UIData.data["tabs"]: - if aprops.tab == tab: - row.operator("bim.manage_tab_panels", text="", icon="PREFERENCES").tab_name = tab - if bonsai.REINSTALLED_BBIM_VERSION: box = self.layout.box() box.alert = True diff --git a/src/bonsai/bonsai/tool/blender.py b/src/bonsai/bonsai/tool/blender.py index 2f6190c448..24cd5071fa 100644 --- a/src/bonsai/bonsai/tool/blender.py +++ b/src/bonsai/bonsai/tool/blender.py @@ -167,12 +167,12 @@ class Blender(bonsai.core.tool.Blender): return True if (is_bookmark_tab := aprops.tab == "BOOKMARK") or aprops.tab == tab: bprops = tool.Blender.get_bim_props() - if not (panel_visibility := bprops.panel_properties.get(panel)): + if not (panel_visibility := bprops.panel_visibilities.get(panel)): return not is_bookmark_tab if is_bookmark_tab: - if panel_visibility.is_bookmarked and panel_visibility.is_visible_in_bookmarks: + if panel_visibility.is_bookmarked: return True - elif panel_visibility.is_visible_in_tab: + elif panel_visibility.is_visible: return True @classmethod From 8060c790ed2018e02b1b99eb1f1d5348cb2d56f0 Mon Sep 17 00:00:00 2001 From: Dion Moult Date: Thu, 15 Jan 2026 22:00:51 +1100 Subject: [PATCH 47/49] Black --- src/bonsai/bonsai/bim/module/geometry/operator.py | 8 ++++---- src/bonsai/bonsai/bim/module/model/wall.py | 12 +++++++----- src/bonsai/bonsai/bim/module/spatial/operator.py | 14 +++++++------- 3 files changed, 18 insertions(+), 16 deletions(-) diff --git a/src/bonsai/bonsai/bim/module/geometry/operator.py b/src/bonsai/bonsai/bim/module/geometry/operator.py index 4927c9cfab..a35a4de7bc 100644 --- a/src/bonsai/bonsai/bim/module/geometry/operator.py +++ b/src/bonsai/bonsai/bim/module/geometry/operator.py @@ -1723,11 +1723,11 @@ class RefreshLinkedAggregate(bpy.types.Operator, tool.Ifc.Operator): container=container, element_obj=obj, ) - + # Get the container's collection for moving parts in the outliner container_obj = tool.Ifc.get_object(container) container_collection = container_obj.BIMObjectProperties.collection if container_obj else None - + # Move all parts to the container's collection in the outliner if container_collection: for part in ifcopenshell.util.element.get_parts(element): @@ -1736,11 +1736,11 @@ class RefreshLinkedAggregate(bpy.types.Operator, tool.Ifc.Operator): # Remove from all previous collections for col in part_obj.users_collection[:]: col.objects.unlink(part_obj) - + # Link to container collection if part_obj.name not in container_collection.objects: container_collection.objects.link(part_obj) - + # Recursively handle nested parts for nested_part in ifcopenshell.util.element.get_parts(part): nested_part_obj = tool.Ifc.get_object(nested_part) diff --git a/src/bonsai/bonsai/bim/module/model/wall.py b/src/bonsai/bonsai/bim/module/model/wall.py index 0d0b88339e..d652041125 100644 --- a/src/bonsai/bonsai/bim/module/model/wall.py +++ b/src/bonsai/bonsai/bim/module/model/wall.py @@ -575,20 +575,22 @@ class ChangeExtrusionXAngle(bpy.types.Operator, tool.Ifc.Operator): rotation_axis.normalize() dot_product = expected_new_world_direction.dot(current_world_direction) angle = acos(min(max(dot_product, -1), 1)) - + # Rotate around object's own origin # Decompose the matrix to get translation, rotation, scale translation, rotation, scale = obj.matrix_world.decompose() - + # Create rotation matrix and convert to quaternion rotation_matrix = Matrix.Rotation(angle, 4, rotation_axis) rotation_quat = rotation_matrix.to_quaternion() - + # Apply rotation to existing rotation (quaternion multiplication) new_rotation = rotation_quat @ rotation - + # Reconstruct matrix_world with same translation, new rotation, same scale - obj.matrix_world = Matrix.Translation(translation) @ new_rotation.to_matrix().to_4x4() @ Matrix.Scale(1, 4) + obj.matrix_world = ( + Matrix.Translation(translation) @ new_rotation.to_matrix().to_4x4() @ Matrix.Scale(1, 4) + ) bpy.context.view_layer.update() bonsai.core.geometry.switch_representation( diff --git a/src/bonsai/bonsai/bim/module/spatial/operator.py b/src/bonsai/bonsai/bim/module/spatial/operator.py index 67189666e6..075c3a9a38 100644 --- a/src/bonsai/bonsai/bim/module/spatial/operator.py +++ b/src/bonsai/bonsai/bim/module/spatial/operator.py @@ -183,7 +183,7 @@ class AssignContainer(bpy.types.Operator, tool.Ifc.Operator): root = aggregate current = aggregate return root - + def get_all_parts_recursive(element): """Recursively get all parts of an aggregate""" parts = [] @@ -196,17 +196,17 @@ class AssignContainer(bpy.types.Operator, tool.Ifc.Operator): objs: list[bpy.types.Object] = [] processed_elements = set() # Track elements we've already handled (by IFC ID) promoted_parts = 0 # Count how many parts were promoted to their root aggregate - + for obj in tool.Blender.get_selected_objects(): if not (element := tool.Ifc.get_entity(obj)): continue - + # Check if element is part of an aggregate (at any level) if root_aggregate := get_root_aggregate(element): # Skip if we've already processed this root aggregate if root_aggregate.id() in processed_elements: continue - + # Get the root aggregate object and add it instead if root_aggregate_obj := tool.Ifc.get_object(root_aggregate): objs.append(root_aggregate_obj) @@ -225,13 +225,13 @@ class AssignContainer(bpy.types.Operator, tool.Ifc.Operator): for element_obj in objs: element = tool.Ifc.get_entity(element_obj) - + # Only assign container to the ROOT aggregate (this updates IFC relationships) if self.remove_from_other_containers: for col in element_obj.users_collection[:]: col.objects.unlink(element_obj) core.assign_container(tool.Ifc, tool.Collector, tool.Spatial, container=container, element_obj=element_obj) - + # For parts, only move them in Blender collections (don't change IFC relationships) if container_collection: all_parts = get_all_parts_recursive(element) @@ -240,7 +240,7 @@ class AssignContainer(bpy.types.Operator, tool.Ifc.Operator): # Always remove from ALL previous collections when moving to new container for col in part_obj.users_collection[:]: col.objects.unlink(part_obj) - + # Link to new container collection (Blender-only, no IFC change) if part_obj.name not in container_collection.objects: container_collection.objects.link(part_obj) From d79524b087fb7e4099584f4cbebe3e8e8eb46457 Mon Sep 17 00:00:00 2001 From: Ryan Schultz Date: Thu, 15 Jan 2026 14:21:39 -0600 Subject: [PATCH 48/49] fix #7559: DirectionSense works again. --- src/bonsai/bonsai/bim/module/model/slab.py | 88 +++++----------- src/bonsai/bonsai/tool/loader.py | 111 +++++++++++++++++---- 2 files changed, 117 insertions(+), 82 deletions(-) diff --git a/src/bonsai/bonsai/bim/module/model/slab.py b/src/bonsai/bonsai/bim/module/model/slab.py index 2b2c3a2984..f5765331b6 100644 --- a/src/bonsai/bonsai/bim/module/model/slab.py +++ b/src/bonsai/bonsai/bim/module/model/slab.py @@ -225,16 +225,17 @@ class DumbSlabPlaner: for inverse in tool.Ifc.get().get_inverse(layer_set): if not inverse.is_a("IfcMaterialLayerSetUsage") or inverse.LayerSetDirection != "AXIS3": continue + if tool.Ifc.get().schema == "IFC2X3": for rel in tool.Ifc.get().get_inverse(inverse): if not rel.is_a("IfcRelAssociatesMaterial"): continue for element in rel.RelatedObjects: - self.change_thickness(element, total_thickness) + self.change_thickness(element, total_thickness, preserve_offset=True) else: for rel in inverse.AssociatedTo: for element in rel.RelatedObjects: - self.change_thickness(element, total_thickness) + self.change_thickness(element, total_thickness, preserve_offset=True) def regenerate_from_type(self, usecase_path, ifc_file, settings): relating_type = settings["relating_type"] @@ -276,9 +277,10 @@ class DumbSlabPlaner: return self.change_thickness(element, total_thickness) - def change_thickness(self, element: ifcopenshell.entity_instance, thickness: float) -> None: + def change_thickness(self, element: ifcopenshell.entity_instance, thickness: float, preserve_offset: bool = False) -> None: if tool.Model.get_usage_type(element) != "LAYER3": return + layer_params = tool.Model.get_material_layer_parameters(element) ifc_file = tool.Ifc.get() body_context = ifcopenshell.util.representation.get_context(tool.Ifc.get(), "Model", "Body", "MODEL_VIEW") @@ -297,86 +299,47 @@ class DumbSlabPlaner: extrusion = tool.Model.get_extrusion(representation) if extrusion: direction_ratios = Vector(extrusion.ExtrudedDirection.DirectionRatios) - + # Calculate the actual extrusion angle from vertical extrusion_angle = 0 if direction_ratios.length > 0: cos_angle = direction_ratios.normalized().dot(Vector((0, 0, 1))) extrusion_angle = acos(min(max(cos_angle, -1), 1)) - # FIX: Only apply 1/cos factor when there's actual extrusion slope + # Only apply 1/cos factor when there's actual extrusion slope if extrusion_angle > 1e-6: perpendicular_depth = thickness * abs(1 / cos(extrusion_angle)) - perpendicular_offset = layer_offset * abs(1 / cos(extrusion_angle)) / self.unit_scale + perpendicular_offset = layer_offset * abs(1 / cos(extrusion_angle)) else: perpendicular_depth = thickness - perpendicular_offset = layer_offset / self.unit_scale + perpendicular_offset = layer_offset - # Check if direction sense needs to be applied - # This should only happen if explicitly requested, not automatically - if layer_params.get("apply_direction_sense", False): - # Store current direction before potential change - old_direction = direction_ratios.copy() - - # Apply direction sense logic - existing_x_angle = extrusion_angle - if (abs(existing_x_angle) < (pi / 2) and direction_ratios.z > 0) or ( - abs(existing_x_angle) > (pi / 2) and direction_ratios.z < 0 - ): - if layer_params["direction_sense"] == "NEGATIVE": - direction_ratios *= -1 - elif (abs(existing_x_angle) > (pi / 2) and direction_ratios.z > 0) or ( - abs(existing_x_angle) < (pi / 2) and direction_ratios.z < 0 - ): - offset_direction = direction_ratios.copy() * -1 - if layer_params["direction_sense"] == "POSITIVE": - direction_ratios *= -1 - - # If direction changed, update extrusion with rotation compensation - if (direction_ratios.normalized() - old_direction.normalized()).length > 1e-6: - update_extrusion_direction(element, tuple(direction_ratios), obj) - # After updating direction, get the updated extrusion - extrusion = tool.Model.get_extrusion(representation) - - # Update depth extrusion.Depth = perpendicular_depth # Update position ifc_position = extrusion.Position + if direction_ratios.length > 0: offset_vector = direction_ratios.normalized() * perpendicular_offset position = offset_vector material = ifcopenshell.util.element.get_material(element) if material and material.is_a("IfcMaterialLayerSetUsage"): - material.OffsetFromReferenceLine = position.z + # Only set offset if not preserving it (preserves independent offsets per instance) + if not preserve_offset: + material.OffsetFromReferenceLine = position.z if ifc_position: ifc_position.Location.Coordinates = position else: tool.Model.add_extrusion_position(extrusion, position) - else: - props = tool.Model.get_model_props() - x_angle = 0 if tool.Cad.is_x(props.x_angle, 0, tolerance=0.001) else props.x_angle - new_rep = ifcopenshell.api.geometry.add_slab_representation( - tool.Ifc.get(), - context=body_context, - depth=thickness * self.unit_scale, - x_angle=x_angle, - ) - for inverse in tool.Ifc.get().get_inverse(representation): - ifcopenshell.util.element.replace_attribute(inverse, representation, new_rep) - bonsai.core.geometry.switch_representation( - tool.Ifc, - tool.Geometry, - obj=obj, - representation=new_rep, - ) - bonsai.core.geometry.remove_representation( - tool.Ifc, tool.Geometry, obj=obj, representation=representation - ) - return + bonsai.core.geometry.switch_representation( + tool.Ifc, + tool.Geometry, + obj=obj, + representation=representation, + ) else: props = tool.Model.get_model_props() x_angle = 0 if tool.Cad.is_x(props.x_angle, 0, tolerance=0.001) else props.x_angle @@ -389,13 +352,12 @@ class DumbSlabPlaner: ifcopenshell.api.geometry.assign_representation( tool.Ifc.get(), product=element, representation=representation ) - - bonsai.core.geometry.switch_representation( - tool.Ifc, - tool.Geometry, - obj=obj, - representation=representation, - ) + bonsai.core.geometry.switch_representation( + tool.Ifc, + tool.Geometry, + obj=obj, + representation=representation, + ) def update_extrusion_direction( element: ifcopenshell.entity_instance, new_direction_ratios: tuple, obj: bpy.types.Object = None diff --git a/src/bonsai/bonsai/tool/loader.py b/src/bonsai/bonsai/tool/loader.py index a15a3f8a29..529d06a27b 100644 --- a/src/bonsai/bonsai/tool/loader.py +++ b/src/bonsai/bonsai/tool/loader.py @@ -1021,7 +1021,7 @@ class Loader(bonsai.core.tool.Loader): elif material.is_a("IfcMaterialLayerSetUsage"): usage = material layer_set = material.ForLayerSet - offset = usage.OffsetFromReferenceLine * cls.unit_scale + offset = usage.OffsetFromReferenceLine sense_factor = 1 if usage.DirectionSense == "POSITIVE" else -1 elif material.is_a("IfcMaterialLayerSet"): usage = None @@ -1034,11 +1034,17 @@ class Loader(bonsai.core.tool.Loader): if len(layer_set.MaterialLayers) == 1: return mesh + # Get mesh bounds + if len(mesh.vertices) > 0: + z_coords = [v.co.z for v in mesh.vertices] + mesh_z_min = min(z_coords) + mesh_z_max = max(z_coords) + bm = bmesh.new() bm.from_mesh(mesh) prev_co = None - advance_direction = None # Will store direction to advance planes + advance_direction = None if not usage: sense_factor = 1 @@ -1046,9 +1052,7 @@ class Loader(bonsai.core.tool.Loader): co = Vector((0.0, 0.0, offset)) advance_direction = no elif usage.LayerSetDirection == "AXIS2": - co = Vector((0.0, offset, 0.0)) - - # Get LOCAL extrusion direction + # Get local extrusion direction local_extrusion = Vector([0.0, 0.0, 1.0]) if body := ifcopenshell.util.representation.get_representation(element, "Model", "Body", "MODEL_VIEW"): for item in ifcopenshell.util.representation.resolve_representation(body).Items: @@ -1060,17 +1064,58 @@ class Loader(bonsai.core.tool.Loader): # Thickness direction: perpendicular to extrusion and length thickness_dir = local_extrusion.cross(Vector([1.0, 0.0, 0.0])).normalized() - - # Ensure it points in POSITIVE Y (through wall thickness, not backwards) if thickness_dir.y < 0: thickness_dir = -thickness_dir no = thickness_dir + + # Find start point by projecting vertices onto thickness direction + if len(mesh.vertices) > 0: + projections = [Vector(v.co).dot(no) for v in mesh.vertices] + min_proj = min(projections) + max_proj = max(projections) + + centroid = sum((Vector(v.co) for v in mesh.vertices), Vector()) / len(mesh.vertices) + centroid_proj = centroid.dot(no) + + if sense_factor == 1: + start_proj = min_proj + else: + start_proj = max_proj + + offset_dist = start_proj - centroid_proj + co = centroid + no * offset_dist + + actual_mesh_height = max_proj - min_proj + else: + co = Vector((0.0, 0.0, 0.0)) + advance_direction = thickness_dir elif usage.LayerSetDirection == "AXIS3": - co = Vector((0.0, 0.0, offset)) - no = cls.get_extrusion_vector(element).normalized() + # AXIS3 layers go through slab thickness (local Z) no = Vector([0.0, 0.0, 1.0]) + + # Find start point by projecting vertices onto Z direction + if len(mesh.vertices) > 0: + projections = [Vector(v.co).dot(no) for v in mesh.vertices] + min_proj = min(projections) + max_proj = max(projections) + + centroid = sum((Vector(v.co) for v in mesh.vertices), Vector()) / len(mesh.vertices) + centroid_proj = centroid.dot(no) + + if sense_factor == 1: + start_proj = min_proj + else: + start_proj = max_proj + + offset = start_proj - centroid_proj + co = centroid + no * offset + + actual_mesh_height = max_proj - min_proj + else: + co = Vector((0.0, 0.0, 0.0)) + advance_direction = no elif usage.LayerSetDirection == "AXIS1": co = Vector((0.0, 0.0, offset)) @@ -1078,10 +1123,27 @@ class Loader(bonsai.core.tool.Loader): no = Vector([1.0, 0.0, 0.0]) advance_direction = no - no *= sense_factor - advance_direction *= sense_factor + # Apply DirectionSense + if usage and usage.LayerSetDirection == "AXIS2": + if sense_factor == -1: + advance_direction = -advance_direction + test_normal = -no + else: + test_normal = no + elif usage and usage.LayerSetDirection == "AXIS1": + no = no * sense_factor + advance_direction = advance_direction * sense_factor + test_normal = no + elif usage and usage.LayerSetDirection == "AXIS3": + if sense_factor == -1: + advance_direction = -advance_direction + test_normal = -no + else: + test_normal = no + else: + test_normal = no - # Cache this + # Cache material styles body = ifcopenshell.util.representation.get_context(tool.Ifc.get(), "Model", "Body", "MODEL_VIEW") styles = {} has_layer_styles = False @@ -1089,12 +1151,23 @@ class Loader(bonsai.core.tool.Loader): if style := tool.Ifc.get_entity(material): styles[style] = i + layer_list = list(enumerate(layer_set.MaterialLayers)) + + # Calculate scale factor + total_layer_thickness = sum(layer.LayerThickness for _, layer in layer_list) + + if 'actual_mesh_height' not in locals(): + actual_mesh_height = mesh_z_max - mesh_z_min if len(mesh.vertices) > 0 else total_layer_thickness + + thickness_scale = actual_mesh_height / total_layer_thickness if total_layer_thickness > 0 else 1.0 + last_i = len(layer_set.MaterialLayers) - 1 - for i, layer in enumerate(layer_set.MaterialLayers): - if i != last_i: + + for idx, (original_i, layer) in enumerate(layer_list): + if idx != last_i: prev_co = co.copy() - # Use advance_direction (not no) to move planes! - co += advance_direction * layer.LayerThickness * cls.unit_scale + advance_vector = advance_direction * layer.LayerThickness * thickness_scale + co += advance_vector bisect_geom = bmesh.ops.bisect_plane( bm, geom=bm.verts[:] + bm.edges[:] + bm.faces[:], dist=0.0001, plane_co=co, plane_no=no @@ -1107,18 +1180,18 @@ class Loader(bonsai.core.tool.Loader): material_index = len(mesh.materials) mesh.materials.append(tool.Ifc.get_object(style)) - if i == last_i: + if idx == last_i: for face in bisect_geom["geom"]: if isinstance(face, bmesh.types.BMFace): center = face.calc_center_median() - if (center - co).dot(no) >= 0: + if (center - co).dot(test_normal) >= 0: face.material_index = material_index has_layer_styles = True else: for face in bisect_geom["geom"]: if isinstance(face, bmesh.types.BMFace): center = face.calc_center_median() - if (center - co).dot(no) < 0 and (center - prev_co).dot(no) >= 0: + if (center - co).dot(test_normal) < 0 and (center - prev_co).dot(test_normal) >= 0: face.material_index = material_index has_layer_styles = True From 242f1aab35ad11b5d04afa927e01307e8eaeaef9 Mon Sep 17 00:00:00 2001 From: Dion Moult Date: Fri, 16 Jan 2026 10:26:23 +1100 Subject: [PATCH 49/49] Fix #7563. Regression in normalising document URIs. --- src/bonsai/bonsai/bim/module/document/data.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/bonsai/bonsai/bim/module/document/data.py b/src/bonsai/bonsai/bim/module/document/data.py index 7fc05ca3d1..b68c118e56 100644 --- a/src/bonsai/bonsai/bim/module/document/data.py +++ b/src/bonsai/bonsai/bim/module/document/data.py @@ -99,7 +99,7 @@ class ObjectDocumentData: return "" uri = location - if not uri.startswith("file://"): + if "://" not in uri: if not os.path.isabs(uri): uri = os.path.abspath(os.path.join(os.path.dirname(tool.Ifc.get_path()), uri)) uri = "file://" + uri