From 4103aa2407f608c49cba019dcb83ade90412fc56 Mon Sep 17 00:00:00 2001 From: BjornFidder Date: Tue, 26 Aug 2025 13:18:36 +0200 Subject: [PATCH 1/9] Initial commit - Add BIM numbering module for Blender Introduces a new 'numbering' module to the Bonsai OpenBIM Blender add-on, including operators, properties, UI panel, and utility functions for assigning, removing, and managing numbers on IFC elements. Updates core registration to support the new module and adds integration points in the tool layer. --- src/bonsai/bonsai/bim/__init__.py | 1 + .../bonsai/bim/module/numbering/__init__.py | 46 ++ .../bonsai/bim/module/numbering/operator.py | 353 +++++++++ .../bonsai/bim/module/numbering/prop.py | 296 ++++++++ src/bonsai/bonsai/bim/module/numbering/ui.py | 148 ++++ .../bonsai/bim/module/numbering/util.py | 670 ++++++++++++++++++ .../bonsai/bim/module/numbering/workspace.py | 49 ++ src/bonsai/bonsai/core/tool.py | 3 + src/bonsai/bonsai/tool/blender.py | 7 + src/bonsai/bonsai/tool/numbering.py | 31 + .../ifcopenshell/ifcopenshell_wrapper.pyi | 51 +- 11 files changed, 1633 insertions(+), 22 deletions(-) create mode 100644 src/bonsai/bonsai/bim/module/numbering/__init__.py create mode 100644 src/bonsai/bonsai/bim/module/numbering/operator.py create mode 100644 src/bonsai/bonsai/bim/module/numbering/prop.py create mode 100644 src/bonsai/bonsai/bim/module/numbering/ui.py create mode 100644 src/bonsai/bonsai/bim/module/numbering/util.py create mode 100644 src/bonsai/bonsai/bim/module/numbering/workspace.py create mode 100644 src/bonsai/bonsai/tool/numbering.py diff --git a/src/bonsai/bonsai/bim/__init__.py b/src/bonsai/bonsai/bim/__init__.py index 79fc5fdd544..8ac4b52a2ec 100644 --- a/src/bonsai/bonsai/bim/__init__.py +++ b/src/bonsai/bonsai/bim/__init__.py @@ -86,6 +86,7 @@ "web": None, "light": None, "alignment": None, + "numbering": None, # Uncomment this line to enable loading of the demo module. Happy hacking! # The name "demo" must correlate to a folder name in `bim/module/`. # "demo": None, diff --git a/src/bonsai/bonsai/bim/module/numbering/__init__.py b/src/bonsai/bonsai/bim/module/numbering/__init__.py new file mode 100644 index 00000000000..b4dec49fc64 --- /dev/null +++ b/src/bonsai/bonsai/bim/module/numbering/__init__.py @@ -0,0 +1,46 @@ +# 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 +from . import ui, operator, prop, workspace + +# Registration +classes = (operator.AssignNumbers, + operator.RemoveNumbers, + operator.SaveSettings, + operator.LoadSettings, + operator.ExportSettings, + operator.ImportSettings, + operator.DeleteSettings, + operator.ClearSettings, + operator.ShowMessage, + prop.BIMNumberingProperties, + ui.BIM_PT_Numbering) + +def register(): + if not bpy.app.background: + bpy.utils.register_tool(workspace.NumberingTool, after={"bim.numbering_tool"}, separator=False, group=False) + bpy.types.Scene.BIMNumberingProperties = bpy.props.PointerProperty(type=prop.BIMNumberingProperties) + +def unregister(): + if not bpy.app.background: + bpy.utils.unregister_tool(workspace.NumberingTool) + del bpy.types.Scene.BIMNumberingProperties + +register() + diff --git a/src/bonsai/bonsai/bim/module/numbering/operator.py b/src/bonsai/bonsai/bim/module/numbering/operator.py new file mode 100644 index 00000000000..5382af78302 --- /dev/null +++ b/src/bonsai/bonsai/bim/module/numbering/operator.py @@ -0,0 +1,353 @@ +# 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.ifc import IfcStore + +import json +import functools as ft +from .util import Settings, LoadSelection, NumberFormatting, SaveNumber, Storeys, ObjectGeometry, get_id + +class UndoOperator: + @staticmethod + def execute_with_undo(operator, context, method): + ifc_file = tool.Ifc.get() + """Execute a method with undo support.""" + IfcStore.begin_transaction(operator) + settings = Settings.to_dict(context.scene.BIMNumberingProperties) + + parent_type = LoadSelection.get_parent_type(settings) + try: + elements = ifc_file.by_type(parent_type) + except RuntimeError: + operator.report({'ERROR'}, f"Parent type {parent_type} not found in {ifc_file.schema} schema.") + return {'CANCELLED'} + + if settings.get("pset_name") == "Common": + SaveNumber.get_pset_common_names(elements) + + old_numbers = {get_id(element): SaveNumber.get_number(element, settings) for element in elements} + new_numbers = old_numbers.copy() + + result = method(settings, new_numbers) + + operator.transaction_data = {"old_value": old_numbers, "new_value": new_numbers} + IfcStore.add_transaction_operation(operator) + IfcStore.end_transaction(operator) + + bpy.context.view_layer.objects.active = bpy.context.active_object + + return result + + @staticmethod + def rollback(operator, data): + """Support undo of number assignment""" + ifc_file = tool.Ifc.get() + + rollback_count = 0 + settings = Settings.to_dict(bpy.context.scene.BIMNumberingProperties) + for element in ifc_file.by_type(LoadSelection.get_parent_type(settings)): + old_number = data["old_value"].get(get_id(element), None) + rollback_count += int(SaveNumber.save_number(ifc_file, element, old_number, settings, data["new_value"]) or 0) + bpy.ops.bonsai.show_message('EXEC_DEFAULT', message=f"Rollback {rollback_count} numbers.") + + @staticmethod + def commit(operator, data): + """Support redo of number assignment""" + ifc_file = tool.Ifc.get() + + commit_count = 0 + settings = Settings.to_dict(bpy.context.scene.BIMNumberingProperties) + for obj in bpy.context.scene.objects: + element = tool.Ifc.get_entity(obj) + if element is not None and element.is_a(LoadSelection.get_parent_type(settings)): + new_number = data["new_value"].get(obj.name, None) + commit_count += int(SaveNumber.save_number(ifc_file, element, new_number, settings, data["old_value"]) or 0) + bpy.ops.bonsai.show_message('EXEC_DEFAULT', message=f"Commit {commit_count} numbers.") + +class AssignNumbers(bpy.types.Operator): + bl_idname = "bim.assign_numbers" + bl_label = "Assign numbers" + bl_description = "Assign numbers to selected objects" + bl_options = {"REGISTER", "UNDO"} + + def number_elements(elements, ifc_file, settings, elements_locations = None, elements_dimensions = None, storeys = None, numbers_cache = {}, storeys_numbers={}, report=None, remove_count=None): + """Number elements in the IFC file with the provided settings. If element locations or dimensions are specified, these are used for sorting. + Providing numbers_cache, a dictionary with element-> currently saved number, speeds up execution. + If storeys_numbers is provided, as a dictionary storey->number, this is used for assigning storey numbers.""" + if report is None: + def report(report_type, message): + if report_type == {"INFO"}: + print("INFO: ", message) + if report_type == {"WARNING"}: + raise Exception(message) + if storeys is None: + storeys = [] + + number_count = 0 + + if elements_dimensions: + elements.sort(key=ft.cmp_to_key(lambda a, b: ObjectGeometry.cmp_within_precision(elements_dimensions[a], elements_dimensions[b], settings, use_dir=False))) + if elements_locations: + elements.sort(key=ft.cmp_to_key(lambda a, b: ObjectGeometry.cmp_within_precision(elements_locations[a], elements_locations[b], settings))) + + selected_types = LoadSelection.get_selected_types(settings) + + if not selected_types: + selected_types = list(set(element.is_a() for element in elements)) + + elements_by_type = [[element for element in elements if element.is_a() == ifc_type] for ifc_type in selected_types] + + failed_types = set() + for (element_number, element) in enumerate(elements): + + type_index = selected_types.index(element.is_a()) + type_elements = elements_by_type[type_index] + type_number = type_elements.index(element) + type_name = selected_types[type_index][3:] + + if storeys: + storey_number = Storeys.get_storey_number(element, storeys, settings, storeys_numbers) + if storey_number is None and "{S}" in settings.get("format"): + if report is not None: + report({'WARNING'}, f"Element {getattr(element, 'Name', '')} of type {element.is_a()} with ID {get_id(element)} is not contained in any storey.") + else: + raise Exception(f"Element {getattr(element, 'Name', '')} of type {element.is_a()} with ID {get_id(element)} is not contained in any storey.") + else: + storey_number = None + + number = NumberFormatting.format_number(settings, (element_number, type_number, storey_number), (len(elements), len(type_elements), len(storeys)), type_name) + count = SaveNumber.save_number(ifc_file, element, number, settings, numbers_cache) + if count is None: + report({'WARNING'}, f"Failed to save number for element {getattr(element, 'Name', '')} of type {element.is_a()} with ID {get_id(element)}.") + failed_types.add(element.is_a()) + else: + number_count += count + + if failed_types: + report({'WARNING'}, f"Failed to renumber the following types: {failed_types}") + + if settings.get("remove_toggle") and remove_count is not None: + report({'INFO'}, f"Renumbered {number_count} objects, removed number from {remove_count} objects.") + else: + report({'INFO'}, f"Renumbered {number_count} objects.") + + return {'FINISHED'}, number_count + + def assign_numbers(self, settings, numbers_cache): + """Assign numbers to selected objects based on their IFC type and location.""" + ifc_file = tool.Ifc.get() + + remove_count = 0 + + if settings.get("remove_toggle"): + for obj in bpy.context.scene.objects: + if (settings.get("selected_toggle") and obj not in bpy.context.selected_objects) or \ + (settings.get("visible_toggle") and not obj.visible_get()): + element = tool.Ifc.get_entity(obj) + if element is not None and element.is_a(LoadSelection.get_parent_type(settings)): + count_diff = SaveNumber.remove_number(ifc_file, element, settings, numbers_cache) + remove_count += count_diff + + objects = LoadSelection.load_selected_objects(settings) + + if not objects: + self.report({'WARNING'}, f"No objects selected or available for numbering, removed {remove_count} existing numbers.") + return {'CANCELLED'} + + selected_types = LoadSelection.get_selected_types(settings) + possible_types = [tupl[0] for tupl in LoadSelection.possible_types] + + selected_elements = [] + elements_locations = {} + elements_dimensions = {} + for obj in objects: + element = tool.Ifc.get_entity(obj) + if element is None: + continue + if element.is_a() in selected_types: + selected_elements.append(element) + elements_locations[element] = ObjectGeometry.get_object_location(obj, settings) + elements_dimensions[element] = ObjectGeometry.get_object_dimensions(obj) + elif settings.get("remove_toggle") and element.is_a() in possible_types: + remove_count += SaveNumber.remove_number(ifc_file, element, settings, numbers_cache) + + if not selected_elements: + self.report({'WARNING'}, f"No elements selected or available for numbering, removed {remove_count} existing numbers.") + + storeys = Storeys.get_storeys(settings) + res, _= AssignNumbers.number_elements(selected_elements, + ifc_file, settings, + elements_locations, + elements_dimensions, + storeys, + numbers_cache, + report = self.report, + remove_count=remove_count) + + if settings.get("check_duplicates_toggle"): + numbers = [] + for obj in bpy.context.scene.objects: + element = tool.Ifc.get_entity(obj) + if element is None or not element.is_a(LoadSelection.get_parent_type(settings)): + continue + number = SaveNumber.get_number(element, settings, numbers_cache) + if number in numbers: + self.report({'WARNING'}, f"The model contains duplicate numbers") + return {'FINISHED'} + if number is not None: + numbers.append(number) + + return res + + def execute(self, context): + return UndoOperator.execute_with_undo(self, context, self.assign_numbers) + + def rollback(self, data): + UndoOperator.rollback(self, data) + + def commit(self, data): + UndoOperator.commit(self, data) + +class RemoveNumbers(bpy.types.Operator): + bl_idname = "bim.remove_numbers" + bl_label = "Remove numbers" + bl_description = "Remove numbers from selected objects, from the selected attribute or Pset" + bl_options = {"REGISTER", "UNDO"} + + def remove_numbers(self, settings, numbers_cache): + """Remove numbers from selected objects""" + ifc_file = tool.Ifc.get() + + remove_count = 0 + + objects = bpy.context.selected_objects if settings.get("selected_toggle") else bpy.context.scene.objects + if settings.get("visible_toggle"): + objects = [obj for obj in objects if obj.visible_get()] + + if not objects: + self.report({'WARNING'}, f"No objects selected or available for removal.") + return {'CANCELLED'} + + for obj in objects: + element = tool.Ifc.get_entity(obj) + if element is not None and element.is_a(LoadSelection.get_parent_type(settings)): + remove_count += SaveNumber.remove_number(ifc_file, element, settings, numbers_cache) + numbers_cache[get_id(element)] = None + + if remove_count == 0: + self.report({'WARNING'}, f"No elements selected or available for removal.") + return {'CANCELLED'} + + self.report({'INFO'}, f"Removed {remove_count} existing numbers.") + return {'FINISHED'} + + def execute(self, context): + return UndoOperator.execute_with_undo(self, context, self.remove_numbers) + + def rollback(self, data): + UndoOperator.rollback(self, data) + + def commit(self, data): + UndoOperator.commit(self, data) + +class ShowMessage(bpy.types.Operator): + bl_idname = "bim.show_message" + bl_label = "Show Message" + bl_description = "Show a message in the info area" + message: bpy.props.StringProperty() # pyright: ignore[reportInvalidTypeForm] + + def execute(self, context): + self.report({'INFO'}, self.message) + return {'FINISHED'} + +class SaveSettings(bpy.types.Operator): + bl_idname = "bim.save_settings" + bl_label = "Save Settings" + bl_description = f"Save the current numbering settings to {Settings.pset_name} of the IFC Project element, under the selected name" + + def execute(self, context): + props = context.scene.BIMNumberingProperties + ifc_file = tool.Ifc.get() + return Settings.save_settings(self, props, ifc_file) + +class LoadSettings(bpy.types.Operator): + bl_idname = "bim.load_settings" + bl_label = "Load Settings" + bl_description = f"Load the selected numbering settings from {Settings.pset_name} of the IFC Project element" + + def execute(self, context): + props = context.scene.BIMNumberingProperties + ifc_file = tool.Ifc.get() + return Settings.load_settings(self, props, ifc_file) + +class DeleteSettings(bpy.types.Operator): + bl_idname = "bim.delete_settings" + bl_label = "Delete Settings" + bl_description = f"Delete the selected numbering settings from {Settings.pset_name} of the IFC Project element" + + def execute(self, context): + props = context.scene.BIMNumberingProperties + return Settings.delete_settings(self, props) + +class ClearSettings(bpy.types.Operator): + bl_idname = "bim.clear_settings" + bl_label = "Clear Settings" + bl_description = f"Remove the {Settings.pset_name} Pset and all the saved settings from the IFC Project element" + + def execute(self, context): + props = context.scene.BIMNumberingProperties + return Settings.clear_settings(self, props) + +class ExportSettings(bpy.types.Operator): + bl_idname = "bim.export_settings" + bl_label = "Export Settings" + bl_description = f"Export the current numbering settings to a JSON file" + filepath: bpy.props.StringProperty(subtype="FILE_PATH") # pyright: ignore[reportInvalidTypeForm] + + def execute(self, context): + props = context.scene.BIMNumberingProperties + with open(self.filepath, 'w') as f: + json.dump(Settings.settings_dict(props), f) + self.report({'INFO'}, f"Exported settings to {self.filepath}") + return {'FINISHED'} + + def invoke(self, context, event): + self.filepath = "settings.json" + context.window_manager.fileselect_add(self) + return {'RUNNING_MODAL'} + +class ImportSettings(bpy.types.Operator): + bl_idname = "bim.import_settings" + bl_label = "Import Settings" + bl_description = f"Import numbering settings from a JSON file" + + filepath: bpy.props.StringProperty(subtype="FILE_PATH") # pyright: ignore[reportInvalidTypeForm] + + def execute(self, context): + props = context.scene.BIMNumberingProperties + with open(self.filepath, 'r') as f: + settings = json.load(f) + Settings.read_settings(self, settings, props) + self.report({'INFO'}, f"Imported settings from {self.filepath}") + return {'FINISHED'} + + def invoke(self, context, event): + context.window_manager.fileselect_add(self) + return {'RUNNING_MODAL'} diff --git a/src/bonsai/bonsai/bim/module/numbering/prop.py b/src/bonsai/bonsai/bim/module/numbering/prop.py new file mode 100644 index 00000000000..41f1c711c35 --- /dev/null +++ b/src/bonsai/bonsai/bim/module/numbering/prop.py @@ -0,0 +1,296 @@ +# 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 . + +from bpy.types import PropertyGroup +from bpy.props import ( + IntProperty, + StringProperty, + EnumProperty, + BoolProperty, + IntVectorProperty +) +from .util import Settings, LoadSelection, NumberFormatting, NumberingSystems, SaveNumber, Storeys + +class BIMNumberingProperties(PropertyGroup): + settings_name : StringProperty( + name="Settings name", + description="Name for saving the current settings", + default="" + ) # pyright: ignore[reportInvalidTypeForm] + + def get_saved_settings_items(self, context): + settings_names = Settings.get_settings_names() + if not settings_names: + return [("NONE", "No saved settings", "")] + return [(name, name, "") for name in settings_names] + + saved_settings : EnumProperty( + name="Load settings", + description="Select which saved settings to load", + items=get_saved_settings_items + ) # pyright: ignore[reportInvalidTypeForm] + + selected_toggle: BoolProperty( + name="Selected only", + description="Only number selected objects", + default=False, + update=NumberFormatting.update_format_preview + ) # pyright: ignore[reportInvalidTypeForm] + + visible_toggle: BoolProperty( + name="Visible only", + description="Only number visible objects", + default=False, + update=NumberFormatting.update_format_preview + ) # pyright: ignore[reportInvalidTypeForm] + + parent_type: EnumProperty( + name="Parent Type", + description="Select the parent type for numbering", + items=[ + ("IfcElement", "IfcElement", "Number IFC elements"), + ("IfcProduct", "IfcProduct", "Number IFC products"), + ("IfcGridAxis", "IfcGridAxis", "Number IFC grid axes"), + ("Other", "Other", "Input which IFC entities to number") + ], + default="IfcElement", + update = LoadSelection.update_objects + ) # pyright: ignore[reportInvalidTypeForm] + + parent_type_other : StringProperty( + name="Other Parent Type", + description="Input which IFC entities to number", + default="IfcElement", + update = LoadSelection.update_objects + ) # pyright: ignore[reportInvalidTypeForm] + + def update_selected_types(self, context): + NumberFormatting.update_format_preview(self, context) + SaveNumber.update_pset_names(self, context) + + selected_types: EnumProperty( + name="Of type", + description="Select which types of elements to number", + items= LoadSelection.get_possible_types, + options={'ENUM_FLAG'}, + update=update_selected_types + ) # pyright: ignore[reportInvalidTypeForm] + + x_direction: EnumProperty( + name="X", + description="Select axis direction for numbering elements", + items=[ + ("1", "+", "Number elements in order of increasing X coordinate"), + ("-1", "-", "Number elements in order of decreasing X coordinate") + ], + default="1", + ) # pyright: ignore[reportInvalidTypeForm] + + y_direction: EnumProperty( + name="Y", + description="Select axis direction for numbering elements", + items=[ + ("1", "+", "Number elements in order of increasing Y coordinate"), + ("-1", "-", "Number elements in order of decreasing Y coordinate") + ], + default="1" + ) # pyright: ignore[reportInvalidTypeForm] + + z_direction: EnumProperty( + name="Z", + description="Select axis direction for numbering elements", + items=[ + ("1", "+", "Number elements in order of increasing Z coordinate"), + ("-1", "-", "Number elements in order of decreasing Z coordinate") + ], + default="1" + ) # pyright: ignore[reportInvalidTypeForm] + + axis_order: EnumProperty( + name="Axis order", + description="Order of axes in numbering elements", + items=[ + ("XYZ", "X, Y, Z", "Number elements in X, Y, Z order"), + ("XZY", "X, Z, Y", "Number elements in X, Z, Y order"), + ("YXZ", "Y, X, Z", "Number elements in Y, X, Z order"), + ("YZX", "Y, Z, X", "Number elements in Y, Z, X order"), + ("ZXY", "Z, X, Y", "Number elements in Z, X, Y order"), + ("ZYX", "Z, Y, X", "Number elements in Z, Y, X order") + ], + default="ZYX" + ) # pyright: ignore[reportInvalidTypeForm] + + location_type: EnumProperty( + name="Reference location", + description="Location to use for sorting elements", + items=[ + ("CENTER", "Center", "Use object center for sorting"), + ("BOUNDING_BOX", "Bounding Box", "Use object bounding box for sorting"), + ], + default="BOUNDING_BOX" + ) # pyright: ignore[reportInvalidTypeForm] + + precision: IntVectorProperty( + name="Precision", + description="Precision for sorting elements in X, Y and Z direction", + default=(1, 1, 1), + min=1, + size=3 + ) # pyright: ignore[reportInvalidTypeForm] + + initial_element_number: IntProperty( + name="{E}", + description="Initial number for numbering elements", + default=1, + update=NumberFormatting.update_format_preview + ) # pyright: ignore[reportInvalidTypeForm] + + initial_type_number: IntProperty( + name="{T}", + description="Initial number for numbering elements within type", + default=1, + update=NumberFormatting.update_format_preview + ) # pyright: ignore[reportInvalidTypeForm] + + initial_storey_number: IntProperty( + name="{S}", + description="Initial number for numbering storeys", + default=0, + update=NumberFormatting.update_format_preview + ) # pyright: ignore[reportInvalidTypeForm] + + numberings_enum = lambda self, initial : [ + ("number", NumberingSystems.get_numbering_preview("number", initial), "Use numbers. Negative numbers are shown with brackets"), + ("number_ext", NumberingSystems.get_numbering_preview("number_ext", initial), "Use numbers padded with zeroes to a fixed length based on the number of objects selected. Negative numbers are shown with brackets."), + ("lower_letter", NumberingSystems.get_numbering_preview("lower_letter", initial), "Use lowercase letters, continuing with aa, ab, ... where negative numbers are shown with brackets."), + ("upper_letter", NumberingSystems.get_numbering_preview("upper_letter", initial), "Use uppercase letters, continuing with AA, AB, ... where negative numbers are shown with brackets."), + ] + + custom_storey_enum = [("custom", "Custom", "Use custom numbering for storeys")] + + element_numbering: EnumProperty( + name="{E}", + description="Select numbering system for element numbering", + items=lambda self, context: self.numberings_enum(self.initial_element_number), + update=NumberFormatting.update_format_preview + ) # pyright: ignore[reportInvalidTypeForm] + + type_numbering: EnumProperty( + name="{T}", + description="Select numbering system for numbering within types", + items=lambda self, context: self.numberings_enum(self.initial_type_number), + update=NumberFormatting.update_format_preview + ) # pyright: ignore[reportInvalidTypeForm] + + def update_storey_numbering(self, context): + if self.storey_numbering == "custom": + self.initial_storey_number = 0 + + storey_numbering: EnumProperty( + name="{S}", + description="Select numbering system for numbering storeys. Storeys are numbered in positive Z-order by default.", + items=lambda self, context: self.numberings_enum(self.initial_storey_number) + self.custom_storey_enum, + update=update_storey_numbering + ) # pyright: ignore[reportInvalidTypeForm] + + custom_storey: EnumProperty( + name = "Storey", + description = "Select storey to number", + items = lambda self, _: [(storey.Name, storey.Name, f"{storey.Name}\nID: {storey.GlobalId}") for storey in Storeys.get_storeys(Settings.to_dict(self))], + update = Storeys.update_custom_storey + ) # pyright: ignore[reportInvalidTypeForm] + + custom_storey_number: IntProperty( + name = "Storey number", + description = f"Set custom storey number for selected storey, stored in {Storeys.settings['pset_name']} in the IFC element", + get = Storeys.get_custom_storey_number, + set = Storeys.set_custom_storey_number + ) # pyright: ignore[reportInvalidTypeForm] + + format: StringProperty( + name="Format", + description="Format string for selected IFC type.\n" \ + "{E}: element number \n" \ + "{T}: number within type \n" \ + "{S}: number of storey\n" \ + "[T]: first letter of type name\n" \ + "[TT] : all capitalized letters in type name\n" \ + "[TF]: full type name", + default="E{E}S{S}[T]{T}", + update=NumberFormatting.update_format_preview + ) # pyright: ignore[reportInvalidTypeForm] + + save_type : EnumProperty( + name="Type of number storage", + items = [("Attribute", "Attribute", "Store number in an attribute of the IFC element"), + ("Pset", "Pset", "Store number in a Pset of the IFC element") + ], + default = "Attribute", + update = SaveNumber.update_pset_names + ) # pyright: ignore[reportInvalidTypeForm] + + attribute_name : EnumProperty( + name="Attribute name", + description="Name of the attribute to store the number", + items = [("Tag", "Tag", "Store number in IFC Tag attribute"), + ("Name", "Name", "Store number in IFC Name attribute"), + ("Description", "Description", "Store number in IFC Description attribute"), + ("AxisTag", "AxisTag", "Store number in IFC AxisTag attribute, used for IFCGridAxis"), + ("Other", "Other", "Input in which IFC attribute to store the number") + ], + default="Tag" + ) # pyright: ignore[reportInvalidTypeForm] + + attribute_name_other : StringProperty( + name="Other attribute name", + description="Name of the other attribute to store the number", + default="Tag" + ) # pyright: ignore[reportInvalidTypeForm] + + def get_pset_names(self, context): + return SaveNumber.pset_names + + pset_name : EnumProperty( + name="Pset name", + description="Name of the Pset to store the number", + items = get_pset_names + ) # pyright: ignore[reportInvalidTypeForm] + + property_name : StringProperty( + name="Property name", + description="Name of the property to store the number", + default="Number" + ) # pyright: ignore[reportInvalidTypeForm] + + custom_pset_name : StringProperty( + name="Custom Pset name", + description="Name of the custom Pset to store the number", + default="Pset_Numbering" + ) # pyright: ignore[reportInvalidTypeForm] + + remove_toggle: BoolProperty( + name="Remove numbers from unselected objects", + description="Remove numbers from unselected objects in the scene", + default=True + ) # pyright: ignore[reportInvalidTypeForm] + + check_duplicates_toggle: BoolProperty( + name="Check for duplicate numbers", + description="Check for duplicate numbers in all objects in the scene", + default=True + ) # pyright: ignore[reportInvalidTypeForm] diff --git a/src/bonsai/bonsai/bim/module/numbering/ui.py b/src/bonsai/bonsai/bim/module/numbering/ui.py new file mode 100644 index 00000000000..85d6b87f159 --- /dev/null +++ b/src/bonsai/bonsai/bim/module/numbering/ui.py @@ -0,0 +1,148 @@ +# 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 . + + +from bpy.types import Panel +import bonsai.tool as tool +from .util import NumberFormatting + +class BIM_PT_Numbering(Panel): + bl_label = "Numbering Container" + bl_idname = "BIM_PT_numbering" + bl_space_type = "PROPERTIES" + bl_region_type = "WINDOW" + bl_context = "object" + bl_parent_id = "BIM_PT_tab_object_metadata" + + @classmethod + def draw(self, context): + + assert self.layout + layout = self.layout + + props = tool.Numbering.get_numbering_props() + + # Settings box + box = layout.box() + box.label(text="Settings") + grid = box.grid_flow(row_major=True, align=True, columns=4, even_columns=True) + grid.prop(props, "settings_name", text="Name") + grid.operator("bonsai.save_settings", icon="FILE_TICK", text="Save") + grid.operator("bonsai.clear_settings", icon="CANCEL", text="Clear") + grid.operator("bonsai.export_settings", icon="EXPORT", text="Export") + + grid.prop(props, "saved_settings", text="") + grid.operator("bonsai.load_settings", icon="FILE_REFRESH", text="Load") + grid.operator("bonsai.delete_settings", icon="TRASH", text="Delete") + grid.operator("bonsai.import_settings", icon="IMPORT", text="Import") + + # Selection box + box = layout.box() + box.label(text="Elements to number:") + grid = box.grid_flow(row_major=True, align=False, columns=4, even_columns=True) + grid.prop(props, "selected_toggle") + grid.prop(props, "visible_toggle") + grid.prop(props, "parent_type", text="") + if props.parent_type == "Other": + grid.prop(props, "parent_type_other", text="") + else: + grid.label(text="") + + grid = box.grid_flow(row_major=True, align=True, columns=4, even_columns=True) + grid.prop(props, "selected_types", expand=True) + + # Numbering order box + box = layout.box() + box.label(text="Numbering order") + # Create a grid for direction and precision + grid = box.grid_flow(row_major=True, align=False, columns=4, even_columns=True) + grid.label(text="Direction: ") + grid.prop(props, "x_direction", text="X") + grid.prop(props, "y_direction", text="Y") + grid.prop(props, "z_direction", text="Z") + grid.label(text="Precision: ") + grid.prop(props, "precision", index=0, text="X") + grid.prop(props, "precision", index=1, text="Y") + grid.prop(props, "precision", index=2, text="Z") + + # Axis order and reference point + grid = box.grid_flow(row_major=True, align=True, columns=4) + grid.label(text="Order:") + grid.prop(props, "axis_order", text="") + grid.label(text="Reference point:") + grid.prop(props, "location_type", text="") + + # Numbering systems box + box = layout.box() + box.label(text="Numbering of elements {E}, within type {T} and storeys {S}") + grid = box.grid_flow(row_major=True, align=False, columns=4, even_columns=True) + grid.label(text="Start at:") + grid.prop(props, "initial_element_number", text="{E}") + grid.prop(props, "initial_type_number", text="{T}") + grid.prop(props, "initial_storey_number", text="{S}") + grid.label(text="System:") + grid.prop(props, "element_numbering", text="{E}") + grid.prop(props, "type_numbering", text="{T}") + grid.prop(props, "storey_numbering", text="{S}") + + # Custom storey number + if props.storey_numbering == "custom": + box = box.box() + row = box.row(align=False) + row.prop(props, "custom_storey", text="Storey") + row.prop(props, "custom_storey_number", text="Number") + + # Numbering format box + box = layout.box() + box.label(text="Numbering format") + grid = box.grid_flow(align=False, columns=4, even_columns=True) + grid.label(text="Format:") + grid.prop(props, "format", text="") + # Show preview in a textbox style (non-editable) + grid.label(text="Preview:") + preview_box = grid.box() + preview_box.label(text=NumberFormatting.format_preview) + + # Storage options + box = layout.box() + box.label(text="Store number in") + + grid = box.grid_flow(align=False, columns=4, even_columns=True) + grid.prop(props, "save_type", text="") + if props.save_type == "Attribute": + grid.prop(props, "attribute_name", text="") + if props.attribute_name == "Other": + grid.prop(props, "attribute_name_other", text="") + if props.save_type == "Pset": + grid.prop(props, "pset_name", text="") + if props.pset_name == "Custom Pset": + grid.prop(props, "custom_pset_name", text="") + grid.prop(props, "property_name", text="") + + box.prop(props, "remove_toggle") + box.prop(props, "check_duplicates_toggle") + + # Actions + layout.separator() + row = layout.row(align=True) + row.operator("bonsai.assign_numbers", icon="TAG", text="Assign numbers") + row = layout.row(align=True) + row.operator("bonsai.remove_numbers", icon="X", text="Remove numbers") + + + diff --git a/src/bonsai/bonsai/bim/module/numbering/util.py b/src/bonsai/bonsai/bim/module/numbering/util.py new file mode 100644 index 00000000000..ff59cff3861 --- /dev/null +++ b/src/bonsai/bonsai/bim/module/numbering/util.py @@ -0,0 +1,670 @@ +# 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 + +import ifcopenshell.api as ifc_api +from ifcopenshell.util.element import get_pset +from ifcopenshell.util.pset import PsetQto + +from mathutils import Vector +import functools as ft +import numpy as np +import ifcopenshell.geom as geom +import ifcopenshell.util.shape as ifc_shape + +import string +import json + +def get_id(element): + return getattr(element, "GlobalId", element.id()) + +class SaveNumber: + + pset_names = [("Custom", "Custom Pset", "")] + pset_common_names = {} + ifc_file = tool.Ifc.get() + pset_qto = PsetQto(ifc_file.schema) + + @staticmethod + def update_ifc_file(): + if (ifc_file := tool.Ifc.get()) != SaveNumber.ifc_file: + SaveNumber.ifc_file = ifc_file + SaveNumber.pset_qto = PsetQto(ifc_file.schema) + + @staticmethod + def get_number(element, settings, numbers_cache=None): + if element is None: + return None + if numbers_cache is None: + numbers_cache = {} + if get_id(element) in numbers_cache: + return numbers_cache[get_id(element)] + if settings.get("save_type") == "Attribute": + return getattr(element, SaveNumber.get_attribute_name(settings), None) + if settings.get("save_type") == "Pset": + pset_name = SaveNumber.get_pset_name(element, settings) + if (pset := get_pset(element, pset_name)): + return pset.get(settings.get("property_name")) + return None + + @staticmethod + def save_number(ifc_file, element, number, settings, numbers_cache=None): + if element is None: + return None + if numbers_cache is None: + numbers_cache = {} + if number == SaveNumber.get_number(element, settings, numbers_cache): + return 0 + if settings.get("save_type") == "Attribute": + attribute_name = SaveNumber.get_attribute_name(settings) + if not hasattr(element, attribute_name): + return None + if attribute_name == "Name" and number is None: + number = element.is_a().strip("Ifc") #Reset Name to name of type + setattr(element, attribute_name, number) + numbers_cache[get_id(element)] = number + return 1 + if settings.get("save_type") == "Pset": + pset_name = SaveNumber.get_pset_name(element, settings) + if not pset_name: + return None + if pset := get_pset(element, pset_name): + pset = ifc_file.by_id(pset["id"]) + else: + pset = ifc_api.run("pset.add_pset", ifc_file, product=element, name=pset_name) + ifc_api.run("pset.edit_pset", ifc_file, pset=pset, properties={settings["property_name"]: number}, should_purge=True) + if number is None and not pset.HasProperties: + ifc_api.run("pset.remove_pset", ifc_file, product=element, pset=pset) + numbers_cache[get_id(element)] = number + return 1 + return None + + @staticmethod + def remove_number(ifc_file, element, settings, numbers_cache=None): + count = SaveNumber.save_number(ifc_file, element, None, settings, numbers_cache) + return int(count or 0) + + def get_attribute_name(settings): + if settings.get("attribute_name") == "Other": + return settings.get("attribute_name_other") + return settings.get("attribute_name") + + @staticmethod + def get_pset_name(element, settings): + if settings.get("pset_name") == "Common": + ifc_type = element.is_a() + name = SaveNumber.pset_common_names.get(ifc_type, None) + return name + if settings.get("pset_name") == "Custom Pset": + return settings.get("custom_pset_name") + return settings.get("pset_name") + + @staticmethod + def update_pset_names(prop, context): + settings = Settings.to_dict(context.scene.BIMNumberingProperties) + pset_names_sets = [set(SaveNumber.pset_qto.get_applicable_names(ifc_type)) for ifc_type in LoadSelection.get_selected_types(settings)] + intersection = set.intersection(*pset_names_sets) if pset_names_sets else set() + SaveNumber.pset_names = [('Custom Pset', 'Custom Pset', 'Store in custom Pset with selected name'), + ('Common', 'Pset_Common', 'Store in Pset common of the type, e.g. Pset_WallCommon')] + \ + [(name, name, f"Store in Pset called {name}") for name in intersection] + + @staticmethod + def get_pset_common_names(elements): + SaveNumber.pset_common_names = {} + pset_qto = PsetQto(SaveNumber.ifc_file.schema) + for element in elements: + ifc_type = element.is_a() + if ifc_type in SaveNumber.pset_common_names: + continue + pset_names = pset_qto.get_applicable_names(ifc_type) + if (name_guess := "Pset_" + ifc_type.strip("Ifc") + "Common") in pset_names: + pset_common_name = name_guess + elif (name_guess := "Pset_" + ifc_type.strip("Ifc") + "TypeCommon") in pset_names: + pset_common_name = name_guess + elif common_names := [name for name in pset_names if 'Common' in name]: + pset_common_name = common_names[0] + else: + pset_common_name = None + SaveNumber.pset_common_names[ifc_type] = pset_common_name + +class LoadSelection: + + all_objects = [] + selected_objects = [] + possible_types = [] + + @staticmethod + def get_parent_type(settings): + """Get the parent type from the settings.""" + if settings.get("parent_type") == "Other": + return settings.get("parent_type_other") + return settings.get("parent_type") + + @staticmethod + def load_selected_objects(settings): + """Load the selected objects based on the current context.""" + objects = bpy.context.selected_objects if settings.get("selected_toggle") else bpy.context.scene.objects + if settings.get("visible_toggle"): + objects = [obj for obj in objects if obj.visible_get()] + return objects + + @staticmethod + def get_selected_types(settings): + """Get the selected IFC types from the settings, processing if All types are selected""" + selected_types = settings.get("selected_types", []) + if "All" in selected_types: + selected_types = [type_tuple[0] for type_tuple in LoadSelection.possible_types[1:]] + return selected_types + + @staticmethod + def load_possible_types(objects, parent_type): + """Load the available IFC types and their counts from the selected elements.""" + if not objects: + return [("All", "All", "element")], {"All": 0} + + ifc_types = [("All", "All", "element")] + seen_types = [] + number_counts = {"All": 0} + + for obj in objects: + element = tool.Ifc.get_entity(obj) + if element is None or not element.is_a(parent_type): + continue + ifc_type = element.is_a() #Starts with "Ifc", which we can strip by starting from index 3 + + if ifc_type not in seen_types: + seen_types.append(ifc_type) + ifc_types.append((ifc_type, ifc_type[3:], ifc_type[3:].lower())) # Store type as (id, name, name_lower) + number_counts[ifc_type] = 0 + + number_counts["All"] += 1 + number_counts[ifc_type] += 1 + + ifc_types.sort(key=lambda ifc_type: ifc_type[0]) + + return ifc_types, number_counts + + @staticmethod + def update_objects(prop, context): + settings = Settings.to_dict(context.scene.BIMNumberingProperties) + ifc_types, number_counts = LoadSelection.load_possible_types(LoadSelection.selected_objects, LoadSelection.get_parent_type(settings)) + LoadSelection.possible_types = [(id, name + f": {number_counts[id]}", "") for (id, name, _) in ifc_types] + NumberFormatting.update_format_preview(prop, context) + SaveNumber.update_pset_names(prop, context) + SaveNumber.update_ifc_file() + + @staticmethod + def get_possible_types(prop, context): + """Return the list of available types for selection.""" + props = context.scene.BIMNumberingProperties + settings = {"selected_toggle": props.selected_toggle, "visible_toggle": props.visible_toggle} + all_objects = list(bpy.context.scene.objects) + objects = LoadSelection.load_selected_objects(settings) + if all_objects != LoadSelection.all_objects or objects != LoadSelection.selected_objects: + LoadSelection.all_objects = all_objects + LoadSelection.selected_objects = objects + LoadSelection.update_objects(prop, context) + return LoadSelection.possible_types + +class Storeys: + + settings = {"save_type": "Pset", + "pset_name": "Pset_Numbering", + "property_name": "CustomStoreyNumber"} + + @staticmethod + def get_storeys(settings): + """Get all storeys from the current scene.""" + storeys = [] + storey_locations = {} + for obj in bpy.context.scene.objects: + element = tool.Ifc.get_entity(obj) + if element is not None and element.is_a("IfcBuildingStorey"): + storeys.append(element) + storey_locations[element] = ObjectGeometry.get_object_location(obj, settings) + storeys.sort(key=ft.cmp_to_key(lambda a, b: ObjectGeometry.cmp_within_precision(storey_locations[a], storey_locations[b], settings, use_dir=False))) + return storeys + + @staticmethod + def update_custom_storey(props, context): + storeys = Storeys.get_storeys(Settings.to_dict(context.scene.BIMNumberingProperties)) + storey = next((storey for storey in storeys if storey.Name == props.custom_storey), None) + number = SaveNumber.get_number(storey, Storeys.settings) + if number is None: # If the number is not set, use the index + number = storeys.index(storey) + props["_custom_storey_number"] = int(number) + + @staticmethod + def get_custom_storey_number(props): + return int(props.get("_custom_storey_number", 0)) + + @staticmethod + def set_custom_storey_number(props, value): + ifc_file = tool.Ifc.get() + storeys = Storeys.get_storeys(Settings.to_dict(props)) + storey = next((storey for storey in storeys if storey.Name == props.custom_storey), None) + index = storeys.index(storey) + if value == index: # If the value is the same as the index, remove the number + SaveNumber.save_number(ifc_file, storey, None, Storeys.settings) + else: + SaveNumber.save_number(ifc_file, storey, str(value), Storeys.settings) + props["_custom_storey_number"] = value + + @staticmethod + def get_storey_number(element, storeys, settings, storeys_numbers): + storey_number = None + if structure := getattr(element, "ContainedInStructure", None): + storey = getattr(structure[0], "RelatingStructure", None) + if storey and storeys_numbers: + storey_number = storeys_numbers.get(storey, None) + if storey and settings.get("storey_numbering") == "custom": + storey_number = SaveNumber.get_number(storey, Storeys.settings) + if storey_number is not None: + storey_number = int(storey_number) + if storey_number is None: + storey_number = storeys.index(storey) if storey in storeys else None + return storey_number + +class NumberFormatting: + + format_preview = "" + + @staticmethod + def format_number(settings, number_values = (0, 0, None), max_number_values=(100, 100, 1), type_name=""): + """Return the formatted number for the given element, type and storey number""" + format = settings.get("format", None) + if format is None: + return format + if "{E}" in format: + format = format.replace("{E}", NumberingSystems.to_numbering_string(settings.get("initial_element_number", 0) + number_values[0], settings.get("element_numbering"), max_number_values[0])) + if "{T}" in format: + format = format.replace("{T}", NumberingSystems.to_numbering_string(settings.get("initial_type_number", 0) + number_values[1], settings.get("type_numbering"), max_number_values[1])) + if "{S}" in format: + if number_values[2] is not None: + format = format.replace("{S}", NumberingSystems.to_numbering_string(settings.get("initial_storey_number", 0) + number_values[2], settings.get("storey_numbering"), max_number_values[2])) + else: + format = format.replace("{S}", "x") + if "[T]" in format and len(type_name) > 0: + format = format.replace("[T]", type_name[0]) + if "[TT]" in format and len(type_name) > 1: + format = format.replace("[TT]", "".join([c for c in type_name if c.isupper()])) + if "[TF]" in format: + format = format.replace("[TF]", type_name) + return format + + @staticmethod + def get_type_name(settings): + """Return type name used in preview, based on selected types""" + if not settings.get("selected_types"): + #If no types selected, return "Type" + return "Type" + #Get the type name of the selected type, excluding 'IfcElement' + types = settings.get("selected_types") + if 'All' in types: + types.remove('All') + if len(types)>0: + return str(list(types)[0][3:]) + #If all selected, return type name of one of the selected types + all_types = LoadSelection.possible_types + if len(all_types) > 1: + return str(all_types[1][0][3:]) + #If none selected, return "Type" + return "Type" + + @staticmethod + def get_max_numbers(settings, type_name): + """Return number of selected elements used in preview, based on selected types""" + max_element, max_type, max_storey = 0, 0, 0 + if settings.get("storey_numbering") == 'number_ext': + max_storey = len(Storeys.get_storeys(settings)) + if settings.get("element_numbering") == 'number_ext' or settings.get("type_numbering") == 'number_ext': + if not settings.get("selected_types"): + return max_element, max_type, max_storey + type_counts = {type_tuple[0]: int(''.join([c for c in type_tuple[1] if c.isdigit()])) \ + for type_tuple in LoadSelection.possible_types} + if "All" in settings.get("selected_types"): + max_element = type_counts.get("All", 0) + else: + max_element = sum(type_counts.get(t, 0) for t in LoadSelection.get_selected_types(settings)) + max_type = type_counts.get('Ifc' + type_name, max_element) + return max_element, max_type, max_storey + + @staticmethod + def update_format_preview(prop, context): + settings = Settings.to_dict(context.scene.BIMNumberingProperties) + type_name = NumberFormatting.get_type_name(settings) + NumberFormatting.format_preview = NumberFormatting.format_number(settings, (0, 0, 0), NumberFormatting.get_max_numbers(settings, type_name), type_name) + +class NumberingSystems: + + @staticmethod + def to_number(i): + """Convert a number to a string.""" + if i < 0: + return "(" + str(-i) + ")" + return str(i) + + @staticmethod + def to_number_ext(i, length=2): + """Convert a number to a string with leading zeroes.""" + if i < 0: + return "(" + NumberingSystems.to_number_ext(-i, length) + ")" + res = str(i) + while len(res) < length: + res = "0" + res + return res + + @staticmethod + def to_letter(i, upper=False): + """Convert a number to a letter or sequence of letters.""" + if i == 0: + return "0" + if i < 0: + return "(" + NumberingSystems.to_letter(-i, upper) + ")" + + num2alphadict = dict(zip(range(1, 27), string.ascii_uppercase if upper else string.ascii_lowercase)) + res = "" + numloops = (i-1) // 26 + + if numloops > 0: + res = res + NumberingSystems.to_letter(numloops, upper) + + remainder = i % 26 + if remainder == 0: + remainder += 26 + return res + num2alphadict[remainder] + + @staticmethod + def get_numberings(): + return { + "number": NumberingSystems.to_number, + "number_ext": NumberingSystems.to_number_ext, + "lower_letter": NumberingSystems.to_letter, + "upper_letter": lambda x: NumberingSystems.to_letter(x, True) + } + + def to_numbering_string(i, numbering_system, max_number): + """Convert a number to a string based on the numbering system.""" + if numbering_system == "number_ext": + # Determine the length based on the maximum number + length = len(str(max_number)) + return NumberingSystems.to_number_ext(i, length) + if numbering_system == "custom": + return NumberingSystems.to_number(i) + return NumberingSystems.get_numberings()[numbering_system](i) + + def get_numbering_preview(numbering_system, initial): + """Get a preview of the numbering string for a given number and type.""" + numbers = [NumberingSystems.to_numbering_string(i, numbering_system, 10) for i in range(initial, initial + 3)] + return "{0}, {1}, {2}, ...".format(*numbers) + +class ObjectGeometry: + @staticmethod + def get_object_location(obj, settings): + """Get the location of a Blender object.""" + mat = obj.matrix_world + bbox_vectors = [mat @ Vector(b) for b in obj.bound_box] + + if settings.get("location_type", "CENTER") == "CENTER": + return 0.125 * sum(bbox_vectors, Vector()) + + elif settings.get("location_type") == "BOUNDING_BOX": + bbox_vector = Vector((0, 0, 0)) + # Determine the coordinates based on the direction and axis order + direction = (int(settings.get("x_direction")), int(settings.get("y_direction")), int(settings.get("z_direction"))) + for i in range(3): + if direction[i] == -1: + bbox_vector[i] = max(v[i] for v in bbox_vectors) + else: + bbox_vector[i] = min(v[i] for v in bbox_vectors) + return bbox_vector + + + @staticmethod + def get_object_dimensions(obj): + """Get the dimensions of a Blender object.""" + # Get the object's bounding box corners in world space + mat = obj.matrix_world + coords = [mat @ Vector(corner) for corner in obj.bound_box] + + # Compute min and max coordinates + min_corner = Vector((min(v[i] for v in coords) for i in range(3))) + max_corner = Vector((max(v[i] for v in coords) for i in range(3))) + + # Dimensions in global space + dimensions = max_corner - min_corner + return dimensions + + @staticmethod + def cmp_within_precision(a, b, settings, use_dir=True): + """Compare two vectors within a given precision.""" + direction = (int(settings.get("x_direction", 1)), int(settings.get("y_direction", 1)), int(settings.get("z_direction", 1))) if use_dir else (1, 1, 1) + for axis in settings.get("axis_order", "XYZ"): + idx = "XYZ".index(axis) + diff = (a[idx] - b[idx]) * direction[idx] + if 1000 * abs(diff) > settings.get("precision", [0, 0, 0])[idx]: + return 1 if diff > 0 else -1 + return 0 + +class ElementGeometry: + @staticmethod + def get_element_location(element, settings): + """Get the location of an IFC element.""" + geom_settings = geom.settings() + geom_settings.set("use-world-coords", True) + shape = geom.create_shape(geom_settings, element) + + verts = ifc_shape.get_shape_vertices(shape, shape.geometry) + if settings.get("location_type") == "CENTER": + return np.mean(verts, axis=0) + + elif settings.get("location_type") == "BOUNDING_BOX": + direction = (int(settings.get("x_direction", 1)), int(settings.get("y_direction", 1)), int(settings.get("z_direction", 1))) + bbox_min = np.min(verts, axis=0) + bbox_max = np.max(verts, axis=0) + bbox_vector = np.zeros(3) + for i in range(3): + if direction[i] == -1: + bbox_vector[i] = bbox_max[i] + else: + bbox_vector[i] = bbox_min[i] + return bbox_vector + + + @staticmethod + def get_element_dimensions(element): + """Get the dimensions of an IFC element.""" + geom_settings = geom.settings() + geom_settings.set("use-world-coords", True) + shape = geom.create_shape(geom_settings, element) + + verts = ifc_shape.get_shape_vertices(shape, shape.geometry) + bbox_min = np.min(verts, axis=0) + bbox_max = np.max(verts, axis=0) + return bbox_max - bbox_min + +class Settings: + + pset_name = "Pset_NumberingSettings" + + settings_names = None + + def import_settings(filepath): + """Import settings from a JSON file, e.g. as exported from the UI""" + with open(filepath, 'r') as file: + settings = json.load(file) + return settings + + def default_settings(): + """"Return a default dictionary of settings for numbering elements.""" + return { + "x_direction": 1, + "y_direction": 1, + "z_direction": 1, + "axis_order": "ZYX", + "location_type": "CENTER", + "precision": (1, 1, 1), + "initial_element_number": 1, + "initial_type_number": 1, + "initial_storey_number": 0, + "element_numbering": "number", + "type_numbering": "number", + "storey_numbering": "number", + "format": "E{E}S{S}[T]{T}", + "save_type": "Attribute", + "attribute_name": "Tag", + "pset_name": "Common", + "custom_pset_name": "Pset_Numbering", + "property_name": "Number" + } + + @staticmethod + def to_dict(props): + """Convert the properties to a dictionary for saving.""" + return { + "selected_toggle": props.selected_toggle, + "visible_toggle": props.visible_toggle, + "parent_type": props.parent_type, + "parent_type_other": props.parent_type_other, + "selected_types": list(props.selected_types), + "x_direction": props.x_direction, + "y_direction": props.y_direction, + "z_direction": props.z_direction, + "axis_order": props.axis_order, + "location_type": props.location_type, + "precision": (props.precision[0], props.precision[1], props.precision[2]), + "initial_element_number": props.initial_element_number, + "initial_type_number": props.initial_type_number, + "initial_storey_number": props.initial_storey_number, + "element_numbering": props.element_numbering, + "type_numbering": props.type_numbering, + "storey_numbering": props.storey_numbering, + "format": props.format, + "save_type": props.save_type, + "attribute_name": props.attribute_name, + "attribute_name_other": props.attribute_name_other, + "pset_name": props.pset_name, + "custom_pset_name": props.custom_pset_name, + "property_name": props.property_name, + "remove_toggle": props.remove_toggle, + "check_duplicates_toggle": props.check_duplicates_toggle + } + + @staticmethod + def save_settings(operator, props, ifc_file): + """Save the numbering settings to the IFC file.""" + # Save multiple settings by name in a dictionary + project = ifc_file.by_type("IfcProject")[0] + settings_name = props.settings_name.strip() + if not settings_name: + operator.report({'ERROR'}, "Please enter a name for the settings.") + return {'CANCELLED'} + if pset_settings := get_pset(project, Settings.pset_name): + pset_settings = ifc_file.by_id(pset_settings["id"]) + else: + pset_settings = ifc_api.run("pset.add_pset", ifc_file, product=project, name=Settings.pset_name) + if not pset_settings: + operator.report({'ERROR'}, "Could not create property set") + return {'CANCELLED'} + ifc_api.run("pset.edit_pset", ifc_file, pset=pset_settings, properties={settings_name: json.dumps(Settings.to_dict(props))}) + Settings.settings_names.add(settings_name) + operator.report({'INFO'}, f"Saved settings '{settings_name}' to IFCProject element") + return {'FINISHED'} + + @staticmethod + def read_settings(operator, settings, props): + for key, value in settings.items(): + if key == "selected_types": + possible_type_names = [t[0] for t in LoadSelection.possible_types] + value = set([type_name for type_name in value if type_name in possible_type_names]) + try: + setattr(props, key, value) + except Exception as e: + operator.report({'ERROR'}, f"Failed to set property {key} to {value}. Error: {e}") + + @staticmethod + def get_settings_names(): + ifc_file = tool.Ifc.get() + if Settings.settings_names is None: + if pset := get_pset(ifc_file.by_type("IfcProject")[0], Settings.pset_name): + names = set(pset.keys()) + names.remove("id") + else: + names = set() + Settings.settings_names = names + return Settings.settings_names + + @staticmethod + def load_settings(operator, props, ifc_file): + # Load selected settings by name + settings_name = props.saved_settings + if settings_name == "NONE": + operator.report({'WARNING'}, "No saved settings to load.") + return {'CANCELLED'} + if pset_settings := get_pset(ifc_file.by_type("IfcProject")[0], Settings.pset_name): + settings = pset_settings.get(settings_name, None) + if settings is None: + operator.report({'WARNING'}, f"Settings '{settings_name}' not found.") + return {'CANCELLED'} + settings = json.loads(settings) + Settings.read_settings(operator, settings, props) + operator.report({'INFO'}, f"Loaded settings '{settings_name}' from IFCProject element") + return {'FINISHED'} + else: + operator.report({'WARNING'}, "No settings found") + return {'CANCELLED'} + + @staticmethod + def delete_settings(operator, props): + ifc_file = tool.Ifc.get() + settings_name = props.saved_settings + if settings_name == "NONE": + operator.report({'WARNING'}, "No saved settings to delete.") + return {'CANCELLED'} + if pset_settings := get_pset(ifc_file.by_type("IfcProject")[0], Settings.pset_name): + if settings_name in pset_settings: + pset_settings = ifc_file.by_id(pset_settings["id"]) + ifc_api.run("pset.edit_pset", ifc_file, pset=pset_settings, properties={settings_name: None}, should_purge=True) + Settings.settings_names.remove(settings_name) + operator.report({'INFO'}, f"Deleted settings '{settings_name}' from IFCProject element") + + if not pset_settings.HasProperties: + ifc_api.run("pset.remove_pset", ifc_file, product=ifc_file.by_type("IfcProject")[0], pset=pset_settings) + return {'FINISHED'} + else: + operator.report({'WARNING'}, f"Settings '{settings_name}' not found.") + return {'CANCELLED'} + else: + operator.report({'WARNING'}, "No settings found") + return {'CANCELLED'} + + @staticmethod + def clear_settings(operator, props): + ifc_file = tool.Ifc.get() + project = ifc_file.by_type("IfcProject")[0] + if pset_settings := get_pset(project, Settings.pset_name): + pset_settings = ifc_file.by_id(pset_settings["id"]) + ifc_api.run("pset.remove_pset", ifc_file, product=project, pset=pset_settings) + operator.report({'INFO'}, f"Cleared settings from IFCProject element") + Settings.settings_names = set() + return {'FINISHED'} + else: + operator.report({'WARNING'}, "No settings found") + return {'CANCELLED'} diff --git a/src/bonsai/bonsai/bim/module/numbering/workspace.py b/src/bonsai/bonsai/bim/module/numbering/workspace.py new file mode 100644 index 00000000000..0d689d3415e --- /dev/null +++ b/src/bonsai/bonsai/bim/module/numbering/workspace.py @@ -0,0 +1,49 @@ +# Bonsai - OpenBIM Blender Add-on +# Copyright (C) 2023 @Andrej730 +# +# 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 os +import bonsai.tool as tool +from bpy.types import WorkSpaceTool + + +class NumberingTool(WorkSpaceTool): + bl_space_type = "VIEW_3D" + bl_context_mode = "OBJECT" + bl_idname = "bim.numbering_tool" + bl_label = "Numbering Tool" + bl_description = "Assign or remove numbers from elements" + bl_icon = os.path.join(os.path.dirname(__file__), "ops.authoring.covering") + bl_widget = None + + @classmethod + def draw_settings(cls, context, layout, ws_tool): + NumberingToolUI.draw(context, layout) + +class NumberingToolUI: + @classmethod + def draw(cls, context, layout): + cls.layout = layout + cls.props = tool.Numbering.get_numbering_props() + + row = cls.layout.row(align=True) + if not tool.Ifc.get(): + row.label(text="No IFC Project", icon="ERROR") + return + + pass diff --git a/src/bonsai/bonsai/core/tool.py b/src/bonsai/bonsai/core/tool.py index 4ab7e9d9ab4..52e30977cc9 100644 --- a/src/bonsai/bonsai/core/tool.py +++ b/src/bonsai/bonsai/core/tool.py @@ -607,6 +607,9 @@ def disable_editing(cls, obj): pass def enable_editing(cls, obj): pass def get_container(cls, element): pass +@interface +class Numbering: + def get_numbering_props(cls): pass @interface class Patch: diff --git a/src/bonsai/bonsai/tool/blender.py b/src/bonsai/bonsai/tool/blender.py index ad2285f6a14..0f25681ada4 100644 --- a/src/bonsai/bonsai/tool/blender.py +++ b/src/bonsai/bonsai/tool/blender.py @@ -1308,6 +1308,7 @@ def register_toolbar(cls): import bonsai.bim.module.spatial.workspace as ws_spatial import bonsai.bim.module.structural.workspace as ws_structural import bonsai.bim.module.covering.workspace as ws_covering + import bonsai.bim.module.numbering.workspace as ws_numbering if bpy.app.background: return @@ -1330,6 +1331,10 @@ def register_toolbar(cls): bpy.utils.register_tool( ws_covering.CoveringTool, after={"bim.structural_tool"}, separator=False, group=False ) + bpy.utils.register_tool( + ws_numbering.NumberingTool, after={"bim.numbering_tool"}, separator=False, group=False + ) + except: pass @@ -1340,6 +1345,7 @@ def unregister_toolbar(cls): import bonsai.bim.module.spatial.workspace as ws_spatial import bonsai.bim.module.structural.workspace as ws_structural import bonsai.bim.module.covering.workspace as ws_covering + import bonsai.bim.module.numbering.workspace as ws_numbering if bpy.app.background: return @@ -1358,6 +1364,7 @@ def unregister_toolbar(cls): bpy.utils.unregister_tool(ws_spatial.SpatialTool) bpy.utils.unregister_tool(ws_structural.StructuralTool) bpy.utils.unregister_tool(ws_covering.CoveringTool) + bpy.utils.unregister_tool(ws_numbering.NumberingTool) except: pass diff --git a/src/bonsai/bonsai/tool/numbering.py b/src/bonsai/bonsai/tool/numbering.py new file mode 100644 index 00000000000..1fe7f03d0c0 --- /dev/null +++ b/src/bonsai/bonsai/tool/numbering.py @@ -0,0 +1,31 @@ +# Bonsai - OpenBIM Blender Add-on +# Copyright (C) 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.core.tool +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + from bonsai.bim.module.numbering.prop import ( + BIMNumberingProperties, + ) + +class Model(bonsai.core.tool.Model): + @classmethod + def get_numbering_props(cls) -> BIMNumberingProperties: + return bpy.context.scene.BIMNumberingProperties diff --git a/src/ifcopenshell-python/ifcopenshell/ifcopenshell_wrapper.pyi b/src/ifcopenshell-python/ifcopenshell/ifcopenshell_wrapper.pyi index 659a062cfd0..2c0a6873974 100644 --- a/src/ifcopenshell-python/ifcopenshell/ifcopenshell_wrapper.pyi +++ b/src/ifcopenshell-python/ifcopenshell/ifcopenshell_wrapper.pyi @@ -94,7 +94,6 @@ class FileDescription(HeaderEntity): class FileName(HeaderEntity): name: str - """Updated automatically only on ``file.write``.""" time_stamp: str author: tuple[str, ...] organization: tuple[str, ...] @@ -136,6 +135,11 @@ class BRepElement(Element): @property def volume(self): ... +class CgalEmitOriginalEdges: + name: Any + description: Any + defaultvalue: Any + class ColladaSerializer(WriteOnlyGeometrySerializer): def finalize(self): ... def isTesselated(self): ... @@ -146,6 +150,11 @@ class ColladaSerializer(WriteOnlyGeometrySerializer): def write(self, *args): ... def writeHeader(self): ... +class ComputeCurvature: + name: Any + description: Any + defaultvalue: Any + class ConversionResult: def ItemId(self): ... def Placement(self): ... @@ -264,6 +273,16 @@ class Element: class FileSchema(HeaderEntity): schema_identifiers: Any +class FunctionStepParam: + name: Any + description: Any + defaultvalue: Any + +class FunctionStepType: + name: Any + description: Any + defaultvalue: Any + class GeometrySerializer: READ_BREP: Any READ_TRIANGULATION: Any @@ -289,7 +308,7 @@ class HdfSerializer(GeometrySerializer): def isTesselated(self): ... def read(self, *args): ... def ready(self): ... - def remove(self, guid: str) -> None: ... + def remove(self, guid): ... def setFile(self, arg2): ... def setUnitNameAndMagnitude(self, arg2, arg3): ... def write(self, *args): ... @@ -335,8 +354,6 @@ class Iterator: def getLog(self): ... def get_native(self): ... def get_object(self, id): ... - def get_task_items(self): ... - def get_task_products(self): ... def had_error_processing_elements(self): ... def initialize(self) -> bool: """Return true if the iterator is initialized with any elements, false otherwise.""" @@ -359,7 +376,7 @@ class Iterator: """ ... - def set_cache(self, cache: Union[GeometrySerializer, None]) -> None: ... + def set_cache(self, cache: GeometrySerializer): ... def unit_magnitude(self): ... def unit_name(self): ... @@ -504,7 +521,6 @@ class Triangulation(Representation): def edges(self) -> tuple[int, ...]: ... @property def edges_buffer(self) -> bytes: ... - @property def edges_item_ids(self) -> tuple[int, ...]: ... @property def edges_item_ids_buffer(self) -> bytes: ... @@ -550,6 +566,11 @@ class TriangulationElement(Element): def geometry(self) -> Triangulation: ... def geometry_pointer(self): ... +class TriangulationType: + name: Any + description: Any + defaultvalue: Any + class TtlWktSerializer(WriteOnlyGeometrySerializer): def finalize(self): ... def isTesselated(self): ... @@ -1038,15 +1059,6 @@ class geom_item(item): matrix: Any surface_style: Any -class geometry_conversion_result: - breps: Any - elements: Any - index: Any - item: Any - products: Any - products_2: Any - representation: Any - class geometry_exception: def what(self): ... @@ -1059,8 +1071,6 @@ class gradient_function(function_item): def kind(self): ... def start(self): ... -class equal_functor: ... -class hash_functor: ... class horizontal_plan_at_element: ... class implicit_item(geom_item): ... @@ -1126,7 +1136,6 @@ class loop: fi: Any def calc_hash(self): ... def calculate_linear_edge_curves(self): ... - def centroid(self): ... @property def children(self): ... def clone_(self): ... @@ -1290,7 +1299,6 @@ class select_type(declaration): class shell: closed: Any def calc_hash(self): ... - def centroid(self): ... @property def children(self): ... def clone_(self): ... @@ -1352,8 +1360,8 @@ class style(item): - IFC style id if style assigned to the representation items directly or through material with a style; - IFC material id if both true: - - element has a material without a style; - - there are parts of the geometry that has no other style assigned to them; + - element has a material without a style; + - there are parts of the geometry that has no other style assigned to them; - -1 in case if there is no material; - 0 in case if there are default materials used. """ @@ -1583,7 +1591,6 @@ def clear_schemas(): ... def construct_iterator_with_include_exclude(geometry_library, settings, file, elems, include, num_threads): ... def construct_iterator_with_include_exclude_globalid(geometry_library, settings, file, elems, include, num_threads): ... def construct_iterator_with_include_exclude_id(geometry_library, settings, file, elems, include, num_threads): ... -def convert_loop_to_function_item(loop): ... def create_box(*args): ... def create_epeck(*args): ... def create_shape(*args): ... From f68f2818bfe3004cd040aac1c1b9aa1c78549ebc Mon Sep 17 00:00:00 2001 From: BjornFidder Date: Tue, 26 Aug 2025 16:05:56 +0200 Subject: [PATCH 2/9] Refactor and stub out BIM numbering module Commented out most functionality in the numbering UI and properties to prepare for refactoring. Added new data and core files for numbering, updated imports, and introduced stubs for project retrieval. Updated workspace icon and method signature, and fixed import paths for utility functions. --- .../bonsai/bim/module/numbering/__init__.py | 24 +- .../bonsai/bim/module/numbering/data.py | 44 ++ .../bonsai/bim/module/numbering/operator.py | 2 +- .../numbering/ops.authoring.numbering.dat | Bin 0 -> 368 bytes .../bonsai/bim/module/numbering/prop.py | 525 +++++++++--------- src/bonsai/bonsai/bim/module/numbering/ui.py | 203 +++---- .../bonsai/bim/module/numbering/util.py | 4 +- .../bonsai/bim/module/numbering/workspace.py | 5 +- src/bonsai/bonsai/core/numbering.py | 18 + src/bonsai/bonsai/core/tool.py | 1 + src/bonsai/bonsai/tool/__init__.py | 1 + src/bonsai/bonsai/tool/numbering.py | 15 +- 12 files changed, 462 insertions(+), 380 deletions(-) create mode 100644 src/bonsai/bonsai/bim/module/numbering/data.py create mode 100644 src/bonsai/bonsai/bim/module/numbering/ops.authoring.numbering.dat create mode 100644 src/bonsai/bonsai/core/numbering.py diff --git a/src/bonsai/bonsai/bim/module/numbering/__init__.py b/src/bonsai/bonsai/bim/module/numbering/__init__.py index b4dec49fc64..182b5516e63 100644 --- a/src/bonsai/bonsai/bim/module/numbering/__init__.py +++ b/src/bonsai/bonsai/bim/module/numbering/__init__.py @@ -19,22 +19,22 @@ import bpy from . import ui, operator, prop, workspace -# Registration -classes = (operator.AssignNumbers, - operator.RemoveNumbers, - operator.SaveSettings, - operator.LoadSettings, - operator.ExportSettings, - operator.ImportSettings, - operator.DeleteSettings, - operator.ClearSettings, - operator.ShowMessage, +classes = ( + # operator.AssignNumbers, + # operator.RemoveNumbers, + # operator.SaveSettings, + # operator.LoadSettings, + # operator.ExportSettings, + # operator.ImportSettings, + # operator.DeleteSettings, + # operator.ClearSettings, + # operator.ShowMessage, prop.BIMNumberingProperties, ui.BIM_PT_Numbering) def register(): - if not bpy.app.background: - bpy.utils.register_tool(workspace.NumberingTool, after={"bim.numbering_tool"}, separator=False, group=False) + # if not bpy.app.background: + # bpy.utils.register_tool(workspace.NumberingTool, after={"bim.explore_tool"}, separator=False, group=False) bpy.types.Scene.BIMNumberingProperties = bpy.props.PointerProperty(type=prop.BIMNumberingProperties) def unregister(): diff --git a/src/bonsai/bonsai/bim/module/numbering/data.py b/src/bonsai/bonsai/bim/module/numbering/data.py new file mode 100644 index 00000000000..2f1b0ac2714 --- /dev/null +++ b/src/bonsai/bonsai/bim/module/numbering/data.py @@ -0,0 +1,44 @@ +# Bonsai - OpenBIM Blender Add-on +# Copyright (C) 2022 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 bonsai.tool as tool + +def refresh(): + NumberingData.is_loaded = False + +class NumberingData: + data = {} + is_loaded = False + + @classmethod + def load(cls): + cls.data = { + "has_project": cls.has_project(), + "project": cls.project(), + } + cls.is_loaded = True + + @classmethod + def has_project(cls): + return bool(tool.Ifc.get()) + + @classmethod + def project(cls): + ifc = tool.Ifc.get() + if ifc: + return ifc.by_type("IfcProject")[0] or None diff --git a/src/bonsai/bonsai/bim/module/numbering/operator.py b/src/bonsai/bonsai/bim/module/numbering/operator.py index 5382af78302..a781553f440 100644 --- a/src/bonsai/bonsai/bim/module/numbering/operator.py +++ b/src/bonsai/bonsai/bim/module/numbering/operator.py @@ -22,7 +22,7 @@ import json import functools as ft -from .util import Settings, LoadSelection, NumberFormatting, SaveNumber, Storeys, ObjectGeometry, get_id +from bonsai.bim.module.numbering.util import Settings, LoadSelection, NumberFormatting, SaveNumber, Storeys, ObjectGeometry, get_id class UndoOperator: @staticmethod diff --git a/src/bonsai/bonsai/bim/module/numbering/ops.authoring.numbering.dat b/src/bonsai/bonsai/bim/module/numbering/ops.authoring.numbering.dat new file mode 100644 index 0000000000000000000000000000000000000000..bb9091c0b171f417ce4e7493db8434c17494e423 GIT binary patch literal 368 zcma)$p$-Bu42Jg}XOSo7Cvh_vg1twmVun|kJwtMrOpud^&24t#EOzP=-lKGcCKHgb zzVU;p;BKL|u2OdpRin+c@psirFCu!0Na_8b!V8j7~M1F5ar>5fQOYmne#24|*F zi1T#. +from typing import TYPE_CHECKING + +import bpy + from bpy.types import PropertyGroup from bpy.props import ( IntProperty, @@ -24,273 +28,276 @@ BoolProperty, IntVectorProperty ) -from .util import Settings, LoadSelection, NumberFormatting, NumberingSystems, SaveNumber, Storeys +#from .util import Settings, LoadSelection, NumberFormatting, NumberingSystems, SaveNumber, Storeys class BIMNumberingProperties(PropertyGroup): - settings_name : StringProperty( + settings_name: StringProperty( name="Settings name", description="Name for saving the current settings", default="" ) # pyright: ignore[reportInvalidTypeForm] - def get_saved_settings_items(self, context): - settings_names = Settings.get_settings_names() - if not settings_names: - return [("NONE", "No saved settings", "")] - return [(name, name, "") for name in settings_names] - - saved_settings : EnumProperty( - name="Load settings", - description="Select which saved settings to load", - items=get_saved_settings_items - ) # pyright: ignore[reportInvalidTypeForm] - - selected_toggle: BoolProperty( - name="Selected only", - description="Only number selected objects", - default=False, - update=NumberFormatting.update_format_preview - ) # pyright: ignore[reportInvalidTypeForm] - - visible_toggle: BoolProperty( - name="Visible only", - description="Only number visible objects", - default=False, - update=NumberFormatting.update_format_preview - ) # pyright: ignore[reportInvalidTypeForm] + # def get_saved_settings_items(self, context): + # settings_names = Settings.get_settings_names() + # if not settings_names: + # return [("NONE", "No saved settings", "")] + # return [(name, name, "") for name in settings_names] + + # saved_settings : EnumProperty( + # name="Load settings", + # description="Select which saved settings to load", + # items=get_saved_settings_items + # ) # pyright: ignore[reportInvalidTypeForm] + + # selected_toggle: BoolProperty( + # name="Selected only", + # description="Only number selected objects", + # default=False, + # update=NumberFormatting.update_format_preview + # ) # pyright: ignore[reportInvalidTypeForm] + + # visible_toggle: BoolProperty( + # name="Visible only", + # description="Only number visible objects", + # default=False, + # update=NumberFormatting.update_format_preview + # ) # pyright: ignore[reportInvalidTypeForm] - parent_type: EnumProperty( - name="Parent Type", - description="Select the parent type for numbering", - items=[ - ("IfcElement", "IfcElement", "Number IFC elements"), - ("IfcProduct", "IfcProduct", "Number IFC products"), - ("IfcGridAxis", "IfcGridAxis", "Number IFC grid axes"), - ("Other", "Other", "Input which IFC entities to number") - ], - default="IfcElement", - update = LoadSelection.update_objects - ) # pyright: ignore[reportInvalidTypeForm] - - parent_type_other : StringProperty( - name="Other Parent Type", - description="Input which IFC entities to number", - default="IfcElement", - update = LoadSelection.update_objects - ) # pyright: ignore[reportInvalidTypeForm] - - def update_selected_types(self, context): - NumberFormatting.update_format_preview(self, context) - SaveNumber.update_pset_names(self, context) - - selected_types: EnumProperty( - name="Of type", - description="Select which types of elements to number", - items= LoadSelection.get_possible_types, - options={'ENUM_FLAG'}, - update=update_selected_types - ) # pyright: ignore[reportInvalidTypeForm] - - x_direction: EnumProperty( - name="X", - description="Select axis direction for numbering elements", - items=[ - ("1", "+", "Number elements in order of increasing X coordinate"), - ("-1", "-", "Number elements in order of decreasing X coordinate") - ], - default="1", - ) # pyright: ignore[reportInvalidTypeForm] - - y_direction: EnumProperty( - name="Y", - description="Select axis direction for numbering elements", - items=[ - ("1", "+", "Number elements in order of increasing Y coordinate"), - ("-1", "-", "Number elements in order of decreasing Y coordinate") - ], - default="1" - ) # pyright: ignore[reportInvalidTypeForm] - - z_direction: EnumProperty( - name="Z", - description="Select axis direction for numbering elements", - items=[ - ("1", "+", "Number elements in order of increasing Z coordinate"), - ("-1", "-", "Number elements in order of decreasing Z coordinate") - ], - default="1" - ) # pyright: ignore[reportInvalidTypeForm] - - axis_order: EnumProperty( - name="Axis order", - description="Order of axes in numbering elements", - items=[ - ("XYZ", "X, Y, Z", "Number elements in X, Y, Z order"), - ("XZY", "X, Z, Y", "Number elements in X, Z, Y order"), - ("YXZ", "Y, X, Z", "Number elements in Y, X, Z order"), - ("YZX", "Y, Z, X", "Number elements in Y, Z, X order"), - ("ZXY", "Z, X, Y", "Number elements in Z, X, Y order"), - ("ZYX", "Z, Y, X", "Number elements in Z, Y, X order") - ], - default="ZYX" - ) # pyright: ignore[reportInvalidTypeForm] - - location_type: EnumProperty( - name="Reference location", - description="Location to use for sorting elements", - items=[ - ("CENTER", "Center", "Use object center for sorting"), - ("BOUNDING_BOX", "Bounding Box", "Use object bounding box for sorting"), - ], - default="BOUNDING_BOX" - ) # pyright: ignore[reportInvalidTypeForm] - - precision: IntVectorProperty( - name="Precision", - description="Precision for sorting elements in X, Y and Z direction", - default=(1, 1, 1), - min=1, - size=3 - ) # pyright: ignore[reportInvalidTypeForm] - - initial_element_number: IntProperty( - name="{E}", - description="Initial number for numbering elements", - default=1, - update=NumberFormatting.update_format_preview - ) # pyright: ignore[reportInvalidTypeForm] - - initial_type_number: IntProperty( - name="{T}", - description="Initial number for numbering elements within type", - default=1, - update=NumberFormatting.update_format_preview - ) # pyright: ignore[reportInvalidTypeForm] - - initial_storey_number: IntProperty( - name="{S}", - description="Initial number for numbering storeys", - default=0, - update=NumberFormatting.update_format_preview - ) # pyright: ignore[reportInvalidTypeForm] - - numberings_enum = lambda self, initial : [ - ("number", NumberingSystems.get_numbering_preview("number", initial), "Use numbers. Negative numbers are shown with brackets"), - ("number_ext", NumberingSystems.get_numbering_preview("number_ext", initial), "Use numbers padded with zeroes to a fixed length based on the number of objects selected. Negative numbers are shown with brackets."), - ("lower_letter", NumberingSystems.get_numbering_preview("lower_letter", initial), "Use lowercase letters, continuing with aa, ab, ... where negative numbers are shown with brackets."), - ("upper_letter", NumberingSystems.get_numbering_preview("upper_letter", initial), "Use uppercase letters, continuing with AA, AB, ... where negative numbers are shown with brackets."), - ] - - custom_storey_enum = [("custom", "Custom", "Use custom numbering for storeys")] - - element_numbering: EnumProperty( - name="{E}", - description="Select numbering system for element numbering", - items=lambda self, context: self.numberings_enum(self.initial_element_number), - update=NumberFormatting.update_format_preview - ) # pyright: ignore[reportInvalidTypeForm] - - type_numbering: EnumProperty( - name="{T}", - description="Select numbering system for numbering within types", - items=lambda self, context: self.numberings_enum(self.initial_type_number), - update=NumberFormatting.update_format_preview - ) # pyright: ignore[reportInvalidTypeForm] - - def update_storey_numbering(self, context): - if self.storey_numbering == "custom": - self.initial_storey_number = 0 + # parent_type: EnumProperty( + # name="Parent Type", + # description="Select the parent type for numbering", + # items=[ + # ("IfcElement", "IfcElement", "Number IFC elements"), + # ("IfcProduct", "IfcProduct", "Number IFC products"), + # ("IfcGridAxis", "IfcGridAxis", "Number IFC grid axes"), + # ("Other", "Other", "Input which IFC entities to number") + # ], + # default="IfcElement", + # update = LoadSelection.update_objects + # ) # pyright: ignore[reportInvalidTypeForm] + + # parent_type_other : StringProperty( + # name="Other Parent Type", + # description="Input which IFC entities to number", + # default="IfcElement", + # update = LoadSelection.update_objects + # ) # pyright: ignore[reportInvalidTypeForm] + + # def update_selected_types(self, context): + # NumberFormatting.update_format_preview(self, context) + # SaveNumber.update_pset_names(self, context) + + # selected_types: EnumProperty( + # name="Of type", + # description="Select which types of elements to number", + # items= LoadSelection.get_possible_types, + # options={'ENUM_FLAG'}, + # update=update_selected_types + # ) # pyright: ignore[reportInvalidTypeForm] + + # x_direction: EnumProperty( + # name="X", + # description="Select axis direction for numbering elements", + # items=[ + # ("1", "+", "Number elements in order of increasing X coordinate"), + # ("-1", "-", "Number elements in order of decreasing X coordinate") + # ], + # default="1", + # ) # pyright: ignore[reportInvalidTypeForm] + + # y_direction: EnumProperty( + # name="Y", + # description="Select axis direction for numbering elements", + # items=[ + # ("1", "+", "Number elements in order of increasing Y coordinate"), + # ("-1", "-", "Number elements in order of decreasing Y coordinate") + # ], + # default="1" + # ) # pyright: ignore[reportInvalidTypeForm] + + # z_direction: EnumProperty( + # name="Z", + # description="Select axis direction for numbering elements", + # items=[ + # ("1", "+", "Number elements in order of increasing Z coordinate"), + # ("-1", "-", "Number elements in order of decreasing Z coordinate") + # ], + # default="1" + # ) # pyright: ignore[reportInvalidTypeForm] + + # axis_order: EnumProperty( + # name="Axis order", + # description="Order of axes in numbering elements", + # items=[ + # ("XYZ", "X, Y, Z", "Number elements in X, Y, Z order"), + # ("XZY", "X, Z, Y", "Number elements in X, Z, Y order"), + # ("YXZ", "Y, X, Z", "Number elements in Y, X, Z order"), + # ("YZX", "Y, Z, X", "Number elements in Y, Z, X order"), + # ("ZXY", "Z, X, Y", "Number elements in Z, X, Y order"), + # ("ZYX", "Z, Y, X", "Number elements in Z, Y, X order") + # ], + # default="ZYX" + # ) # pyright: ignore[reportInvalidTypeForm] + + # location_type: EnumProperty( + # name="Reference location", + # description="Location to use for sorting elements", + # items=[ + # ("CENTER", "Center", "Use object center for sorting"), + # ("BOUNDING_BOX", "Bounding Box", "Use object bounding box for sorting"), + # ], + # default="BOUNDING_BOX" + # ) # pyright: ignore[reportInvalidTypeForm] + + # precision: IntVectorProperty( + # name="Precision", + # description="Precision for sorting elements in X, Y and Z direction", + # default=(1, 1, 1), + # min=1, + # size=3 + # ) # pyright: ignore[reportInvalidTypeForm] + + # initial_element_number: IntProperty( + # name="{E}", + # description="Initial number for numbering elements", + # default=1, + # update=NumberFormatting.update_format_preview + # ) # pyright: ignore[reportInvalidTypeForm] + + # initial_type_number: IntProperty( + # name="{T}", + # description="Initial number for numbering elements within type", + # default=1, + # update=NumberFormatting.update_format_preview + # ) # pyright: ignore[reportInvalidTypeForm] + + # initial_storey_number: IntProperty( + # name="{S}", + # description="Initial number for numbering storeys", + # default=0, + # update=NumberFormatting.update_format_preview + # ) # pyright: ignore[reportInvalidTypeForm] + + # numberings_enum = lambda self, initial : [ + # ("number", NumberingSystems.get_numbering_preview("number", initial), "Use numbers. Negative numbers are shown with brackets"), + # ("number_ext", NumberingSystems.get_numbering_preview("number_ext", initial), "Use numbers padded with zeroes to a fixed length based on the number of objects selected. Negative numbers are shown with brackets."), + # ("lower_letter", NumberingSystems.get_numbering_preview("lower_letter", initial), "Use lowercase letters, continuing with aa, ab, ... where negative numbers are shown with brackets."), + # ("upper_letter", NumberingSystems.get_numbering_preview("upper_letter", initial), "Use uppercase letters, continuing with AA, AB, ... where negative numbers are shown with brackets."), + # ] + + # custom_storey_enum = [("custom", "Custom", "Use custom numbering for storeys")] + + # element_numbering: EnumProperty( + # name="{E}", + # description="Select numbering system for element numbering", + # items=lambda self, context: self.numberings_enum(self.initial_element_number), + # update=NumberFormatting.update_format_preview + # ) # pyright: ignore[reportInvalidTypeForm] + + # type_numbering: EnumProperty( + # name="{T}", + # description="Select numbering system for numbering within types", + # items=lambda self, context: self.numberings_enum(self.initial_type_number), + # update=NumberFormatting.update_format_preview + # ) # pyright: ignore[reportInvalidTypeForm] + + # def update_storey_numbering(self, context): + # if self.storey_numbering == "custom": + # self.initial_storey_number = 0 - storey_numbering: EnumProperty( - name="{S}", - description="Select numbering system for numbering storeys. Storeys are numbered in positive Z-order by default.", - items=lambda self, context: self.numberings_enum(self.initial_storey_number) + self.custom_storey_enum, - update=update_storey_numbering - ) # pyright: ignore[reportInvalidTypeForm] - - custom_storey: EnumProperty( - name = "Storey", - description = "Select storey to number", - items = lambda self, _: [(storey.Name, storey.Name, f"{storey.Name}\nID: {storey.GlobalId}") for storey in Storeys.get_storeys(Settings.to_dict(self))], - update = Storeys.update_custom_storey - ) # pyright: ignore[reportInvalidTypeForm] - - custom_storey_number: IntProperty( - name = "Storey number", - description = f"Set custom storey number for selected storey, stored in {Storeys.settings['pset_name']} in the IFC element", - get = Storeys.get_custom_storey_number, - set = Storeys.set_custom_storey_number - ) # pyright: ignore[reportInvalidTypeForm] + # storey_numbering: EnumProperty( + # name="{S}", + # description="Select numbering system for numbering storeys. Storeys are numbered in positive Z-order by default.", + # items=lambda self, context: self.numberings_enum(self.initial_storey_number) + self.custom_storey_enum, + # update=update_storey_numbering + # ) # pyright: ignore[reportInvalidTypeForm] + + # custom_storey: EnumProperty( + # name = "Storey", + # description = "Select storey to number", + # items = lambda self, _: [(storey.Name, storey.Name, f"{storey.Name}\nID: {storey.GlobalId}") for storey in Storeys.get_storeys(Settings.to_dict(self))], + # update = Storeys.update_custom_storey + # ) # pyright: ignore[reportInvalidTypeForm] + + # custom_storey_number: IntProperty( + # name = "Storey number", + # description = f"Set custom storey number for selected storey, stored in {Storeys.settings['pset_name']} in the IFC element", + # get = Storeys.get_custom_storey_number, + # set = Storeys.set_custom_storey_number + # ) # pyright: ignore[reportInvalidTypeForm] - format: StringProperty( - name="Format", - description="Format string for selected IFC type.\n" \ - "{E}: element number \n" \ - "{T}: number within type \n" \ - "{S}: number of storey\n" \ - "[T]: first letter of type name\n" \ - "[TT] : all capitalized letters in type name\n" \ - "[TF]: full type name", - default="E{E}S{S}[T]{T}", - update=NumberFormatting.update_format_preview - ) # pyright: ignore[reportInvalidTypeForm] - - save_type : EnumProperty( - name="Type of number storage", - items = [("Attribute", "Attribute", "Store number in an attribute of the IFC element"), - ("Pset", "Pset", "Store number in a Pset of the IFC element") - ], - default = "Attribute", - update = SaveNumber.update_pset_names - ) # pyright: ignore[reportInvalidTypeForm] - - attribute_name : EnumProperty( - name="Attribute name", - description="Name of the attribute to store the number", - items = [("Tag", "Tag", "Store number in IFC Tag attribute"), - ("Name", "Name", "Store number in IFC Name attribute"), - ("Description", "Description", "Store number in IFC Description attribute"), - ("AxisTag", "AxisTag", "Store number in IFC AxisTag attribute, used for IFCGridAxis"), - ("Other", "Other", "Input in which IFC attribute to store the number") - ], - default="Tag" - ) # pyright: ignore[reportInvalidTypeForm] - - attribute_name_other : StringProperty( - name="Other attribute name", - description="Name of the other attribute to store the number", - default="Tag" - ) # pyright: ignore[reportInvalidTypeForm] - - def get_pset_names(self, context): - return SaveNumber.pset_names + # format: StringProperty( + # name="Format", + # description="Format string for selected IFC type.\n" \ + # "{E}: element number \n" \ + # "{T}: number within type \n" \ + # "{S}: number of storey\n" \ + # "[T]: first letter of type name\n" \ + # "[TT] : all capitalized letters in type name\n" \ + # "[TF]: full type name", + # default="E{E}S{S}[T]{T}", + # update=NumberFormatting.update_format_preview + # ) # pyright: ignore[reportInvalidTypeForm] + + # save_type : EnumProperty( + # name="Type of number storage", + # items = [("Attribute", "Attribute", "Store number in an attribute of the IFC element"), + # ("Pset", "Pset", "Store number in a Pset of the IFC element") + # ], + # default = "Attribute", + # update = SaveNumber.update_pset_names + # ) # pyright: ignore[reportInvalidTypeForm] + + # attribute_name : EnumProperty( + # name="Attribute name", + # description="Name of the attribute to store the number", + # items = [("Tag", "Tag", "Store number in IFC Tag attribute"), + # ("Name", "Name", "Store number in IFC Name attribute"), + # ("Description", "Description", "Store number in IFC Description attribute"), + # ("AxisTag", "AxisTag", "Store number in IFC AxisTag attribute, used for IFCGridAxis"), + # ("Other", "Other", "Input in which IFC attribute to store the number") + # ], + # default="Tag" + # ) # pyright: ignore[reportInvalidTypeForm] + + # attribute_name_other : StringProperty( + # name="Other attribute name", + # description="Name of the other attribute to store the number", + # default="Tag" + # ) # pyright: ignore[reportInvalidTypeForm] + + # def get_pset_names(self, context): + # return SaveNumber.pset_names - pset_name : EnumProperty( - name="Pset name", - description="Name of the Pset to store the number", - items = get_pset_names - ) # pyright: ignore[reportInvalidTypeForm] - - property_name : StringProperty( - name="Property name", - description="Name of the property to store the number", - default="Number" - ) # pyright: ignore[reportInvalidTypeForm] - - custom_pset_name : StringProperty( - name="Custom Pset name", - description="Name of the custom Pset to store the number", - default="Pset_Numbering" - ) # pyright: ignore[reportInvalidTypeForm] - - remove_toggle: BoolProperty( - name="Remove numbers from unselected objects", - description="Remove numbers from unselected objects in the scene", - default=True - ) # pyright: ignore[reportInvalidTypeForm] - - check_duplicates_toggle: BoolProperty( - name="Check for duplicate numbers", - description="Check for duplicate numbers in all objects in the scene", - default=True - ) # pyright: ignore[reportInvalidTypeForm] + # pset_name : EnumProperty( + # name="Pset name", + # description="Name of the Pset to store the number", + # items = get_pset_names + # ) # pyright: ignore[reportInvalidTypeForm] + + # property_name : StringProperty( + # name="Property name", + # description="Name of the property to store the number", + # default="Number" + # ) # pyright: ignore[reportInvalidTypeForm] + + # custom_pset_name : StringProperty( + # name="Custom Pset name", + # description="Name of the custom Pset to store the number", + # default="Pset_Numbering" + # ) # pyright: ignore[reportInvalidTypeForm] + + # remove_toggle: BoolProperty( + # name="Remove numbers from unselected objects", + # description="Remove numbers from unselected objects in the scene", + # default=True + # ) # pyright: ignore[reportInvalidTypeForm] + + # check_duplicates_toggle: BoolProperty( + # name="Check for duplicate numbers", + # description="Check for duplicate numbers in all objects in the scene", + # default=True + # ) # pyright: ignore[reportInvalidTypeForm] + + if TYPE_CHECKING: + settings_name: str diff --git a/src/bonsai/bonsai/bim/module/numbering/ui.py b/src/bonsai/bonsai/bim/module/numbering/ui.py index 85d6b87f159..9dd49be0de6 100644 --- a/src/bonsai/bonsai/bim/module/numbering/ui.py +++ b/src/bonsai/bonsai/bim/module/numbering/ui.py @@ -19,7 +19,8 @@ from bpy.types import Panel import bonsai.tool as tool -from .util import NumberFormatting +#from bonsai.bim.module.numbering.util import NumberFormatting +from bonsai.bim.module.numbering_test.data import NumberingData class BIM_PT_Numbering(Panel): bl_label = "Numbering Container" @@ -35,6 +36,8 @@ def draw(self, context): assert self.layout layout = self.layout + if not NumberingData.is_loaded: + NumberingData.load() props = tool.Numbering.get_numbering_props() # Settings box @@ -42,107 +45,107 @@ def draw(self, context): box.label(text="Settings") grid = box.grid_flow(row_major=True, align=True, columns=4, even_columns=True) grid.prop(props, "settings_name", text="Name") - grid.operator("bonsai.save_settings", icon="FILE_TICK", text="Save") - grid.operator("bonsai.clear_settings", icon="CANCEL", text="Clear") - grid.operator("bonsai.export_settings", icon="EXPORT", text="Export") - - grid.prop(props, "saved_settings", text="") - grid.operator("bonsai.load_settings", icon="FILE_REFRESH", text="Load") - grid.operator("bonsai.delete_settings", icon="TRASH", text="Delete") - grid.operator("bonsai.import_settings", icon="IMPORT", text="Import") + # grid.operator("bonsai.save_settings", icon="FILE_TICK", text="Save") + # grid.operator("bonsai.clear_settings", icon="CANCEL", text="Clear") + # grid.operator("bonsai.export_settings", icon="EXPORT", text="Export") + + # grid.prop(props, "saved_settings", text="") + # grid.operator("bonsai.load_settings", icon="FILE_REFRESH", text="Load") + # grid.operator("bonsai.delete_settings", icon="TRASH", text="Delete") + # grid.operator("bonsai.import_settings", icon="IMPORT", text="Import") - # Selection box - box = layout.box() - box.label(text="Elements to number:") - grid = box.grid_flow(row_major=True, align=False, columns=4, even_columns=True) - grid.prop(props, "selected_toggle") - grid.prop(props, "visible_toggle") - grid.prop(props, "parent_type", text="") - if props.parent_type == "Other": - grid.prop(props, "parent_type_other", text="") - else: - grid.label(text="") - - grid = box.grid_flow(row_major=True, align=True, columns=4, even_columns=True) - grid.prop(props, "selected_types", expand=True) - - # Numbering order box - box = layout.box() - box.label(text="Numbering order") - # Create a grid for direction and precision - grid = box.grid_flow(row_major=True, align=False, columns=4, even_columns=True) - grid.label(text="Direction: ") - grid.prop(props, "x_direction", text="X") - grid.prop(props, "y_direction", text="Y") - grid.prop(props, "z_direction", text="Z") - grid.label(text="Precision: ") - grid.prop(props, "precision", index=0, text="X") - grid.prop(props, "precision", index=1, text="Y") - grid.prop(props, "precision", index=2, text="Z") - - # Axis order and reference point - grid = box.grid_flow(row_major=True, align=True, columns=4) - grid.label(text="Order:") - grid.prop(props, "axis_order", text="") - grid.label(text="Reference point:") - grid.prop(props, "location_type", text="") - - # Numbering systems box - box = layout.box() - box.label(text="Numbering of elements {E}, within type {T} and storeys {S}") - grid = box.grid_flow(row_major=True, align=False, columns=4, even_columns=True) - grid.label(text="Start at:") - grid.prop(props, "initial_element_number", text="{E}") - grid.prop(props, "initial_type_number", text="{T}") - grid.prop(props, "initial_storey_number", text="{S}") - grid.label(text="System:") - grid.prop(props, "element_numbering", text="{E}") - grid.prop(props, "type_numbering", text="{T}") - grid.prop(props, "storey_numbering", text="{S}") - - # Custom storey number - if props.storey_numbering == "custom": - box = box.box() - row = box.row(align=False) - row.prop(props, "custom_storey", text="Storey") - row.prop(props, "custom_storey_number", text="Number") - - # Numbering format box - box = layout.box() - box.label(text="Numbering format") - grid = box.grid_flow(align=False, columns=4, even_columns=True) - grid.label(text="Format:") - grid.prop(props, "format", text="") - # Show preview in a textbox style (non-editable) - grid.label(text="Preview:") - preview_box = grid.box() - preview_box.label(text=NumberFormatting.format_preview) - - # Storage options - box = layout.box() - box.label(text="Store number in") + # # Selection box + # box = layout.box() + # box.label(text="Elements to number:") + # grid = box.grid_flow(row_major=True, align=False, columns=4, even_columns=True) + # grid.prop(props, "selected_toggle") + # grid.prop(props, "visible_toggle") + # grid.prop(props, "parent_type", text="") + # if props.parent_type == "Other": + # grid.prop(props, "parent_type_other", text="") + # else: + # grid.label(text="") + + # grid = box.grid_flow(row_major=True, align=True, columns=4, even_columns=True) + # grid.prop(props, "selected_types", expand=True) + + # # Numbering order box + # box = layout.box() + # box.label(text="Numbering order") + # # Create a grid for direction and precision + # grid = box.grid_flow(row_major=True, align=False, columns=4, even_columns=True) + # grid.label(text="Direction: ") + # grid.prop(props, "x_direction", text="X") + # grid.prop(props, "y_direction", text="Y") + # grid.prop(props, "z_direction", text="Z") + # grid.label(text="Precision: ") + # grid.prop(props, "precision", index=0, text="X") + # grid.prop(props, "precision", index=1, text="Y") + # grid.prop(props, "precision", index=2, text="Z") + + # # Axis order and reference point + # grid = box.grid_flow(row_major=True, align=True, columns=4) + # grid.label(text="Order:") + # grid.prop(props, "axis_order", text="") + # grid.label(text="Reference point:") + # grid.prop(props, "location_type", text="") + + # # Numbering systems box + # box = layout.box() + # box.label(text="Numbering of elements {E}, within type {T} and storeys {S}") + # grid = box.grid_flow(row_major=True, align=False, columns=4, even_columns=True) + # grid.label(text="Start at:") + # grid.prop(props, "initial_element_number", text="{E}") + # grid.prop(props, "initial_type_number", text="{T}") + # grid.prop(props, "initial_storey_number", text="{S}") + # grid.label(text="System:") + # grid.prop(props, "element_numbering", text="{E}") + # grid.prop(props, "type_numbering", text="{T}") + # grid.prop(props, "storey_numbering", text="{S}") + + # # Custom storey number + # if props.storey_numbering == "custom": + # box = box.box() + # row = box.row(align=False) + # row.prop(props, "custom_storey", text="Storey") + # row.prop(props, "custom_storey_number", text="Number") + + # # Numbering format box + # box = layout.box() + # box.label(text="Numbering format") + # grid = box.grid_flow(align=False, columns=4, even_columns=True) + # grid.label(text="Format:") + # grid.prop(props, "format", text="") + # # Show preview in a textbox style (non-editable) + # grid.label(text="Preview:") + # preview_box = grid.box() + # preview_box.label(text=NumberFormatting.format_preview) + + # # Storage options + # box = layout.box() + # box.label(text="Store number in") - grid = box.grid_flow(align=False, columns=4, even_columns=True) - grid.prop(props, "save_type", text="") - if props.save_type == "Attribute": - grid.prop(props, "attribute_name", text="") - if props.attribute_name == "Other": - grid.prop(props, "attribute_name_other", text="") - if props.save_type == "Pset": - grid.prop(props, "pset_name", text="") - if props.pset_name == "Custom Pset": - grid.prop(props, "custom_pset_name", text="") - grid.prop(props, "property_name", text="") - - box.prop(props, "remove_toggle") - box.prop(props, "check_duplicates_toggle") - - # Actions - layout.separator() - row = layout.row(align=True) - row.operator("bonsai.assign_numbers", icon="TAG", text="Assign numbers") - row = layout.row(align=True) - row.operator("bonsai.remove_numbers", icon="X", text="Remove numbers") + # grid = box.grid_flow(align=False, columns=4, even_columns=True) + # grid.prop(props, "save_type", text="") + # if props.save_type == "Attribute": + # grid.prop(props, "attribute_name", text="") + # if props.attribute_name == "Other": + # grid.prop(props, "attribute_name_other", text="") + # if props.save_type == "Pset": + # grid.prop(props, "pset_name", text="") + # if props.pset_name == "Custom Pset": + # grid.prop(props, "custom_pset_name", text="") + # grid.prop(props, "property_name", text="") + + # box.prop(props, "remove_toggle") + # box.prop(props, "check_duplicates_toggle") + + # # Actions + # layout.separator() + # row = layout.row(align=True) + # row.operator("bonsai.assign_numbers", icon="TAG", text="Assign numbers") + # row = layout.row(align=True) + # row.operator("bonsai.remove_numbers", icon="X", text="Remove numbers") diff --git a/src/bonsai/bonsai/bim/module/numbering/util.py b/src/bonsai/bonsai/bim/module/numbering/util.py index ff59cff3861..5551850360b 100644 --- a/src/bonsai/bonsai/bim/module/numbering/util.py +++ b/src/bonsai/bonsai/bim/module/numbering/util.py @@ -39,8 +39,8 @@ class SaveNumber: pset_names = [("Custom", "Custom Pset", "")] pset_common_names = {} - ifc_file = tool.Ifc.get() - pset_qto = PsetQto(ifc_file.schema) + ifc_file = None + pset_qto = None @staticmethod def update_ifc_file(): diff --git a/src/bonsai/bonsai/bim/module/numbering/workspace.py b/src/bonsai/bonsai/bim/module/numbering/workspace.py index 0d689d3415e..c0dc6e553a3 100644 --- a/src/bonsai/bonsai/bim/module/numbering/workspace.py +++ b/src/bonsai/bonsai/bim/module/numbering/workspace.py @@ -28,11 +28,12 @@ class NumberingTool(WorkSpaceTool): bl_idname = "bim.numbering_tool" bl_label = "Numbering Tool" bl_description = "Assign or remove numbers from elements" - bl_icon = os.path.join(os.path.dirname(__file__), "ops.authoring.covering") + # TODO: replace with numbering icon + bl_icon = os.path.join(os.path.dirname(__file__), "ops.authoring.numbering") bl_widget = None @classmethod - def draw_settings(cls, context, layout, ws_tool): + def draw_settings(context, layout, ws_tool): NumberingToolUI.draw(context, layout) class NumberingToolUI: diff --git a/src/bonsai/bonsai/core/numbering.py b/src/bonsai/bonsai/core/numbering.py new file mode 100644 index 00000000000..e5bb5041569 --- /dev/null +++ b/src/bonsai/bonsai/core/numbering.py @@ -0,0 +1,18 @@ +# Bonsai - OpenBIM Blender Add-on +# Copyright (C) 2022 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 . + diff --git a/src/bonsai/bonsai/core/tool.py b/src/bonsai/bonsai/core/tool.py index 52e30977cc9..b6a67c85323 100644 --- a/src/bonsai/bonsai/core/tool.py +++ b/src/bonsai/bonsai/core/tool.py @@ -610,6 +610,7 @@ def get_container(cls, element): pass @interface class Numbering: def get_numbering_props(cls): pass + def get_project(cls): pass @interface class Patch: diff --git a/src/bonsai/bonsai/tool/__init__.py b/src/bonsai/bonsai/tool/__init__.py index 8da603f6559..6e8932314dc 100644 --- a/src/bonsai/bonsai/tool/__init__.py +++ b/src/bonsai/bonsai/tool/__init__.py @@ -47,6 +47,7 @@ from bonsai.tool.misc import Misc from bonsai.tool.model import Model from bonsai.tool.nest import Nest +from bonsai.tool.numbering import Numbering from bonsai.tool.owner import Owner from bonsai.tool.patch import Patch from bonsai.tool.polyline import Polyline diff --git a/src/bonsai/bonsai/tool/numbering.py b/src/bonsai/bonsai/tool/numbering.py index 1fe7f03d0c0..5c65a75307d 100644 --- a/src/bonsai/bonsai/tool/numbering.py +++ b/src/bonsai/bonsai/tool/numbering.py @@ -16,16 +16,23 @@ # You should have received a copy of the GNU General Public License # along with Bonsai. If not, see . +from __future__ import annotations + import bpy import bonsai.core.tool +import ifcopenshell +import bonsai.tool as tool from typing import TYPE_CHECKING if TYPE_CHECKING: - from bonsai.bim.module.numbering.prop import ( - BIMNumberingProperties, - ) + from bonsai.bim.module.numbering.prop import BIMNumberingProperties -class Model(bonsai.core.tool.Model): +class Numbering(bonsai.core.tool.Numbering): @classmethod def get_numbering_props(cls) -> BIMNumberingProperties: return bpy.context.scene.BIMNumberingProperties + + @classmethod + def get_project(cls) -> ifcopenshell.entity_instance: + return tool.Ifc.get().by_type("IfcProject")[0] + From cd869083c950f8f1bc364b8aa728f89f082eec3b Mon Sep 17 00:00:00 2001 From: BjornFidder Date: Tue, 26 Aug 2025 20:14:53 +0200 Subject: [PATCH 3/9] Enable full Numbering UI and properties in Bonsai This commit completes the implementation of the Numbering module UI and property group, enabling all settings, selection, ordering, and storage options for element numbering in the Bonsai Blender add-on. It refactors the workspace tool to use the new UI, updates operator references, and adds core and tool documentation for maintainability. Tool seems fully working, but there is still some refactoring to do, to use the NumberingData instead of static class variables, and to fix the format, the tool's formatting is not quite right. Also try to find a way to override the toolbar layout to be different from the sidebar layout. --- .../bonsai/bim/module/numbering/__init__.py | 38 +- .../bonsai/bim/module/numbering/data.py | 2 +- .../bonsai/bim/module/numbering/operator.py | 4 +- .../bonsai/bim/module/numbering/prop.py | 564 +++++++++--------- src/bonsai/bonsai/bim/module/numbering/ui.py | 136 +---- .../bonsai/bim/module/numbering/workspace.py | 144 ++++- src/bonsai/bonsai/core/numbering.py | 62 ++ src/bonsai/bonsai/tool/blender.py | 8 +- src/bonsai/bonsai/tool/numbering.py | 52 +- 9 files changed, 566 insertions(+), 444 deletions(-) diff --git a/src/bonsai/bonsai/bim/module/numbering/__init__.py b/src/bonsai/bonsai/bim/module/numbering/__init__.py index 182b5516e63..afa4104b5cb 100644 --- a/src/bonsai/bonsai/bim/module/numbering/__init__.py +++ b/src/bonsai/bonsai/bim/module/numbering/__init__.py @@ -1,5 +1,5 @@ # Bonsai - OpenBIM Blender Add-on -# Copyright (C) 2020, 2021 Dion Moult +# Copyright (C) 2022 Dion Moult # # This file is part of Bonsai. # @@ -17,30 +17,30 @@ # along with Bonsai. If not, see . import bpy -from . import ui, operator, prop, workspace +from . import prop, operator, workspace classes = ( - # operator.AssignNumbers, - # operator.RemoveNumbers, - # operator.SaveSettings, - # operator.LoadSettings, - # operator.ExportSettings, - # operator.ImportSettings, - # operator.DeleteSettings, - # operator.ClearSettings, - # operator.ShowMessage, - prop.BIMNumberingProperties, - ui.BIM_PT_Numbering) + prop.BIMNumberingProperties, + operator.AssignNumbers, + operator.RemoveNumbers, + operator.SaveSettings, + operator.LoadSettings, + operator.DeleteSettings, + operator.ClearSettings, + operator.ImportSettings, + operator.ExportSettings, + operator.ShowMessage +) -def register(): - # if not bpy.app.background: - # bpy.utils.register_tool(workspace.NumberingTool, after={"bim.explore_tool"}, separator=False, group=False) +def register(): + if not bpy.app.background: + bpy.utils.register_tool(workspace.NumberingTool, after={"bim.structural_tool"}, separator=False, group=False) bpy.types.Scene.BIMNumberingProperties = bpy.props.PointerProperty(type=prop.BIMNumberingProperties) + +# When someone disables the add-on, we need to unload everything we loaded. This +# does the reverse of the register function. def unregister(): if not bpy.app.background: bpy.utils.unregister_tool(workspace.NumberingTool) del bpy.types.Scene.BIMNumberingProperties - -register() - diff --git a/src/bonsai/bonsai/bim/module/numbering/data.py b/src/bonsai/bonsai/bim/module/numbering/data.py index 2f1b0ac2714..5da70bb2bce 100644 --- a/src/bonsai/bonsai/bim/module/numbering/data.py +++ b/src/bonsai/bonsai/bim/module/numbering/data.py @@ -18,13 +18,13 @@ import bonsai.tool as tool + def refresh(): NumberingData.is_loaded = False class NumberingData: data = {} is_loaded = False - @classmethod def load(cls): cls.data = { diff --git a/src/bonsai/bonsai/bim/module/numbering/operator.py b/src/bonsai/bonsai/bim/module/numbering/operator.py index a781553f440..391aed33179 100644 --- a/src/bonsai/bonsai/bim/module/numbering/operator.py +++ b/src/bonsai/bonsai/bim/module/numbering/operator.py @@ -65,7 +65,7 @@ def rollback(operator, data): for element in ifc_file.by_type(LoadSelection.get_parent_type(settings)): old_number = data["old_value"].get(get_id(element), None) rollback_count += int(SaveNumber.save_number(ifc_file, element, old_number, settings, data["new_value"]) or 0) - bpy.ops.bonsai.show_message('EXEC_DEFAULT', message=f"Rollback {rollback_count} numbers.") + bpy.ops.bim.show_message('EXEC_DEFAULT', message=f"Rollback {rollback_count} numbers.") @staticmethod def commit(operator, data): @@ -79,7 +79,7 @@ def commit(operator, data): if element is not None and element.is_a(LoadSelection.get_parent_type(settings)): new_number = data["new_value"].get(obj.name, None) commit_count += int(SaveNumber.save_number(ifc_file, element, new_number, settings, data["old_value"]) or 0) - bpy.ops.bonsai.show_message('EXEC_DEFAULT', message=f"Commit {commit_count} numbers.") + bpy.ops.bim.show_message('EXEC_DEFAULT', message=f"Commit {commit_count} numbers.") class AssignNumbers(bpy.types.Operator): bl_idname = "bim.assign_numbers" diff --git a/src/bonsai/bonsai/bim/module/numbering/prop.py b/src/bonsai/bonsai/bim/module/numbering/prop.py index 7d5ef52fdd7..13a5258be7c 100644 --- a/src/bonsai/bonsai/bim/module/numbering/prop.py +++ b/src/bonsai/bonsai/bim/module/numbering/prop.py @@ -1,21 +1,3 @@ -# 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 . - from typing import TYPE_CHECKING import bpy @@ -28,8 +10,9 @@ BoolProperty, IntVectorProperty ) -#from .util import Settings, LoadSelection, NumberFormatting, NumberingSystems, SaveNumber, Storeys +from .util import Settings, LoadSelection, NumberFormatting, NumberingSystems, SaveNumber, Storeys + class BIMNumberingProperties(PropertyGroup): settings_name: StringProperty( name="Settings name", @@ -37,267 +20,296 @@ class BIMNumberingProperties(PropertyGroup): default="" ) # pyright: ignore[reportInvalidTypeForm] - # def get_saved_settings_items(self, context): - # settings_names = Settings.get_settings_names() - # if not settings_names: - # return [("NONE", "No saved settings", "")] - # return [(name, name, "") for name in settings_names] - - # saved_settings : EnumProperty( - # name="Load settings", - # description="Select which saved settings to load", - # items=get_saved_settings_items - # ) # pyright: ignore[reportInvalidTypeForm] - - # selected_toggle: BoolProperty( - # name="Selected only", - # description="Only number selected objects", - # default=False, - # update=NumberFormatting.update_format_preview - # ) # pyright: ignore[reportInvalidTypeForm] - - # visible_toggle: BoolProperty( - # name="Visible only", - # description="Only number visible objects", - # default=False, - # update=NumberFormatting.update_format_preview - # ) # pyright: ignore[reportInvalidTypeForm] + def get_saved_settings_items(self, context): + settings_names = Settings.get_settings_names() + if not settings_names: + return [("NONE", "No saved settings", "")] + return [(name, name, "") for name in settings_names] + + saved_settings : EnumProperty( + name="Load settings", + description="Select which saved settings to load", + items=get_saved_settings_items + ) # pyright: ignore[reportInvalidTypeForm] + + selected_toggle: BoolProperty( + name="Selected only", + description="Only number selected objects", + default=False, + update=NumberFormatting.update_format_preview + ) # pyright: ignore[reportInvalidTypeForm] + + visible_toggle: BoolProperty( + name="Visible only", + description="Only number visible objects", + default=False, + update=NumberFormatting.update_format_preview + ) # pyright: ignore[reportInvalidTypeForm] - # parent_type: EnumProperty( - # name="Parent Type", - # description="Select the parent type for numbering", - # items=[ - # ("IfcElement", "IfcElement", "Number IFC elements"), - # ("IfcProduct", "IfcProduct", "Number IFC products"), - # ("IfcGridAxis", "IfcGridAxis", "Number IFC grid axes"), - # ("Other", "Other", "Input which IFC entities to number") - # ], - # default="IfcElement", - # update = LoadSelection.update_objects - # ) # pyright: ignore[reportInvalidTypeForm] - - # parent_type_other : StringProperty( - # name="Other Parent Type", - # description="Input which IFC entities to number", - # default="IfcElement", - # update = LoadSelection.update_objects - # ) # pyright: ignore[reportInvalidTypeForm] - - # def update_selected_types(self, context): - # NumberFormatting.update_format_preview(self, context) - # SaveNumber.update_pset_names(self, context) - - # selected_types: EnumProperty( - # name="Of type", - # description="Select which types of elements to number", - # items= LoadSelection.get_possible_types, - # options={'ENUM_FLAG'}, - # update=update_selected_types - # ) # pyright: ignore[reportInvalidTypeForm] - - # x_direction: EnumProperty( - # name="X", - # description="Select axis direction for numbering elements", - # items=[ - # ("1", "+", "Number elements in order of increasing X coordinate"), - # ("-1", "-", "Number elements in order of decreasing X coordinate") - # ], - # default="1", - # ) # pyright: ignore[reportInvalidTypeForm] - - # y_direction: EnumProperty( - # name="Y", - # description="Select axis direction for numbering elements", - # items=[ - # ("1", "+", "Number elements in order of increasing Y coordinate"), - # ("-1", "-", "Number elements in order of decreasing Y coordinate") - # ], - # default="1" - # ) # pyright: ignore[reportInvalidTypeForm] - - # z_direction: EnumProperty( - # name="Z", - # description="Select axis direction for numbering elements", - # items=[ - # ("1", "+", "Number elements in order of increasing Z coordinate"), - # ("-1", "-", "Number elements in order of decreasing Z coordinate") - # ], - # default="1" - # ) # pyright: ignore[reportInvalidTypeForm] - - # axis_order: EnumProperty( - # name="Axis order", - # description="Order of axes in numbering elements", - # items=[ - # ("XYZ", "X, Y, Z", "Number elements in X, Y, Z order"), - # ("XZY", "X, Z, Y", "Number elements in X, Z, Y order"), - # ("YXZ", "Y, X, Z", "Number elements in Y, X, Z order"), - # ("YZX", "Y, Z, X", "Number elements in Y, Z, X order"), - # ("ZXY", "Z, X, Y", "Number elements in Z, X, Y order"), - # ("ZYX", "Z, Y, X", "Number elements in Z, Y, X order") - # ], - # default="ZYX" - # ) # pyright: ignore[reportInvalidTypeForm] - - # location_type: EnumProperty( - # name="Reference location", - # description="Location to use for sorting elements", - # items=[ - # ("CENTER", "Center", "Use object center for sorting"), - # ("BOUNDING_BOX", "Bounding Box", "Use object bounding box for sorting"), - # ], - # default="BOUNDING_BOX" - # ) # pyright: ignore[reportInvalidTypeForm] - - # precision: IntVectorProperty( - # name="Precision", - # description="Precision for sorting elements in X, Y and Z direction", - # default=(1, 1, 1), - # min=1, - # size=3 - # ) # pyright: ignore[reportInvalidTypeForm] - - # initial_element_number: IntProperty( - # name="{E}", - # description="Initial number for numbering elements", - # default=1, - # update=NumberFormatting.update_format_preview - # ) # pyright: ignore[reportInvalidTypeForm] - - # initial_type_number: IntProperty( - # name="{T}", - # description="Initial number for numbering elements within type", - # default=1, - # update=NumberFormatting.update_format_preview - # ) # pyright: ignore[reportInvalidTypeForm] - - # initial_storey_number: IntProperty( - # name="{S}", - # description="Initial number for numbering storeys", - # default=0, - # update=NumberFormatting.update_format_preview - # ) # pyright: ignore[reportInvalidTypeForm] - - # numberings_enum = lambda self, initial : [ - # ("number", NumberingSystems.get_numbering_preview("number", initial), "Use numbers. Negative numbers are shown with brackets"), - # ("number_ext", NumberingSystems.get_numbering_preview("number_ext", initial), "Use numbers padded with zeroes to a fixed length based on the number of objects selected. Negative numbers are shown with brackets."), - # ("lower_letter", NumberingSystems.get_numbering_preview("lower_letter", initial), "Use lowercase letters, continuing with aa, ab, ... where negative numbers are shown with brackets."), - # ("upper_letter", NumberingSystems.get_numbering_preview("upper_letter", initial), "Use uppercase letters, continuing with AA, AB, ... where negative numbers are shown with brackets."), - # ] - - # custom_storey_enum = [("custom", "Custom", "Use custom numbering for storeys")] - - # element_numbering: EnumProperty( - # name="{E}", - # description="Select numbering system for element numbering", - # items=lambda self, context: self.numberings_enum(self.initial_element_number), - # update=NumberFormatting.update_format_preview - # ) # pyright: ignore[reportInvalidTypeForm] - - # type_numbering: EnumProperty( - # name="{T}", - # description="Select numbering system for numbering within types", - # items=lambda self, context: self.numberings_enum(self.initial_type_number), - # update=NumberFormatting.update_format_preview - # ) # pyright: ignore[reportInvalidTypeForm] - - # def update_storey_numbering(self, context): - # if self.storey_numbering == "custom": - # self.initial_storey_number = 0 + parent_type: EnumProperty( + name="Parent Type", + description="Select the parent type for numbering", + items=[ + ("IfcElement", "IfcElement", "Number IFC elements"), + ("IfcProduct", "IfcProduct", "Number IFC products"), + ("IfcGridAxis", "IfcGridAxis", "Number IFC grid axes"), + ("Other", "Other", "Input which IFC entities to number") + ], + default="IfcElement", + update = LoadSelection.update_objects + ) # pyright: ignore[reportInvalidTypeForm] + + parent_type_other : StringProperty( + name="Other Parent Type", + description="Input which IFC entities to number", + default="IfcElement", + update = LoadSelection.update_objects + ) # pyright: ignore[reportInvalidTypeForm] + + def update_selected_types(self, context): + NumberFormatting.update_format_preview(self, context) + SaveNumber.update_pset_names(self, context) + + selected_types: EnumProperty( + name="Selected types", + description="Select which types of elements to number", + items= LoadSelection.get_possible_types, + options={'ENUM_FLAG'}, + update=update_selected_types + ) # pyright: ignore[reportInvalidTypeForm] + + x_direction: EnumProperty( + name="X", + description="Select axis direction for numbering elements", + items=[ + ("1", "+", "Number elements in order of increasing X coordinate"), + ("-1", "-", "Number elements in order of decreasing X coordinate") + ], + default="1", + ) # pyright: ignore[reportInvalidTypeForm] + + y_direction: EnumProperty( + name="Y", + description="Select axis direction for numbering elements", + items=[ + ("1", "+", "Number elements in order of increasing Y coordinate"), + ("-1", "-", "Number elements in order of decreasing Y coordinate") + ], + default="1" + ) # pyright: ignore[reportInvalidTypeForm] + + z_direction: EnumProperty( + name="Z", + description="Select axis direction for numbering elements", + items=[ + ("1", "+", "Number elements in order of increasing Z coordinate"), + ("-1", "-", "Number elements in order of decreasing Z coordinate") + ], + default="1" + ) # pyright: ignore[reportInvalidTypeForm] + + axis_order: EnumProperty( + name="Axis order", + description="Order of axes in numbering elements", + items=[ + ("XYZ", "X, Y, Z", "Number elements in X, Y, Z order"), + ("XZY", "X, Z, Y", "Number elements in X, Z, Y order"), + ("YXZ", "Y, X, Z", "Number elements in Y, X, Z order"), + ("YZX", "Y, Z, X", "Number elements in Y, Z, X order"), + ("ZXY", "Z, X, Y", "Number elements in Z, X, Y order"), + ("ZYX", "Z, Y, X", "Number elements in Z, Y, X order") + ], + default="ZYX" + ) # pyright: ignore[reportInvalidTypeForm] + + location_type: EnumProperty( + name="Reference location", + description="Location to use for sorting elements", + items=[ + ("CENTER", "Center", "Use object center for sorting"), + ("BOUNDING_BOX", "Bounding Box", "Use object bounding box for sorting"), + ], + default="BOUNDING_BOX" + ) # pyright: ignore[reportInvalidTypeForm] + + precision: IntVectorProperty( + name="Precision", + description="Precision for sorting elements in X, Y and Z direction", + default=(1, 1, 1), + min=1, + size=3 + ) # pyright: ignore[reportInvalidTypeForm] + + initial_element_number: IntProperty( + name="{E}", + description="Initial number for numbering elements", + default=1, + update=NumberFormatting.update_format_preview + ) # pyright: ignore[reportInvalidTypeForm] + + initial_type_number: IntProperty( + name="{T}", + description="Initial number for numbering elements within type", + default=1, + update=NumberFormatting.update_format_preview + ) # pyright: ignore[reportInvalidTypeForm] + + initial_storey_number: IntProperty( + name="{S}", + description="Initial number for numbering storeys", + default=0, + update=NumberFormatting.update_format_preview + ) # pyright: ignore[reportInvalidTypeForm] + + numberings_enum = lambda self, initial : [ + ("number", NumberingSystems.get_numbering_preview("number", initial), "Use numbers. Negative numbers are shown with brackets"), + ("number_ext", NumberingSystems.get_numbering_preview("number_ext", initial), "Use numbers padded with zeroes to a fixed length based on the number of objects selected. Negative numbers are shown with brackets."), + ("lower_letter", NumberingSystems.get_numbering_preview("lower_letter", initial), "Use lowercase letters, continuing with aa, ab, ... where negative numbers are shown with brackets."), + ("upper_letter", NumberingSystems.get_numbering_preview("upper_letter", initial), "Use uppercase letters, continuing with AA, AB, ... where negative numbers are shown with brackets."), + ] + + custom_storey_enum = [("custom", "Custom", "Use custom numbering for storeys")] + + element_numbering: EnumProperty( + name="{E}", + description="Select numbering system for element numbering", + items=lambda self, context: self.numberings_enum(self.initial_element_number), + update=NumberFormatting.update_format_preview + ) # pyright: ignore[reportInvalidTypeForm] + + type_numbering: EnumProperty( + name="{T}", + description="Select numbering system for numbering within types", + items=lambda self, context: self.numberings_enum(self.initial_type_number), + update=NumberFormatting.update_format_preview + ) # pyright: ignore[reportInvalidTypeForm] + + def update_storey_numbering(self, context): + if self.storey_numbering == "custom": + self.initial_storey_number = 0 - # storey_numbering: EnumProperty( - # name="{S}", - # description="Select numbering system for numbering storeys. Storeys are numbered in positive Z-order by default.", - # items=lambda self, context: self.numberings_enum(self.initial_storey_number) + self.custom_storey_enum, - # update=update_storey_numbering - # ) # pyright: ignore[reportInvalidTypeForm] - - # custom_storey: EnumProperty( - # name = "Storey", - # description = "Select storey to number", - # items = lambda self, _: [(storey.Name, storey.Name, f"{storey.Name}\nID: {storey.GlobalId}") for storey in Storeys.get_storeys(Settings.to_dict(self))], - # update = Storeys.update_custom_storey - # ) # pyright: ignore[reportInvalidTypeForm] - - # custom_storey_number: IntProperty( - # name = "Storey number", - # description = f"Set custom storey number for selected storey, stored in {Storeys.settings['pset_name']} in the IFC element", - # get = Storeys.get_custom_storey_number, - # set = Storeys.set_custom_storey_number - # ) # pyright: ignore[reportInvalidTypeForm] + storey_numbering: EnumProperty( + name="{S}", + description="Select numbering system for numbering storeys. Storeys are numbered in positive Z-order by default.", + items=lambda self, context: self.numberings_enum(self.initial_storey_number) + self.custom_storey_enum, + update=update_storey_numbering + ) # pyright: ignore[reportInvalidTypeForm] + + custom_storey: EnumProperty( + name = "Storey", + description = "Select storey to number", + items = lambda self, _: [(storey.Name, storey.Name, f"{storey.Name}\nID: {storey.GlobalId}") for storey in Storeys.get_storeys(Settings.to_dict(self))], + update = Storeys.update_custom_storey + ) # pyright: ignore[reportInvalidTypeForm] + + custom_storey_number: IntProperty( + name = "Storey number", + description = f"Set custom storey number for selected storey, stored in {Storeys.settings['pset_name']} in the IFC element", + get = Storeys.get_custom_storey_number, + set = Storeys.set_custom_storey_number + ) # pyright: ignore[reportInvalidTypeForm] - # format: StringProperty( - # name="Format", - # description="Format string for selected IFC type.\n" \ - # "{E}: element number \n" \ - # "{T}: number within type \n" \ - # "{S}: number of storey\n" \ - # "[T]: first letter of type name\n" \ - # "[TT] : all capitalized letters in type name\n" \ - # "[TF]: full type name", - # default="E{E}S{S}[T]{T}", - # update=NumberFormatting.update_format_preview - # ) # pyright: ignore[reportInvalidTypeForm] - - # save_type : EnumProperty( - # name="Type of number storage", - # items = [("Attribute", "Attribute", "Store number in an attribute of the IFC element"), - # ("Pset", "Pset", "Store number in a Pset of the IFC element") - # ], - # default = "Attribute", - # update = SaveNumber.update_pset_names - # ) # pyright: ignore[reportInvalidTypeForm] - - # attribute_name : EnumProperty( - # name="Attribute name", - # description="Name of the attribute to store the number", - # items = [("Tag", "Tag", "Store number in IFC Tag attribute"), - # ("Name", "Name", "Store number in IFC Name attribute"), - # ("Description", "Description", "Store number in IFC Description attribute"), - # ("AxisTag", "AxisTag", "Store number in IFC AxisTag attribute, used for IFCGridAxis"), - # ("Other", "Other", "Input in which IFC attribute to store the number") - # ], - # default="Tag" - # ) # pyright: ignore[reportInvalidTypeForm] - - # attribute_name_other : StringProperty( - # name="Other attribute name", - # description="Name of the other attribute to store the number", - # default="Tag" - # ) # pyright: ignore[reportInvalidTypeForm] - - # def get_pset_names(self, context): - # return SaveNumber.pset_names + format: StringProperty( + name="Format", + description="Format string for selected IFC type.\n" \ + "{E}: element number \n" \ + "{T}: number within type \n" \ + "{S}: number of storey\n" \ + "[T]: first letter of type name\n" \ + "[TT] : all capitalized letters in type name\n" \ + "[TF]: full type name", + default="E{E}S{S}[T]{T}", + update=NumberFormatting.update_format_preview + ) # pyright: ignore[reportInvalidTypeForm] + + save_type : EnumProperty( + name="Type of number storage", + items = [("Attribute", "Attribute", "Store number in an attribute of the IFC element"), + ("Pset", "Pset", "Store number in a Pset of the IFC element") + ], + default = "Attribute", + update = SaveNumber.update_pset_names + ) # pyright: ignore[reportInvalidTypeForm] + + attribute_name : EnumProperty( + name="Attribute name", + description="Name of the attribute to store the number", + items = [("Tag", "Tag", "Store number in IFC Tag attribute"), + ("Name", "Name", "Store number in IFC Name attribute"), + ("Description", "Description", "Store number in IFC Description attribute"), + ("AxisTag", "AxisTag", "Store number in IFC AxisTag attribute, used for IFCGridAxis"), + ("Other", "Other", "Input in which IFC attribute to store the number") + ], + default="Tag" + ) # pyright: ignore[reportInvalidTypeForm] + + attribute_name_other : StringProperty( + name="Other attribute name", + description="Name of the other attribute to store the number", + default="Tag" + ) # pyright: ignore[reportInvalidTypeForm] + + def get_pset_names(self, context): + return SaveNumber.pset_names - # pset_name : EnumProperty( - # name="Pset name", - # description="Name of the Pset to store the number", - # items = get_pset_names - # ) # pyright: ignore[reportInvalidTypeForm] - - # property_name : StringProperty( - # name="Property name", - # description="Name of the property to store the number", - # default="Number" - # ) # pyright: ignore[reportInvalidTypeForm] - - # custom_pset_name : StringProperty( - # name="Custom Pset name", - # description="Name of the custom Pset to store the number", - # default="Pset_Numbering" - # ) # pyright: ignore[reportInvalidTypeForm] - - # remove_toggle: BoolProperty( - # name="Remove numbers from unselected objects", - # description="Remove numbers from unselected objects in the scene", - # default=True - # ) # pyright: ignore[reportInvalidTypeForm] - - # check_duplicates_toggle: BoolProperty( - # name="Check for duplicate numbers", - # description="Check for duplicate numbers in all objects in the scene", - # default=True - # ) # pyright: ignore[reportInvalidTypeForm] + pset_name : EnumProperty( + name="Pset name", + description="Name of the Pset to store the number", + items = get_pset_names + ) # pyright: ignore[reportInvalidTypeForm] + + property_name : StringProperty( + name="Property name", + description="Name of the property to store the number", + default="Number" + ) # pyright: ignore[reportInvalidTypeForm] + + custom_pset_name : StringProperty( + name="Custom Pset name", + description="Name of the custom Pset to store the number", + default="Pset_Numbering" + ) # pyright: ignore[reportInvalidTypeForm] + + remove_toggle: BoolProperty( + name="Remove numbers from unselected objects", + description="Remove numbers from unselected objects in the scene", + default=True + ) # pyright: ignore[reportInvalidTypeForm] + + check_duplicates_toggle: BoolProperty( + name="Check for duplicate numbers", + description="Check for duplicate numbers in all objects in the scene", + default=True + ) # pyright: ignore[reportInvalidTypeForm] if TYPE_CHECKING: settings_name: str + saved_settings: str + selected_toggle: bool + visible_toggle: bool + parent_type: str + parent_type_other: str + selected_types: set[str] + x_direction: str + y_direction: str + z_direction: str + axis_order: str + location_type: str + precision: tuple[int, int, int] + initial_element_number: int + initial_type_number: int + initial_storey_number: int + element_numbering: str + type_numbering: str + storey_numbering: str + custom_storey: str + custom_storey_number: int + format: str + save_type: str + attribute_name: str + attribute_name_other: str + pset_name: str + property_name: str + custom_pset_name: str + remove_toggle: bool + check_duplicates: bool \ No newline at end of file diff --git a/src/bonsai/bonsai/bim/module/numbering/ui.py b/src/bonsai/bonsai/bim/module/numbering/ui.py index 9dd49be0de6..2b7c4f26916 100644 --- a/src/bonsai/bonsai/bim/module/numbering/ui.py +++ b/src/bonsai/bonsai/bim/module/numbering/ui.py @@ -1,5 +1,5 @@ # Bonsai - OpenBIM Blender Add-on -# Copyright (C) 2020, 2021 Dion Moult +# Copyright (C) 2022 Dion Moult # # This file is part of Bonsai. # @@ -15,137 +15,3 @@ # # You should have received a copy of the GNU General Public License # along with Bonsai. If not, see . - - -from bpy.types import Panel -import bonsai.tool as tool -#from bonsai.bim.module.numbering.util import NumberFormatting -from bonsai.bim.module.numbering_test.data import NumberingData - -class BIM_PT_Numbering(Panel): - bl_label = "Numbering Container" - bl_idname = "BIM_PT_numbering" - bl_space_type = "PROPERTIES" - bl_region_type = "WINDOW" - bl_context = "object" - bl_parent_id = "BIM_PT_tab_object_metadata" - - @classmethod - def draw(self, context): - - assert self.layout - layout = self.layout - - if not NumberingData.is_loaded: - NumberingData.load() - props = tool.Numbering.get_numbering_props() - - # Settings box - box = layout.box() - box.label(text="Settings") - grid = box.grid_flow(row_major=True, align=True, columns=4, even_columns=True) - grid.prop(props, "settings_name", text="Name") - # grid.operator("bonsai.save_settings", icon="FILE_TICK", text="Save") - # grid.operator("bonsai.clear_settings", icon="CANCEL", text="Clear") - # grid.operator("bonsai.export_settings", icon="EXPORT", text="Export") - - # grid.prop(props, "saved_settings", text="") - # grid.operator("bonsai.load_settings", icon="FILE_REFRESH", text="Load") - # grid.operator("bonsai.delete_settings", icon="TRASH", text="Delete") - # grid.operator("bonsai.import_settings", icon="IMPORT", text="Import") - - # # Selection box - # box = layout.box() - # box.label(text="Elements to number:") - # grid = box.grid_flow(row_major=True, align=False, columns=4, even_columns=True) - # grid.prop(props, "selected_toggle") - # grid.prop(props, "visible_toggle") - # grid.prop(props, "parent_type", text="") - # if props.parent_type == "Other": - # grid.prop(props, "parent_type_other", text="") - # else: - # grid.label(text="") - - # grid = box.grid_flow(row_major=True, align=True, columns=4, even_columns=True) - # grid.prop(props, "selected_types", expand=True) - - # # Numbering order box - # box = layout.box() - # box.label(text="Numbering order") - # # Create a grid for direction and precision - # grid = box.grid_flow(row_major=True, align=False, columns=4, even_columns=True) - # grid.label(text="Direction: ") - # grid.prop(props, "x_direction", text="X") - # grid.prop(props, "y_direction", text="Y") - # grid.prop(props, "z_direction", text="Z") - # grid.label(text="Precision: ") - # grid.prop(props, "precision", index=0, text="X") - # grid.prop(props, "precision", index=1, text="Y") - # grid.prop(props, "precision", index=2, text="Z") - - # # Axis order and reference point - # grid = box.grid_flow(row_major=True, align=True, columns=4) - # grid.label(text="Order:") - # grid.prop(props, "axis_order", text="") - # grid.label(text="Reference point:") - # grid.prop(props, "location_type", text="") - - # # Numbering systems box - # box = layout.box() - # box.label(text="Numbering of elements {E}, within type {T} and storeys {S}") - # grid = box.grid_flow(row_major=True, align=False, columns=4, even_columns=True) - # grid.label(text="Start at:") - # grid.prop(props, "initial_element_number", text="{E}") - # grid.prop(props, "initial_type_number", text="{T}") - # grid.prop(props, "initial_storey_number", text="{S}") - # grid.label(text="System:") - # grid.prop(props, "element_numbering", text="{E}") - # grid.prop(props, "type_numbering", text="{T}") - # grid.prop(props, "storey_numbering", text="{S}") - - # # Custom storey number - # if props.storey_numbering == "custom": - # box = box.box() - # row = box.row(align=False) - # row.prop(props, "custom_storey", text="Storey") - # row.prop(props, "custom_storey_number", text="Number") - - # # Numbering format box - # box = layout.box() - # box.label(text="Numbering format") - # grid = box.grid_flow(align=False, columns=4, even_columns=True) - # grid.label(text="Format:") - # grid.prop(props, "format", text="") - # # Show preview in a textbox style (non-editable) - # grid.label(text="Preview:") - # preview_box = grid.box() - # preview_box.label(text=NumberFormatting.format_preview) - - # # Storage options - # box = layout.box() - # box.label(text="Store number in") - - # grid = box.grid_flow(align=False, columns=4, even_columns=True) - # grid.prop(props, "save_type", text="") - # if props.save_type == "Attribute": - # grid.prop(props, "attribute_name", text="") - # if props.attribute_name == "Other": - # grid.prop(props, "attribute_name_other", text="") - # if props.save_type == "Pset": - # grid.prop(props, "pset_name", text="") - # if props.pset_name == "Custom Pset": - # grid.prop(props, "custom_pset_name", text="") - # grid.prop(props, "property_name", text="") - - # box.prop(props, "remove_toggle") - # box.prop(props, "check_duplicates_toggle") - - # # Actions - # layout.separator() - # row = layout.row(align=True) - # row.operator("bonsai.assign_numbers", icon="TAG", text="Assign numbers") - # row = layout.row(align=True) - # row.operator("bonsai.remove_numbers", icon="X", text="Remove numbers") - - - diff --git a/src/bonsai/bonsai/bim/module/numbering/workspace.py b/src/bonsai/bonsai/bim/module/numbering/workspace.py index c0dc6e553a3..41f47311f6d 100644 --- a/src/bonsai/bonsai/bim/module/numbering/workspace.py +++ b/src/bonsai/bonsai/bim/module/numbering/workspace.py @@ -18,25 +18,31 @@ import os +import bpy import bonsai.tool as tool from bpy.types import WorkSpaceTool +from functools import partial +from bonsai.bim.module.numbering.data import NumberingData + +from bonsai.bim.module.numbering.util import NumberFormatting class NumberingTool(WorkSpaceTool): bl_space_type = "VIEW_3D" bl_context_mode = "OBJECT" bl_idname = "bim.numbering_tool" bl_label = "Numbering Tool" - bl_description = "Assign or remove numbers from elements" + bl_description = "Gives you Numbering related superpowers" # TODO: replace with numbering icon bl_icon = os.path.join(os.path.dirname(__file__), "ops.authoring.numbering") bl_widget = None - @classmethod def draw_settings(context, layout, ws_tool): + # Unlike operators, Blender doesn't treat workspace tools as a class, so we'll create our own. NumberingToolUI.draw(context, layout) class NumberingToolUI: + @classmethod def draw(cls, context, layout): cls.layout = layout @@ -46,5 +52,137 @@ def draw(cls, context, layout): if not tool.Ifc.get(): row.label(text="No IFC Project", icon="ERROR") return + + if not NumberingData.is_loaded: + NumberingData.load() + + cls.draw_interface() + + @classmethod + def draw_interface(cls): + + assert (layout := cls.layout) + + props = tool.Numbering.get_numbering_props() + + cls.draw_settings(layout, props) + cls.draw_selection(layout, props) + cls.draw_numbering_order(layout, props) + cls.draw_numbering_systems(layout, props) + cls.draw_numbering_format(layout, props) + cls.draw_storage_options(layout, props) + + # Actions + box = layout.box() + row = box.row(align=True) + row.operator("bim.assign_numbers", icon="TAG", text="Assign Numbers") + row = box.row(align=True) + row.operator("bim.remove_numbers", icon="X", text="Remove Numbers") + + def draw_settings(layout, props): + box = layout.box() + box.alignment = "EXPAND" + box.label(text="Settings") + grid = box.grid_flow(row_major=True, align=True, columns=4, even_columns=True) + grid.prop(props, "settings_name", text="Name") + grid.operator("bim.save_settings", icon="FILE_TICK", text="Save") + grid.operator("bim.clear_settings", icon="CANCEL", text="Clear") + grid.operator("bim.export_settings", icon="EXPORT", text="Export") + + grid.prop(props, "saved_settings", text="") + grid.operator("bim.load_settings", icon="FILE_REFRESH", text="Load") + grid.operator("bim.delete_settings", icon="TRASH", text="Delete") + grid.operator("bim.import_settings", icon="IMPORT", text="Import") + + def draw_selection(layout, props): + box = layout.box() + box.alignment = "EXPAND" + box.label(text="Elements to number") + grid = box.grid_flow(row_major=True, align=True, columns=4, even_columns=True) + grid.prop(props, "selected_toggle") + grid.prop(props, "visible_toggle") + grid.prop(props, "parent_type", text="") + if props.parent_type == "Other": + grid.prop(props, "parent_type_other", text="") + else: + grid.label(text="") + + grid = box.grid_flow(row_major=True, align=True, columns=4, even_columns=True) + grid.prop(props, "selected_types", expand=True) + + def draw_numbering_order(layout, props): + box = layout.box() + box.alignment = "EXPAND" + box.label(text="Numbering order") + # Create a grid for direction and precision + grid = box.grid_flow(row_major=True, align=True, columns=4, even_columns=True) + grid.label(text="Direction: ") + grid.prop(props, "x_direction", text="X") + grid.prop(props, "y_direction", text="Y") + grid.prop(props, "z_direction", text="Z") + grid.label(text="Precision: ") + grid.prop(props, "precision", index=0, text="X") + grid.prop(props, "precision", index=1, text="Y") + grid.prop(props, "precision", index=2, text="Z") + + # Axis order and reference point + grid = box.grid_flow(row_major=True, align=True, columns=4) + grid.label(text="Order:") + grid.prop(props, "axis_order", text="") + grid.label(text="Reference point:") + grid.prop(props, "location_type", text="") + + def draw_numbering_systems(layout, props): + box = layout.box() + box.alignment = "EXPAND" + box.label(text="Numbering of elements {E}, within type {T} and storeys {S}") + grid = box.grid_flow(row_major=True, align=True, columns=4, even_columns=True) + grid.label(text="Start at:") + grid.prop(props, "initial_element_number", text="{E}") + grid.prop(props, "initial_type_number", text="{T}") + grid.prop(props, "initial_storey_number", text="{S}") + grid.label(text="System:") + grid.prop(props, "element_numbering", text="{E}") + grid.prop(props, "type_numbering", text="{T}") + grid.prop(props, "storey_numbering", text="{S}") + + # Custom storey number + if props.storey_numbering == "custom": + box = box.box() + row = box.row(align=True) + row.prop(props, "custom_storey", text="Storey") + row.prop(props, "custom_storey_number", text="Number") + + def draw_numbering_format(layout, props): + box = layout.box() + box.alignment = "EXPAND" + box.label(text="Numbering format") + grid = box.grid_flow(align=True, columns=4, even_columns=True) + grid.label(text="Format:") + grid.prop(props, "format", text="") + # Show preview in a textbox style (non-editable) + grid.label(text="Preview:") + preview_box = grid.box() + preview_box.label(text=NumberFormatting.format_preview) + + def draw_storage_options(layout, props): + box = layout.box() + box.alignment = "EXPAND" + box.label(text="Store number in") - pass + grid = box.grid_flow(align=True, columns=4, even_columns=True) + grid.prop(props, "save_type", text="") + if props.save_type == "Attribute": + grid.prop(props, "attribute_name", text="") + if props.attribute_name == "Other": + grid.prop(props, "attribute_name_other", text="") + if props.save_type == "Pset": + grid.prop(props, "pset_name", text="") + if props.pset_name == "Custom Pset": + grid.prop(props, "custom_pset_name", text="") + grid.prop(props, "property_name", text="") + + box.prop(props, "remove_toggle") + box.prop(props, "check_duplicates_toggle") + + diff --git a/src/bonsai/bonsai/core/numbering.py b/src/bonsai/bonsai/core/numbering.py index e5bb5041569..77632d505eb 100644 --- a/src/bonsai/bonsai/core/numbering.py +++ b/src/bonsai/bonsai/core/numbering.py @@ -16,3 +16,65 @@ # You should have received a copy of the GNU General Public License # along with Bonsai. If not, see . +# ############################################################################ # + +# Hey there! Welcome to the Bonsai code. Please feel free to reach +# out if you have any questions or need further guidance. Happy hacking! + +# ############################################################################ # + +# Every module has a core.py file to define all of its core functions. A core +# function describes what happens when the user wants to do something like +# pressing a button. + +# Think of a core function as a short poem of pseudocode that describes what +# happens in different usecases. A core should be no more than 50 lines of code, +# even in the most complex of features. A core simply delegates tasks to tools +# in a sequence that describes the flow of logic in a feature - in other words, +# it tells tools to do different things. You will notice that the core doesn't +# have any code that deals with Blender or IFC directly - these are all little +# details that are hidden away in tools. The core is not interested in these +# details, the core is only concerned with the big picture. + +# Imagine, no matter how complex a software can be, every feature can be +# described in regular sentences in under 50 lines. That is the purpose of the +# core. + + +# Here's the simplest possible core function. It does one thing only. Remember: +# core functions delegate tasks to tools, so all core functions need at least +# one tool. +def demonstrate_hello_world(demo): + # We're telling the demo tool to set a message. We aren't interested how the + # tool works, that's a detail. We aren't interested in the interface, like + # where the message is shown. You can name these functions whatever you feel + # best describes what's going on, like if you had to describe the feature to + # someone else. + demo.set_message("Hello, World!") + + +# Here's a slightly more complex core function. It uses two tools. By default, +# you'll want to use your module's tool (in this case, "Demo") for all tasks, +# except for changing IFC data, where you'll use the "Ifc" tool. At a glance, +# simply by seeing what tools are used, this gives you an idea about what +# aspects (or dependencies) a core function cares about. This function also has +# an non-tool input called "name". Non-tool inputs should be keyword arguments +# with possible default values. +def demonstrate_rename_project(ifc, demo, name=None): + # As you can see, core functions read almost like pseudocode. A great way to + # code a new feature is to write out what it does in English first, then + # change them into tool functions. You can choose any function you want, and + # you can see a list of every single tool function in # `core/tool.py`. + if name: + project = demo.get_project() + ifc.run("attribute.edit_attributes", product=project, attributes={"Name": name}) + demo.clear_name_field() + demo.hide_user_hints() + else: + demo.show_user_hints() + + +# here this core function uses the web tool to send a message to the web UI +# we call the send_webui_data method to send any kind of data to the web UI +def send_webui_demo_message(web, message="Hello"): + web.send_webui_data(data=message, data_key="demo_message", event="demo_data") diff --git a/src/bonsai/bonsai/tool/blender.py b/src/bonsai/bonsai/tool/blender.py index 0f25681ada4..439c8a2ed56 100644 --- a/src/bonsai/bonsai/tool/blender.py +++ b/src/bonsai/bonsai/tool/blender.py @@ -1329,12 +1329,12 @@ def register_toolbar(cls): ws_structural.StructuralTool, after={"bim.spatial_tool"}, separator=False, group=False ) bpy.utils.register_tool( - ws_covering.CoveringTool, after={"bim.structural_tool"}, separator=False, group=False + ws_numbering.NumberingTool, after={"bim.structural_tool"}, separator=False, group=False ) bpy.utils.register_tool( - ws_numbering.NumberingTool, after={"bim.numbering_tool"}, separator=False, group=False + ws_covering.CoveringTool, after={"bim.numbering_tool"}, separator=False, group=False ) - + except: pass @@ -1363,8 +1363,8 @@ def unregister_toolbar(cls): bpy.utils.unregister_tool(ws_drawing.AnnotationTool) bpy.utils.unregister_tool(ws_spatial.SpatialTool) bpy.utils.unregister_tool(ws_structural.StructuralTool) - bpy.utils.unregister_tool(ws_covering.CoveringTool) bpy.utils.unregister_tool(ws_numbering.NumberingTool) + bpy.utils.unregister_tool(ws_covering.CoveringTool) except: pass diff --git a/src/bonsai/bonsai/tool/numbering.py b/src/bonsai/bonsai/tool/numbering.py index 5c65a75307d..f74c439d8c1 100644 --- a/src/bonsai/bonsai/tool/numbering.py +++ b/src/bonsai/bonsai/tool/numbering.py @@ -1,5 +1,5 @@ # Bonsai - OpenBIM Blender Add-on -# Copyright (C) 2021 Dion Moult +# Copyright (C) 2022 Dion Moult # # This file is part of Bonsai. # @@ -16,23 +16,67 @@ # You should have received a copy of the GNU General Public License # along with Bonsai. If not, see . +# ############################################################################ # + +# Hey there! Welcome to the Bonsai code. Please feel free to reach +# out if you have any questions or need further guidance. Happy hacking! + +# ############################################################################ # + +# Every module has a tool file which implements all the functions that the core +# needs. Whereas the core is simply high level code, the tool file has the +# concrete implementations, dealing with exactly how things interact with +# Blender's property systems, IFC's data structures, the filesystem, geometry +# processing, and more. + from __future__ import annotations import bpy -import bonsai.core.tool import ifcopenshell +import bonsai.core.tool import bonsai.tool as tool from typing import TYPE_CHECKING if TYPE_CHECKING: from bonsai.bim.module.numbering.prop import BIMNumberingProperties + +# There is always one class in each tool file, which implements the interface +# defined by `core/tool.py`. class Numbering(bonsai.core.tool.Numbering): @classmethod def get_numbering_props(cls) -> BIMNumberingProperties: - return bpy.context.scene.BIMNumberingProperties + assert (scene := bpy.context.scene) + return scene.BIMNumberingProperties # pyright: ignore[reportAttributeAccessIssue] + + @classmethod + def clear_name_field(cls) -> None: + # In this concrete implementation, we see that "clear name field" + # actually translates to "set this Blender string property to empty + # string". In this case, it's pretty simple - but even simple scenarios + # like these are important to implement in the tool, as it makes the + # pseudocode easier to read in the core, and makes it easier to test + # implementations separately from control flow. It also makes it easy to + # refactor and share functions, where every tool function is captured by + # a function name that describes its intention. + props = cls.get_numbering_props() + props.name = "" @classmethod def get_project(cls) -> ifcopenshell.entity_instance: return tool.Ifc.get().by_type("IfcProject")[0] - + + @classmethod + def hide_user_hints(cls) -> None: + props = cls.get_numbering_props() + props.show_hints = False + + @classmethod + def set_message(cls, message) -> None: + props = cls.get_numbering_props() + props.message = message + + @classmethod + def show_user_hints(cls) -> None: + props = cls.get_numbering_props() + props.show_hints = True From 3350403ad9d457cec562cdbc60edb3374d386e27 Mon Sep 17 00:00:00 2001 From: BjornFidder Date: Wed, 27 Aug 2025 13:56:16 +0200 Subject: [PATCH 4/9] Refactor numbering module and add UI panel Moved numbering logic from module/numbering/util.py to bonsai/core/numbering.py and deleted util.py. Updated imports across the module to use bonsai.core.numbering. Moved all the numbering settings to a separate UI panel (BIM_PT_Numbering), defined in ui.py, which is shown in the Tool panel of the sidebar, in a separate panel. Updated operator logic to use new Numbering class methods. Improved property naming for clarity and consistency. --- .../bonsai/bim/module/numbering/__init__.py | 5 +- .../bonsai/bim/module/numbering/data.py | 18 +- .../bonsai/bim/module/numbering/operator.py | 163 +--- .../bonsai/bim/module/numbering/prop.py | 31 +- src/bonsai/bonsai/bim/module/numbering/ui.py | 142 +++ .../bonsai/bim/module/numbering/util.py | 670 -------------- .../bonsai/bim/module/numbering/workspace.py | 137 +-- src/bonsai/bonsai/core/numbering.py | 876 ++++++++++++++++-- 8 files changed, 999 insertions(+), 1043 deletions(-) delete mode 100644 src/bonsai/bonsai/bim/module/numbering/util.py diff --git a/src/bonsai/bonsai/bim/module/numbering/__init__.py b/src/bonsai/bonsai/bim/module/numbering/__init__.py index afa4104b5cb..2b96ce5d625 100644 --- a/src/bonsai/bonsai/bim/module/numbering/__init__.py +++ b/src/bonsai/bonsai/bim/module/numbering/__init__.py @@ -17,7 +17,7 @@ # along with Bonsai. If not, see . import bpy -from . import prop, operator, workspace +from . import prop, operator, ui, workspace classes = ( prop.BIMNumberingProperties, @@ -29,7 +29,8 @@ operator.ClearSettings, operator.ImportSettings, operator.ExportSettings, - operator.ShowMessage + operator.ShowMessage, + ui.BIM_PT_Numbering ) def register(): diff --git a/src/bonsai/bonsai/bim/module/numbering/data.py b/src/bonsai/bonsai/bim/module/numbering/data.py index 5da70bb2bce..77bddd85995 100644 --- a/src/bonsai/bonsai/bim/module/numbering/data.py +++ b/src/bonsai/bonsai/bim/module/numbering/data.py @@ -22,17 +22,27 @@ def refresh(): NumberingData.is_loaded = False + class NumberingData: data = {} is_loaded = False + @classmethod def load(cls): - cls.data = { - "has_project": cls.has_project(), - "project": cls.project(), - } cls.is_loaded = True + cls.data["poll"] = cls.poll() + if cls.data["poll"]: + cls.data.update( + { + "has_project": cls.has_project(), + "project": cls.project() + } + ) + @classmethod + def poll(cls): + return cls.has_project() + @classmethod def has_project(cls): return bool(tool.Ifc.get()) diff --git a/src/bonsai/bonsai/bim/module/numbering/operator.py b/src/bonsai/bonsai/bim/module/numbering/operator.py index 391aed33179..7a5d4982ec6 100644 --- a/src/bonsai/bonsai/bim/module/numbering/operator.py +++ b/src/bonsai/bonsai/bim/module/numbering/operator.py @@ -22,7 +22,7 @@ import json import functools as ft -from bonsai.bim.module.numbering.util import Settings, LoadSelection, NumberFormatting, SaveNumber, Storeys, ObjectGeometry, get_id +from bonsai.core.numbering import Settings, LoadSelection, SaveNumber, Numbering, get_id class UndoOperator: @staticmethod @@ -45,7 +45,7 @@ def execute_with_undo(operator, context, method): old_numbers = {get_id(element): SaveNumber.get_number(element, settings) for element in elements} new_numbers = old_numbers.copy() - result = method(settings, new_numbers) + result = method(operator, settings, new_numbers) operator.transaction_data = {"old_value": old_numbers, "new_value": new_numbers} IfcStore.add_transaction_operation(operator) @@ -87,137 +87,8 @@ class AssignNumbers(bpy.types.Operator): bl_description = "Assign numbers to selected objects" bl_options = {"REGISTER", "UNDO"} - def number_elements(elements, ifc_file, settings, elements_locations = None, elements_dimensions = None, storeys = None, numbers_cache = {}, storeys_numbers={}, report=None, remove_count=None): - """Number elements in the IFC file with the provided settings. If element locations or dimensions are specified, these are used for sorting. - Providing numbers_cache, a dictionary with element-> currently saved number, speeds up execution. - If storeys_numbers is provided, as a dictionary storey->number, this is used for assigning storey numbers.""" - if report is None: - def report(report_type, message): - if report_type == {"INFO"}: - print("INFO: ", message) - if report_type == {"WARNING"}: - raise Exception(message) - if storeys is None: - storeys = [] - - number_count = 0 - - if elements_dimensions: - elements.sort(key=ft.cmp_to_key(lambda a, b: ObjectGeometry.cmp_within_precision(elements_dimensions[a], elements_dimensions[b], settings, use_dir=False))) - if elements_locations: - elements.sort(key=ft.cmp_to_key(lambda a, b: ObjectGeometry.cmp_within_precision(elements_locations[a], elements_locations[b], settings))) - - selected_types = LoadSelection.get_selected_types(settings) - - if not selected_types: - selected_types = list(set(element.is_a() for element in elements)) - - elements_by_type = [[element for element in elements if element.is_a() == ifc_type] for ifc_type in selected_types] - - failed_types = set() - for (element_number, element) in enumerate(elements): - - type_index = selected_types.index(element.is_a()) - type_elements = elements_by_type[type_index] - type_number = type_elements.index(element) - type_name = selected_types[type_index][3:] - - if storeys: - storey_number = Storeys.get_storey_number(element, storeys, settings, storeys_numbers) - if storey_number is None and "{S}" in settings.get("format"): - if report is not None: - report({'WARNING'}, f"Element {getattr(element, 'Name', '')} of type {element.is_a()} with ID {get_id(element)} is not contained in any storey.") - else: - raise Exception(f"Element {getattr(element, 'Name', '')} of type {element.is_a()} with ID {get_id(element)} is not contained in any storey.") - else: - storey_number = None - - number = NumberFormatting.format_number(settings, (element_number, type_number, storey_number), (len(elements), len(type_elements), len(storeys)), type_name) - count = SaveNumber.save_number(ifc_file, element, number, settings, numbers_cache) - if count is None: - report({'WARNING'}, f"Failed to save number for element {getattr(element, 'Name', '')} of type {element.is_a()} with ID {get_id(element)}.") - failed_types.add(element.is_a()) - else: - number_count += count - - if failed_types: - report({'WARNING'}, f"Failed to renumber the following types: {failed_types}") - - if settings.get("remove_toggle") and remove_count is not None: - report({'INFO'}, f"Renumbered {number_count} objects, removed number from {remove_count} objects.") - else: - report({'INFO'}, f"Renumbered {number_count} objects.") - - return {'FINISHED'}, number_count - - def assign_numbers(self, settings, numbers_cache): - """Assign numbers to selected objects based on their IFC type and location.""" - ifc_file = tool.Ifc.get() - - remove_count = 0 - - if settings.get("remove_toggle"): - for obj in bpy.context.scene.objects: - if (settings.get("selected_toggle") and obj not in bpy.context.selected_objects) or \ - (settings.get("visible_toggle") and not obj.visible_get()): - element = tool.Ifc.get_entity(obj) - if element is not None and element.is_a(LoadSelection.get_parent_type(settings)): - count_diff = SaveNumber.remove_number(ifc_file, element, settings, numbers_cache) - remove_count += count_diff - - objects = LoadSelection.load_selected_objects(settings) - - if not objects: - self.report({'WARNING'}, f"No objects selected or available for numbering, removed {remove_count} existing numbers.") - return {'CANCELLED'} - - selected_types = LoadSelection.get_selected_types(settings) - possible_types = [tupl[0] for tupl in LoadSelection.possible_types] - - selected_elements = [] - elements_locations = {} - elements_dimensions = {} - for obj in objects: - element = tool.Ifc.get_entity(obj) - if element is None: - continue - if element.is_a() in selected_types: - selected_elements.append(element) - elements_locations[element] = ObjectGeometry.get_object_location(obj, settings) - elements_dimensions[element] = ObjectGeometry.get_object_dimensions(obj) - elif settings.get("remove_toggle") and element.is_a() in possible_types: - remove_count += SaveNumber.remove_number(ifc_file, element, settings, numbers_cache) - - if not selected_elements: - self.report({'WARNING'}, f"No elements selected or available for numbering, removed {remove_count} existing numbers.") - - storeys = Storeys.get_storeys(settings) - res, _= AssignNumbers.number_elements(selected_elements, - ifc_file, settings, - elements_locations, - elements_dimensions, - storeys, - numbers_cache, - report = self.report, - remove_count=remove_count) - - if settings.get("check_duplicates_toggle"): - numbers = [] - for obj in bpy.context.scene.objects: - element = tool.Ifc.get_entity(obj) - if element is None or not element.is_a(LoadSelection.get_parent_type(settings)): - continue - number = SaveNumber.get_number(element, settings, numbers_cache) - if number in numbers: - self.report({'WARNING'}, f"The model contains duplicate numbers") - return {'FINISHED'} - if number is not None: - numbers.append(number) - - return res - def execute(self, context): - return UndoOperator.execute_with_undo(self, context, self.assign_numbers) + return UndoOperator.execute_with_undo(self, context, Numbering.assign_numbers) def rollback(self, data): UndoOperator.rollback(self, data) @@ -231,35 +102,9 @@ class RemoveNumbers(bpy.types.Operator): bl_description = "Remove numbers from selected objects, from the selected attribute or Pset" bl_options = {"REGISTER", "UNDO"} - def remove_numbers(self, settings, numbers_cache): - """Remove numbers from selected objects""" - ifc_file = tool.Ifc.get() - - remove_count = 0 - - objects = bpy.context.selected_objects if settings.get("selected_toggle") else bpy.context.scene.objects - if settings.get("visible_toggle"): - objects = [obj for obj in objects if obj.visible_get()] - - if not objects: - self.report({'WARNING'}, f"No objects selected or available for removal.") - return {'CANCELLED'} - - for obj in objects: - element = tool.Ifc.get_entity(obj) - if element is not None and element.is_a(LoadSelection.get_parent_type(settings)): - remove_count += SaveNumber.remove_number(ifc_file, element, settings, numbers_cache) - numbers_cache[get_id(element)] = None - - if remove_count == 0: - self.report({'WARNING'}, f"No elements selected or available for removal.") - return {'CANCELLED'} - - self.report({'INFO'}, f"Removed {remove_count} existing numbers.") - return {'FINISHED'} def execute(self, context): - return UndoOperator.execute_with_undo(self, context, self.remove_numbers) + return UndoOperator.execute_with_undo(self, context, Numbering.remove_numbers) def rollback(self, data): UndoOperator.rollback(self, data) diff --git a/src/bonsai/bonsai/bim/module/numbering/prop.py b/src/bonsai/bonsai/bim/module/numbering/prop.py index 13a5258be7c..07c0a13998f 100644 --- a/src/bonsai/bonsai/bim/module/numbering/prop.py +++ b/src/bonsai/bonsai/bim/module/numbering/prop.py @@ -10,8 +10,7 @@ BoolProperty, IntVectorProperty ) -from .util import Settings, LoadSelection, NumberFormatting, NumberingSystems, SaveNumber, Storeys - +from bonsai.core.numbering import Settings, LoadSelection, NumberFormatting, NumberingSystems, SaveNumber, Storeys class BIMNumberingProperties(PropertyGroup): settings_name: StringProperty( @@ -33,14 +32,14 @@ def get_saved_settings_items(self, context): ) # pyright: ignore[reportInvalidTypeForm] selected_toggle: BoolProperty( - name="Selected only", + name="Selected Only", description="Only number selected objects", default=False, update=NumberFormatting.update_format_preview ) # pyright: ignore[reportInvalidTypeForm] visible_toggle: BoolProperty( - name="Visible only", + name="Visible Only", description="Only number visible objects", default=False, update=NumberFormatting.update_format_preview @@ -71,7 +70,7 @@ def update_selected_types(self, context): SaveNumber.update_pset_names(self, context) selected_types: EnumProperty( - name="Selected types", + name="Selected Types", description="Select which types of elements to number", items= LoadSelection.get_possible_types, options={'ENUM_FLAG'}, @@ -109,7 +108,7 @@ def update_selected_types(self, context): ) # pyright: ignore[reportInvalidTypeForm] axis_order: EnumProperty( - name="Axis order", + name="Axis Order", description="Order of axes in numbering elements", items=[ ("XYZ", "X, Y, Z", "Number elements in X, Y, Z order"), @@ -123,7 +122,7 @@ def update_selected_types(self, context): ) # pyright: ignore[reportInvalidTypeForm] location_type: EnumProperty( - name="Reference location", + name="Reference Location", description="Location to use for sorting elements", items=[ ("CENTER", "Center", "Use object center for sorting"), @@ -203,7 +202,7 @@ def update_storey_numbering(self, context): ) # pyright: ignore[reportInvalidTypeForm] custom_storey_number: IntProperty( - name = "Storey number", + name = "Storey Number", description = f"Set custom storey number for selected storey, stored in {Storeys.settings['pset_name']} in the IFC element", get = Storeys.get_custom_storey_number, set = Storeys.set_custom_storey_number @@ -223,7 +222,7 @@ def update_storey_numbering(self, context): ) # pyright: ignore[reportInvalidTypeForm] save_type : EnumProperty( - name="Type of number storage", + name="Type of Number Storage", items = [("Attribute", "Attribute", "Store number in an attribute of the IFC element"), ("Pset", "Pset", "Store number in a Pset of the IFC element") ], @@ -232,7 +231,7 @@ def update_storey_numbering(self, context): ) # pyright: ignore[reportInvalidTypeForm] attribute_name : EnumProperty( - name="Attribute name", + name="Attribute Name", description="Name of the attribute to store the number", items = [("Tag", "Tag", "Store number in IFC Tag attribute"), ("Name", "Name", "Store number in IFC Name attribute"), @@ -244,7 +243,7 @@ def update_storey_numbering(self, context): ) # pyright: ignore[reportInvalidTypeForm] attribute_name_other : StringProperty( - name="Other attribute name", + name="Other Attribute Name", description="Name of the other attribute to store the number", default="Tag" ) # pyright: ignore[reportInvalidTypeForm] @@ -253,31 +252,31 @@ def get_pset_names(self, context): return SaveNumber.pset_names pset_name : EnumProperty( - name="Pset name", + name="Pset Name", description="Name of the Pset to store the number", items = get_pset_names ) # pyright: ignore[reportInvalidTypeForm] property_name : StringProperty( - name="Property name", + name="Property Name", description="Name of the property to store the number", default="Number" ) # pyright: ignore[reportInvalidTypeForm] custom_pset_name : StringProperty( - name="Custom Pset name", + name="Custom Pset Name", description="Name of the custom Pset to store the number", default="Pset_Numbering" ) # pyright: ignore[reportInvalidTypeForm] remove_toggle: BoolProperty( - name="Remove numbers from unselected objects", + name="Remove Numbers from Unselected Objects", description="Remove numbers from unselected objects in the scene", default=True ) # pyright: ignore[reportInvalidTypeForm] check_duplicates_toggle: BoolProperty( - name="Check for duplicate numbers", + name="Check for Duplicate Numbers", description="Check for duplicate numbers in all objects in the scene", default=True ) # pyright: ignore[reportInvalidTypeForm] diff --git a/src/bonsai/bonsai/bim/module/numbering/ui.py b/src/bonsai/bonsai/bim/module/numbering/ui.py index 2b7c4f26916..02a09d6740d 100644 --- a/src/bonsai/bonsai/bim/module/numbering/ui.py +++ b/src/bonsai/bonsai/bim/module/numbering/ui.py @@ -15,3 +15,145 @@ # # You should have received a copy of the GNU General Public License # along with Bonsai. If not, see . + +from bpy.types import Panel +import bonsai.tool as tool +from bonsai.core.numbering import NumberFormatting +from bonsai.bim.module.numbering.data import NumberingData + +class BIM_PT_Numbering(Panel): + bl_label = "Numbering settings" + bl_space_type = "VIEW_3D" + bl_region_type = "UI" + bl_category = "Tool" + + @classmethod + def poll(cls, context): + return context.workspace.tools.from_space_view3d_mode(context.mode).idname == "bim.numbering_tool" + + def draw(cls, context): + assert (layout := cls.layout) + + if not NumberingData.is_loaded: + NumberingData.load() + + props = tool.Numbering.get_numbering_props() + + cls.draw_selection(layout, props) + cls.draw_numbering_order(layout, props) + cls.draw_numbering_systems(layout, props) + cls.draw_numbering_format(layout, props) + cls.draw_storage_options(layout, props) + cls.draw_settings(layout, props) + + @classmethod + def draw_selection(cls, layout, props): + box = layout.box() + box.alignment = "EXPAND" + box.label(text="Elements to number") + grid = box.grid_flow(row_major=True, align=True, columns=4, even_columns=True) + grid.prop(props, "selected_toggle") + grid.prop(props, "visible_toggle") + grid.prop(props, "parent_type", text="") + if props.parent_type == "Other": + grid.prop(props, "parent_type_other", text="") + else: + grid.label(text="") + + grid = box.grid_flow(row_major=True, align=True, columns=4, even_columns=True) + grid.prop(props, "selected_types", expand=True) + + @classmethod + def draw_numbering_order(cls, layout, props): + box = layout.box() + box.alignment = "EXPAND" + box.label(text="Numbering Order") + # Create a grid for direction and precision + grid = box.grid_flow(row_major=True, align=True, columns=4, even_columns=True) + grid.label(text="Direction: ") + grid.prop(props, "x_direction", text="X") + grid.prop(props, "y_direction", text="Y") + grid.prop(props, "z_direction", text="Z") + grid.label(text="Precision: ") + grid.prop(props, "precision", index=0, text="X") + grid.prop(props, "precision", index=1, text="Y") + grid.prop(props, "precision", index=2, text="Z") + + # Axis order and reference point + grid = box.grid_flow(row_major=True, align=True, columns=4) + grid.label(text="Order:") + grid.prop(props, "axis_order", text="") + grid.label(text="Reference Point:") + grid.prop(props, "location_type", text="") + + @classmethod + def draw_numbering_systems(cls, layout, props): + box = layout.box() + box.alignment = "EXPAND" + box.label(text="Numbering of Elements {E}, Within Type {T} and Storeys {S}") + grid = box.grid_flow(row_major=True, align=True, columns=4, even_columns=True) + grid.label(text="Start at:") + grid.prop(props, "initial_element_number", text="{E}") + grid.prop(props, "initial_type_number", text="{T}") + grid.prop(props, "initial_storey_number", text="{S}") + grid.label(text="System:") + grid.prop(props, "element_numbering", text="{E}") + grid.prop(props, "type_numbering", text="{T}") + grid.prop(props, "storey_numbering", text="{S}") + + # Custom storey number + if props.storey_numbering == "custom": + box = box.box() + row = box.row(align=True) + row.prop(props, "custom_storey", text="Storey") + row.prop(props, "custom_storey_number", text="Number") + + @classmethod + def draw_numbering_format(cls, layout, props): + box = layout.box() + box.alignment = "EXPAND" + box.label(text="Numbering Format") + grid = box.grid_flow(align=True, columns=4, even_columns=True) + grid.label(text="Format:") + grid.prop(props, "format", text="") + # Show preview in a textbox style (non-editable) + grid.label(text="Preview:") + preview_box = grid.box() + preview_box.label(text=NumberFormatting.format_preview) + + @classmethod + def draw_storage_options(cls, layout, props): + box = layout.box() + box.alignment = "EXPAND" + box.label(text="Store Number in") + + grid = box.grid_flow(align=True, columns=4, even_columns=True) + grid.prop(props, "save_type", text="") + if props.save_type == "Attribute": + grid.prop(props, "attribute_name", text="") + if props.attribute_name == "Other": + grid.prop(props, "attribute_name_other", text="") + if props.save_type == "Pset": + grid.prop(props, "pset_name", text="") + if props.pset_name == "Custom Pset": + grid.prop(props, "custom_pset_name", text="") + grid.prop(props, "property_name", text="") + + box.prop(props, "remove_toggle") + box.prop(props, "check_duplicates_toggle") + + @classmethod + def draw_settings(cls, layout, props): + box = layout.box() + box.alignment = "EXPAND" + box.label(text="Manage Settings") + grid = box.grid_flow(row_major=True, align=True, columns=4, even_columns=True) + grid.prop(props, "settings_name", text="Name") + grid.operator("bim.save_settings", icon="FILE_TICK", text="Save") + grid.operator("bim.clear_settings", icon="CANCEL", text="Clear") + grid.operator("bim.export_settings", icon="EXPORT", text="Export") + + grid.prop(props, "saved_settings", text="") + grid.operator("bim.load_settings", icon="FILE_REFRESH", text="Load") + grid.operator("bim.delete_settings", icon="TRASH", text="Delete") + grid.operator("bim.import_settings", icon="IMPORT", text="Import") diff --git a/src/bonsai/bonsai/bim/module/numbering/util.py b/src/bonsai/bonsai/bim/module/numbering/util.py deleted file mode 100644 index 5551850360b..00000000000 --- a/src/bonsai/bonsai/bim/module/numbering/util.py +++ /dev/null @@ -1,670 +0,0 @@ -# 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 - -import ifcopenshell.api as ifc_api -from ifcopenshell.util.element import get_pset -from ifcopenshell.util.pset import PsetQto - -from mathutils import Vector -import functools as ft -import numpy as np -import ifcopenshell.geom as geom -import ifcopenshell.util.shape as ifc_shape - -import string -import json - -def get_id(element): - return getattr(element, "GlobalId", element.id()) - -class SaveNumber: - - pset_names = [("Custom", "Custom Pset", "")] - pset_common_names = {} - ifc_file = None - pset_qto = None - - @staticmethod - def update_ifc_file(): - if (ifc_file := tool.Ifc.get()) != SaveNumber.ifc_file: - SaveNumber.ifc_file = ifc_file - SaveNumber.pset_qto = PsetQto(ifc_file.schema) - - @staticmethod - def get_number(element, settings, numbers_cache=None): - if element is None: - return None - if numbers_cache is None: - numbers_cache = {} - if get_id(element) in numbers_cache: - return numbers_cache[get_id(element)] - if settings.get("save_type") == "Attribute": - return getattr(element, SaveNumber.get_attribute_name(settings), None) - if settings.get("save_type") == "Pset": - pset_name = SaveNumber.get_pset_name(element, settings) - if (pset := get_pset(element, pset_name)): - return pset.get(settings.get("property_name")) - return None - - @staticmethod - def save_number(ifc_file, element, number, settings, numbers_cache=None): - if element is None: - return None - if numbers_cache is None: - numbers_cache = {} - if number == SaveNumber.get_number(element, settings, numbers_cache): - return 0 - if settings.get("save_type") == "Attribute": - attribute_name = SaveNumber.get_attribute_name(settings) - if not hasattr(element, attribute_name): - return None - if attribute_name == "Name" and number is None: - number = element.is_a().strip("Ifc") #Reset Name to name of type - setattr(element, attribute_name, number) - numbers_cache[get_id(element)] = number - return 1 - if settings.get("save_type") == "Pset": - pset_name = SaveNumber.get_pset_name(element, settings) - if not pset_name: - return None - if pset := get_pset(element, pset_name): - pset = ifc_file.by_id(pset["id"]) - else: - pset = ifc_api.run("pset.add_pset", ifc_file, product=element, name=pset_name) - ifc_api.run("pset.edit_pset", ifc_file, pset=pset, properties={settings["property_name"]: number}, should_purge=True) - if number is None and not pset.HasProperties: - ifc_api.run("pset.remove_pset", ifc_file, product=element, pset=pset) - numbers_cache[get_id(element)] = number - return 1 - return None - - @staticmethod - def remove_number(ifc_file, element, settings, numbers_cache=None): - count = SaveNumber.save_number(ifc_file, element, None, settings, numbers_cache) - return int(count or 0) - - def get_attribute_name(settings): - if settings.get("attribute_name") == "Other": - return settings.get("attribute_name_other") - return settings.get("attribute_name") - - @staticmethod - def get_pset_name(element, settings): - if settings.get("pset_name") == "Common": - ifc_type = element.is_a() - name = SaveNumber.pset_common_names.get(ifc_type, None) - return name - if settings.get("pset_name") == "Custom Pset": - return settings.get("custom_pset_name") - return settings.get("pset_name") - - @staticmethod - def update_pset_names(prop, context): - settings = Settings.to_dict(context.scene.BIMNumberingProperties) - pset_names_sets = [set(SaveNumber.pset_qto.get_applicable_names(ifc_type)) for ifc_type in LoadSelection.get_selected_types(settings)] - intersection = set.intersection(*pset_names_sets) if pset_names_sets else set() - SaveNumber.pset_names = [('Custom Pset', 'Custom Pset', 'Store in custom Pset with selected name'), - ('Common', 'Pset_Common', 'Store in Pset common of the type, e.g. Pset_WallCommon')] + \ - [(name, name, f"Store in Pset called {name}") for name in intersection] - - @staticmethod - def get_pset_common_names(elements): - SaveNumber.pset_common_names = {} - pset_qto = PsetQto(SaveNumber.ifc_file.schema) - for element in elements: - ifc_type = element.is_a() - if ifc_type in SaveNumber.pset_common_names: - continue - pset_names = pset_qto.get_applicable_names(ifc_type) - if (name_guess := "Pset_" + ifc_type.strip("Ifc") + "Common") in pset_names: - pset_common_name = name_guess - elif (name_guess := "Pset_" + ifc_type.strip("Ifc") + "TypeCommon") in pset_names: - pset_common_name = name_guess - elif common_names := [name for name in pset_names if 'Common' in name]: - pset_common_name = common_names[0] - else: - pset_common_name = None - SaveNumber.pset_common_names[ifc_type] = pset_common_name - -class LoadSelection: - - all_objects = [] - selected_objects = [] - possible_types = [] - - @staticmethod - def get_parent_type(settings): - """Get the parent type from the settings.""" - if settings.get("parent_type") == "Other": - return settings.get("parent_type_other") - return settings.get("parent_type") - - @staticmethod - def load_selected_objects(settings): - """Load the selected objects based on the current context.""" - objects = bpy.context.selected_objects if settings.get("selected_toggle") else bpy.context.scene.objects - if settings.get("visible_toggle"): - objects = [obj for obj in objects if obj.visible_get()] - return objects - - @staticmethod - def get_selected_types(settings): - """Get the selected IFC types from the settings, processing if All types are selected""" - selected_types = settings.get("selected_types", []) - if "All" in selected_types: - selected_types = [type_tuple[0] for type_tuple in LoadSelection.possible_types[1:]] - return selected_types - - @staticmethod - def load_possible_types(objects, parent_type): - """Load the available IFC types and their counts from the selected elements.""" - if not objects: - return [("All", "All", "element")], {"All": 0} - - ifc_types = [("All", "All", "element")] - seen_types = [] - number_counts = {"All": 0} - - for obj in objects: - element = tool.Ifc.get_entity(obj) - if element is None or not element.is_a(parent_type): - continue - ifc_type = element.is_a() #Starts with "Ifc", which we can strip by starting from index 3 - - if ifc_type not in seen_types: - seen_types.append(ifc_type) - ifc_types.append((ifc_type, ifc_type[3:], ifc_type[3:].lower())) # Store type as (id, name, name_lower) - number_counts[ifc_type] = 0 - - number_counts["All"] += 1 - number_counts[ifc_type] += 1 - - ifc_types.sort(key=lambda ifc_type: ifc_type[0]) - - return ifc_types, number_counts - - @staticmethod - def update_objects(prop, context): - settings = Settings.to_dict(context.scene.BIMNumberingProperties) - ifc_types, number_counts = LoadSelection.load_possible_types(LoadSelection.selected_objects, LoadSelection.get_parent_type(settings)) - LoadSelection.possible_types = [(id, name + f": {number_counts[id]}", "") for (id, name, _) in ifc_types] - NumberFormatting.update_format_preview(prop, context) - SaveNumber.update_pset_names(prop, context) - SaveNumber.update_ifc_file() - - @staticmethod - def get_possible_types(prop, context): - """Return the list of available types for selection.""" - props = context.scene.BIMNumberingProperties - settings = {"selected_toggle": props.selected_toggle, "visible_toggle": props.visible_toggle} - all_objects = list(bpy.context.scene.objects) - objects = LoadSelection.load_selected_objects(settings) - if all_objects != LoadSelection.all_objects or objects != LoadSelection.selected_objects: - LoadSelection.all_objects = all_objects - LoadSelection.selected_objects = objects - LoadSelection.update_objects(prop, context) - return LoadSelection.possible_types - -class Storeys: - - settings = {"save_type": "Pset", - "pset_name": "Pset_Numbering", - "property_name": "CustomStoreyNumber"} - - @staticmethod - def get_storeys(settings): - """Get all storeys from the current scene.""" - storeys = [] - storey_locations = {} - for obj in bpy.context.scene.objects: - element = tool.Ifc.get_entity(obj) - if element is not None and element.is_a("IfcBuildingStorey"): - storeys.append(element) - storey_locations[element] = ObjectGeometry.get_object_location(obj, settings) - storeys.sort(key=ft.cmp_to_key(lambda a, b: ObjectGeometry.cmp_within_precision(storey_locations[a], storey_locations[b], settings, use_dir=False))) - return storeys - - @staticmethod - def update_custom_storey(props, context): - storeys = Storeys.get_storeys(Settings.to_dict(context.scene.BIMNumberingProperties)) - storey = next((storey for storey in storeys if storey.Name == props.custom_storey), None) - number = SaveNumber.get_number(storey, Storeys.settings) - if number is None: # If the number is not set, use the index - number = storeys.index(storey) - props["_custom_storey_number"] = int(number) - - @staticmethod - def get_custom_storey_number(props): - return int(props.get("_custom_storey_number", 0)) - - @staticmethod - def set_custom_storey_number(props, value): - ifc_file = tool.Ifc.get() - storeys = Storeys.get_storeys(Settings.to_dict(props)) - storey = next((storey for storey in storeys if storey.Name == props.custom_storey), None) - index = storeys.index(storey) - if value == index: # If the value is the same as the index, remove the number - SaveNumber.save_number(ifc_file, storey, None, Storeys.settings) - else: - SaveNumber.save_number(ifc_file, storey, str(value), Storeys.settings) - props["_custom_storey_number"] = value - - @staticmethod - def get_storey_number(element, storeys, settings, storeys_numbers): - storey_number = None - if structure := getattr(element, "ContainedInStructure", None): - storey = getattr(structure[0], "RelatingStructure", None) - if storey and storeys_numbers: - storey_number = storeys_numbers.get(storey, None) - if storey and settings.get("storey_numbering") == "custom": - storey_number = SaveNumber.get_number(storey, Storeys.settings) - if storey_number is not None: - storey_number = int(storey_number) - if storey_number is None: - storey_number = storeys.index(storey) if storey in storeys else None - return storey_number - -class NumberFormatting: - - format_preview = "" - - @staticmethod - def format_number(settings, number_values = (0, 0, None), max_number_values=(100, 100, 1), type_name=""): - """Return the formatted number for the given element, type and storey number""" - format = settings.get("format", None) - if format is None: - return format - if "{E}" in format: - format = format.replace("{E}", NumberingSystems.to_numbering_string(settings.get("initial_element_number", 0) + number_values[0], settings.get("element_numbering"), max_number_values[0])) - if "{T}" in format: - format = format.replace("{T}", NumberingSystems.to_numbering_string(settings.get("initial_type_number", 0) + number_values[1], settings.get("type_numbering"), max_number_values[1])) - if "{S}" in format: - if number_values[2] is not None: - format = format.replace("{S}", NumberingSystems.to_numbering_string(settings.get("initial_storey_number", 0) + number_values[2], settings.get("storey_numbering"), max_number_values[2])) - else: - format = format.replace("{S}", "x") - if "[T]" in format and len(type_name) > 0: - format = format.replace("[T]", type_name[0]) - if "[TT]" in format and len(type_name) > 1: - format = format.replace("[TT]", "".join([c for c in type_name if c.isupper()])) - if "[TF]" in format: - format = format.replace("[TF]", type_name) - return format - - @staticmethod - def get_type_name(settings): - """Return type name used in preview, based on selected types""" - if not settings.get("selected_types"): - #If no types selected, return "Type" - return "Type" - #Get the type name of the selected type, excluding 'IfcElement' - types = settings.get("selected_types") - if 'All' in types: - types.remove('All') - if len(types)>0: - return str(list(types)[0][3:]) - #If all selected, return type name of one of the selected types - all_types = LoadSelection.possible_types - if len(all_types) > 1: - return str(all_types[1][0][3:]) - #If none selected, return "Type" - return "Type" - - @staticmethod - def get_max_numbers(settings, type_name): - """Return number of selected elements used in preview, based on selected types""" - max_element, max_type, max_storey = 0, 0, 0 - if settings.get("storey_numbering") == 'number_ext': - max_storey = len(Storeys.get_storeys(settings)) - if settings.get("element_numbering") == 'number_ext' or settings.get("type_numbering") == 'number_ext': - if not settings.get("selected_types"): - return max_element, max_type, max_storey - type_counts = {type_tuple[0]: int(''.join([c for c in type_tuple[1] if c.isdigit()])) \ - for type_tuple in LoadSelection.possible_types} - if "All" in settings.get("selected_types"): - max_element = type_counts.get("All", 0) - else: - max_element = sum(type_counts.get(t, 0) for t in LoadSelection.get_selected_types(settings)) - max_type = type_counts.get('Ifc' + type_name, max_element) - return max_element, max_type, max_storey - - @staticmethod - def update_format_preview(prop, context): - settings = Settings.to_dict(context.scene.BIMNumberingProperties) - type_name = NumberFormatting.get_type_name(settings) - NumberFormatting.format_preview = NumberFormatting.format_number(settings, (0, 0, 0), NumberFormatting.get_max_numbers(settings, type_name), type_name) - -class NumberingSystems: - - @staticmethod - def to_number(i): - """Convert a number to a string.""" - if i < 0: - return "(" + str(-i) + ")" - return str(i) - - @staticmethod - def to_number_ext(i, length=2): - """Convert a number to a string with leading zeroes.""" - if i < 0: - return "(" + NumberingSystems.to_number_ext(-i, length) + ")" - res = str(i) - while len(res) < length: - res = "0" + res - return res - - @staticmethod - def to_letter(i, upper=False): - """Convert a number to a letter or sequence of letters.""" - if i == 0: - return "0" - if i < 0: - return "(" + NumberingSystems.to_letter(-i, upper) + ")" - - num2alphadict = dict(zip(range(1, 27), string.ascii_uppercase if upper else string.ascii_lowercase)) - res = "" - numloops = (i-1) // 26 - - if numloops > 0: - res = res + NumberingSystems.to_letter(numloops, upper) - - remainder = i % 26 - if remainder == 0: - remainder += 26 - return res + num2alphadict[remainder] - - @staticmethod - def get_numberings(): - return { - "number": NumberingSystems.to_number, - "number_ext": NumberingSystems.to_number_ext, - "lower_letter": NumberingSystems.to_letter, - "upper_letter": lambda x: NumberingSystems.to_letter(x, True) - } - - def to_numbering_string(i, numbering_system, max_number): - """Convert a number to a string based on the numbering system.""" - if numbering_system == "number_ext": - # Determine the length based on the maximum number - length = len(str(max_number)) - return NumberingSystems.to_number_ext(i, length) - if numbering_system == "custom": - return NumberingSystems.to_number(i) - return NumberingSystems.get_numberings()[numbering_system](i) - - def get_numbering_preview(numbering_system, initial): - """Get a preview of the numbering string for a given number and type.""" - numbers = [NumberingSystems.to_numbering_string(i, numbering_system, 10) for i in range(initial, initial + 3)] - return "{0}, {1}, {2}, ...".format(*numbers) - -class ObjectGeometry: - @staticmethod - def get_object_location(obj, settings): - """Get the location of a Blender object.""" - mat = obj.matrix_world - bbox_vectors = [mat @ Vector(b) for b in obj.bound_box] - - if settings.get("location_type", "CENTER") == "CENTER": - return 0.125 * sum(bbox_vectors, Vector()) - - elif settings.get("location_type") == "BOUNDING_BOX": - bbox_vector = Vector((0, 0, 0)) - # Determine the coordinates based on the direction and axis order - direction = (int(settings.get("x_direction")), int(settings.get("y_direction")), int(settings.get("z_direction"))) - for i in range(3): - if direction[i] == -1: - bbox_vector[i] = max(v[i] for v in bbox_vectors) - else: - bbox_vector[i] = min(v[i] for v in bbox_vectors) - return bbox_vector - - - @staticmethod - def get_object_dimensions(obj): - """Get the dimensions of a Blender object.""" - # Get the object's bounding box corners in world space - mat = obj.matrix_world - coords = [mat @ Vector(corner) for corner in obj.bound_box] - - # Compute min and max coordinates - min_corner = Vector((min(v[i] for v in coords) for i in range(3))) - max_corner = Vector((max(v[i] for v in coords) for i in range(3))) - - # Dimensions in global space - dimensions = max_corner - min_corner - return dimensions - - @staticmethod - def cmp_within_precision(a, b, settings, use_dir=True): - """Compare two vectors within a given precision.""" - direction = (int(settings.get("x_direction", 1)), int(settings.get("y_direction", 1)), int(settings.get("z_direction", 1))) if use_dir else (1, 1, 1) - for axis in settings.get("axis_order", "XYZ"): - idx = "XYZ".index(axis) - diff = (a[idx] - b[idx]) * direction[idx] - if 1000 * abs(diff) > settings.get("precision", [0, 0, 0])[idx]: - return 1 if diff > 0 else -1 - return 0 - -class ElementGeometry: - @staticmethod - def get_element_location(element, settings): - """Get the location of an IFC element.""" - geom_settings = geom.settings() - geom_settings.set("use-world-coords", True) - shape = geom.create_shape(geom_settings, element) - - verts = ifc_shape.get_shape_vertices(shape, shape.geometry) - if settings.get("location_type") == "CENTER": - return np.mean(verts, axis=0) - - elif settings.get("location_type") == "BOUNDING_BOX": - direction = (int(settings.get("x_direction", 1)), int(settings.get("y_direction", 1)), int(settings.get("z_direction", 1))) - bbox_min = np.min(verts, axis=0) - bbox_max = np.max(verts, axis=0) - bbox_vector = np.zeros(3) - for i in range(3): - if direction[i] == -1: - bbox_vector[i] = bbox_max[i] - else: - bbox_vector[i] = bbox_min[i] - return bbox_vector - - - @staticmethod - def get_element_dimensions(element): - """Get the dimensions of an IFC element.""" - geom_settings = geom.settings() - geom_settings.set("use-world-coords", True) - shape = geom.create_shape(geom_settings, element) - - verts = ifc_shape.get_shape_vertices(shape, shape.geometry) - bbox_min = np.min(verts, axis=0) - bbox_max = np.max(verts, axis=0) - return bbox_max - bbox_min - -class Settings: - - pset_name = "Pset_NumberingSettings" - - settings_names = None - - def import_settings(filepath): - """Import settings from a JSON file, e.g. as exported from the UI""" - with open(filepath, 'r') as file: - settings = json.load(file) - return settings - - def default_settings(): - """"Return a default dictionary of settings for numbering elements.""" - return { - "x_direction": 1, - "y_direction": 1, - "z_direction": 1, - "axis_order": "ZYX", - "location_type": "CENTER", - "precision": (1, 1, 1), - "initial_element_number": 1, - "initial_type_number": 1, - "initial_storey_number": 0, - "element_numbering": "number", - "type_numbering": "number", - "storey_numbering": "number", - "format": "E{E}S{S}[T]{T}", - "save_type": "Attribute", - "attribute_name": "Tag", - "pset_name": "Common", - "custom_pset_name": "Pset_Numbering", - "property_name": "Number" - } - - @staticmethod - def to_dict(props): - """Convert the properties to a dictionary for saving.""" - return { - "selected_toggle": props.selected_toggle, - "visible_toggle": props.visible_toggle, - "parent_type": props.parent_type, - "parent_type_other": props.parent_type_other, - "selected_types": list(props.selected_types), - "x_direction": props.x_direction, - "y_direction": props.y_direction, - "z_direction": props.z_direction, - "axis_order": props.axis_order, - "location_type": props.location_type, - "precision": (props.precision[0], props.precision[1], props.precision[2]), - "initial_element_number": props.initial_element_number, - "initial_type_number": props.initial_type_number, - "initial_storey_number": props.initial_storey_number, - "element_numbering": props.element_numbering, - "type_numbering": props.type_numbering, - "storey_numbering": props.storey_numbering, - "format": props.format, - "save_type": props.save_type, - "attribute_name": props.attribute_name, - "attribute_name_other": props.attribute_name_other, - "pset_name": props.pset_name, - "custom_pset_name": props.custom_pset_name, - "property_name": props.property_name, - "remove_toggle": props.remove_toggle, - "check_duplicates_toggle": props.check_duplicates_toggle - } - - @staticmethod - def save_settings(operator, props, ifc_file): - """Save the numbering settings to the IFC file.""" - # Save multiple settings by name in a dictionary - project = ifc_file.by_type("IfcProject")[0] - settings_name = props.settings_name.strip() - if not settings_name: - operator.report({'ERROR'}, "Please enter a name for the settings.") - return {'CANCELLED'} - if pset_settings := get_pset(project, Settings.pset_name): - pset_settings = ifc_file.by_id(pset_settings["id"]) - else: - pset_settings = ifc_api.run("pset.add_pset", ifc_file, product=project, name=Settings.pset_name) - if not pset_settings: - operator.report({'ERROR'}, "Could not create property set") - return {'CANCELLED'} - ifc_api.run("pset.edit_pset", ifc_file, pset=pset_settings, properties={settings_name: json.dumps(Settings.to_dict(props))}) - Settings.settings_names.add(settings_name) - operator.report({'INFO'}, f"Saved settings '{settings_name}' to IFCProject element") - return {'FINISHED'} - - @staticmethod - def read_settings(operator, settings, props): - for key, value in settings.items(): - if key == "selected_types": - possible_type_names = [t[0] for t in LoadSelection.possible_types] - value = set([type_name for type_name in value if type_name in possible_type_names]) - try: - setattr(props, key, value) - except Exception as e: - operator.report({'ERROR'}, f"Failed to set property {key} to {value}. Error: {e}") - - @staticmethod - def get_settings_names(): - ifc_file = tool.Ifc.get() - if Settings.settings_names is None: - if pset := get_pset(ifc_file.by_type("IfcProject")[0], Settings.pset_name): - names = set(pset.keys()) - names.remove("id") - else: - names = set() - Settings.settings_names = names - return Settings.settings_names - - @staticmethod - def load_settings(operator, props, ifc_file): - # Load selected settings by name - settings_name = props.saved_settings - if settings_name == "NONE": - operator.report({'WARNING'}, "No saved settings to load.") - return {'CANCELLED'} - if pset_settings := get_pset(ifc_file.by_type("IfcProject")[0], Settings.pset_name): - settings = pset_settings.get(settings_name, None) - if settings is None: - operator.report({'WARNING'}, f"Settings '{settings_name}' not found.") - return {'CANCELLED'} - settings = json.loads(settings) - Settings.read_settings(operator, settings, props) - operator.report({'INFO'}, f"Loaded settings '{settings_name}' from IFCProject element") - return {'FINISHED'} - else: - operator.report({'WARNING'}, "No settings found") - return {'CANCELLED'} - - @staticmethod - def delete_settings(operator, props): - ifc_file = tool.Ifc.get() - settings_name = props.saved_settings - if settings_name == "NONE": - operator.report({'WARNING'}, "No saved settings to delete.") - return {'CANCELLED'} - if pset_settings := get_pset(ifc_file.by_type("IfcProject")[0], Settings.pset_name): - if settings_name in pset_settings: - pset_settings = ifc_file.by_id(pset_settings["id"]) - ifc_api.run("pset.edit_pset", ifc_file, pset=pset_settings, properties={settings_name: None}, should_purge=True) - Settings.settings_names.remove(settings_name) - operator.report({'INFO'}, f"Deleted settings '{settings_name}' from IFCProject element") - - if not pset_settings.HasProperties: - ifc_api.run("pset.remove_pset", ifc_file, product=ifc_file.by_type("IfcProject")[0], pset=pset_settings) - return {'FINISHED'} - else: - operator.report({'WARNING'}, f"Settings '{settings_name}' not found.") - return {'CANCELLED'} - else: - operator.report({'WARNING'}, "No settings found") - return {'CANCELLED'} - - @staticmethod - def clear_settings(operator, props): - ifc_file = tool.Ifc.get() - project = ifc_file.by_type("IfcProject")[0] - if pset_settings := get_pset(project, Settings.pset_name): - pset_settings = ifc_file.by_id(pset_settings["id"]) - ifc_api.run("pset.remove_pset", ifc_file, product=project, pset=pset_settings) - operator.report({'INFO'}, f"Cleared settings from IFCProject element") - Settings.settings_names = set() - return {'FINISHED'} - else: - operator.report({'WARNING'}, "No settings found") - return {'CANCELLED'} diff --git a/src/bonsai/bonsai/bim/module/numbering/workspace.py b/src/bonsai/bonsai/bim/module/numbering/workspace.py index 41f47311f6d..5a41f565fd4 100644 --- a/src/bonsai/bonsai/bim/module/numbering/workspace.py +++ b/src/bonsai/bonsai/bim/module/numbering/workspace.py @@ -18,15 +18,14 @@ import os -import bpy import bonsai.tool as tool from bpy.types import WorkSpaceTool +import bpy.ops + from functools import partial from bonsai.bim.module.numbering.data import NumberingData -from bonsai.bim.module.numbering.util import NumberFormatting - class NumberingTool(WorkSpaceTool): bl_space_type = "VIEW_3D" bl_context_mode = "OBJECT" @@ -36,6 +35,10 @@ class NumberingTool(WorkSpaceTool): # TODO: replace with numbering icon bl_icon = os.path.join(os.path.dirname(__file__), "ops.authoring.numbering") bl_widget = None + bl_keymap = tool.Blender.get_default_selection_keypmap() + ( + ("bim.assign_numbers", {"type": "A", "value": "PRESS", "shift": True}, {"properties": [("hotkey", "S_A")]}), + ("bim.remove_numbers", {"type": "R", "value": "PRESS", "shift": True}, {"properties": [("hotkey", "S_R")]}), + ) def draw_settings(context, layout, ws_tool): # Unlike operators, Blender doesn't treat workspace tools as a class, so we'll create our own. @@ -60,129 +63,5 @@ def draw(cls, context, layout): @classmethod def draw_interface(cls): - - assert (layout := cls.layout) - - props = tool.Numbering.get_numbering_props() - - cls.draw_settings(layout, props) - cls.draw_selection(layout, props) - cls.draw_numbering_order(layout, props) - cls.draw_numbering_systems(layout, props) - cls.draw_numbering_format(layout, props) - cls.draw_storage_options(layout, props) - - # Actions - box = layout.box() - row = box.row(align=True) - row.operator("bim.assign_numbers", icon="TAG", text="Assign Numbers") - row = box.row(align=True) - row.operator("bim.remove_numbers", icon="X", text="Remove Numbers") - - def draw_settings(layout, props): - box = layout.box() - box.alignment = "EXPAND" - box.label(text="Settings") - grid = box.grid_flow(row_major=True, align=True, columns=4, even_columns=True) - grid.prop(props, "settings_name", text="Name") - grid.operator("bim.save_settings", icon="FILE_TICK", text="Save") - grid.operator("bim.clear_settings", icon="CANCEL", text="Clear") - grid.operator("bim.export_settings", icon="EXPORT", text="Export") - - grid.prop(props, "saved_settings", text="") - grid.operator("bim.load_settings", icon="FILE_REFRESH", text="Load") - grid.operator("bim.delete_settings", icon="TRASH", text="Delete") - grid.operator("bim.import_settings", icon="IMPORT", text="Import") - - def draw_selection(layout, props): - box = layout.box() - box.alignment = "EXPAND" - box.label(text="Elements to number") - grid = box.grid_flow(row_major=True, align=True, columns=4, even_columns=True) - grid.prop(props, "selected_toggle") - grid.prop(props, "visible_toggle") - grid.prop(props, "parent_type", text="") - if props.parent_type == "Other": - grid.prop(props, "parent_type_other", text="") - else: - grid.label(text="") - - grid = box.grid_flow(row_major=True, align=True, columns=4, even_columns=True) - grid.prop(props, "selected_types", expand=True) - - def draw_numbering_order(layout, props): - box = layout.box() - box.alignment = "EXPAND" - box.label(text="Numbering order") - # Create a grid for direction and precision - grid = box.grid_flow(row_major=True, align=True, columns=4, even_columns=True) - grid.label(text="Direction: ") - grid.prop(props, "x_direction", text="X") - grid.prop(props, "y_direction", text="Y") - grid.prop(props, "z_direction", text="Z") - grid.label(text="Precision: ") - grid.prop(props, "precision", index=0, text="X") - grid.prop(props, "precision", index=1, text="Y") - grid.prop(props, "precision", index=2, text="Z") - - # Axis order and reference point - grid = box.grid_flow(row_major=True, align=True, columns=4) - grid.label(text="Order:") - grid.prop(props, "axis_order", text="") - grid.label(text="Reference point:") - grid.prop(props, "location_type", text="") - - def draw_numbering_systems(layout, props): - box = layout.box() - box.alignment = "EXPAND" - box.label(text="Numbering of elements {E}, within type {T} and storeys {S}") - grid = box.grid_flow(row_major=True, align=True, columns=4, even_columns=True) - grid.label(text="Start at:") - grid.prop(props, "initial_element_number", text="{E}") - grid.prop(props, "initial_type_number", text="{T}") - grid.prop(props, "initial_storey_number", text="{S}") - grid.label(text="System:") - grid.prop(props, "element_numbering", text="{E}") - grid.prop(props, "type_numbering", text="{T}") - grid.prop(props, "storey_numbering", text="{S}") - - # Custom storey number - if props.storey_numbering == "custom": - box = box.box() - row = box.row(align=True) - row.prop(props, "custom_storey", text="Storey") - row.prop(props, "custom_storey_number", text="Number") - - def draw_numbering_format(layout, props): - box = layout.box() - box.alignment = "EXPAND" - box.label(text="Numbering format") - grid = box.grid_flow(align=True, columns=4, even_columns=True) - grid.label(text="Format:") - grid.prop(props, "format", text="") - # Show preview in a textbox style (non-editable) - grid.label(text="Preview:") - preview_box = grid.box() - preview_box.label(text=NumberFormatting.format_preview) - - def draw_storage_options(layout, props): - box = layout.box() - box.alignment = "EXPAND" - box.label(text="Store number in") - - grid = box.grid_flow(align=True, columns=4, even_columns=True) - grid.prop(props, "save_type", text="") - if props.save_type == "Attribute": - grid.prop(props, "attribute_name", text="") - if props.attribute_name == "Other": - grid.prop(props, "attribute_name_other", text="") - if props.save_type == "Pset": - grid.prop(props, "pset_name", text="") - if props.pset_name == "Custom Pset": - grid.prop(props, "custom_pset_name", text="") - grid.prop(props, "property_name", text="") - - box.prop(props, "remove_toggle") - box.prop(props, "check_duplicates_toggle") - - + cls.layout.operator("bim.assign_numbers", text="Assign Numbers", icon="TAG") + cls.layout.operator("bim.remove_numbers", text="Remove Numbers", icon="X") diff --git a/src/bonsai/bonsai/core/numbering.py b/src/bonsai/bonsai/core/numbering.py index 77632d505eb..68d4e1f3313 100644 --- a/src/bonsai/bonsai/core/numbering.py +++ b/src/bonsai/bonsai/core/numbering.py @@ -1,5 +1,5 @@ # Bonsai - OpenBIM Blender Add-on -# Copyright (C) 2022 Dion Moult +# Copyright (C) 2020, 2021 Dion Moult # # This file is part of Bonsai. # @@ -16,65 +16,815 @@ # You should have received a copy of the GNU General Public License # along with Bonsai. If not, see . -# ############################################################################ # - -# Hey there! Welcome to the Bonsai code. Please feel free to reach -# out if you have any questions or need further guidance. Happy hacking! - -# ############################################################################ # - -# Every module has a core.py file to define all of its core functions. A core -# function describes what happens when the user wants to do something like -# pressing a button. - -# Think of a core function as a short poem of pseudocode that describes what -# happens in different usecases. A core should be no more than 50 lines of code, -# even in the most complex of features. A core simply delegates tasks to tools -# in a sequence that describes the flow of logic in a feature - in other words, -# it tells tools to do different things. You will notice that the core doesn't -# have any code that deals with Blender or IFC directly - these are all little -# details that are hidden away in tools. The core is not interested in these -# details, the core is only concerned with the big picture. - -# Imagine, no matter how complex a software can be, every feature can be -# described in regular sentences in under 50 lines. That is the purpose of the -# core. - - -# Here's the simplest possible core function. It does one thing only. Remember: -# core functions delegate tasks to tools, so all core functions need at least -# one tool. -def demonstrate_hello_world(demo): - # We're telling the demo tool to set a message. We aren't interested how the - # tool works, that's a detail. We aren't interested in the interface, like - # where the message is shown. You can name these functions whatever you feel - # best describes what's going on, like if you had to describe the feature to - # someone else. - demo.set_message("Hello, World!") - - -# Here's a slightly more complex core function. It uses two tools. By default, -# you'll want to use your module's tool (in this case, "Demo") for all tasks, -# except for changing IFC data, where you'll use the "Ifc" tool. At a glance, -# simply by seeing what tools are used, this gives you an idea about what -# aspects (or dependencies) a core function cares about. This function also has -# an non-tool input called "name". Non-tool inputs should be keyword arguments -# with possible default values. -def demonstrate_rename_project(ifc, demo, name=None): - # As you can see, core functions read almost like pseudocode. A great way to - # code a new feature is to write out what it does in English first, then - # change them into tool functions. You can choose any function you want, and - # you can see a list of every single tool function in # `core/tool.py`. - if name: - project = demo.get_project() - ifc.run("attribute.edit_attributes", product=project, attributes={"Name": name}) - demo.clear_name_field() - demo.hide_user_hints() - else: - demo.show_user_hints() - - -# here this core function uses the web tool to send a message to the web UI -# we call the send_webui_data method to send any kind of data to the web UI -def send_webui_demo_message(web, message="Hello"): - web.send_webui_data(data=message, data_key="demo_message", event="demo_data") +import bpy +import bonsai.tool as tool + +import ifcopenshell.api as ifc_api +from ifcopenshell.util.element import get_pset +from ifcopenshell.util.pset import PsetQto + +from mathutils import Vector +import functools as ft +import numpy as np +import ifcopenshell.geom as geom +import ifcopenshell.util.shape as ifc_shape + +import string +import json + +def get_id(element): + return getattr(element, "GlobalId", element.id()) + +class SaveNumber: + + pset_names = [("Custom", "Custom Pset", "")] + pset_common_names = {} + ifc_file = None + pset_qto = None + + @staticmethod + def update_ifc_file(): + if (ifc_file := tool.Ifc.get()) != SaveNumber.ifc_file: + SaveNumber.ifc_file = ifc_file + SaveNumber.pset_qto = PsetQto(ifc_file.schema) + + @staticmethod + def get_number(element, settings, numbers_cache=None): + if element is None: + return None + if numbers_cache is None: + numbers_cache = {} + if get_id(element) in numbers_cache: + return numbers_cache[get_id(element)] + if settings.get("save_type") == "Attribute": + return getattr(element, SaveNumber.get_attribute_name(settings), None) + if settings.get("save_type") == "Pset": + pset_name = SaveNumber.get_pset_name(element, settings) + if (pset := get_pset(element, pset_name)): + return pset.get(settings.get("property_name")) + return None + + @staticmethod + def save_number(ifc_file, element, number, settings, numbers_cache=None): + if element is None: + return None + if numbers_cache is None: + numbers_cache = {} + if number == SaveNumber.get_number(element, settings, numbers_cache): + return 0 + if settings.get("save_type") == "Attribute": + attribute_name = SaveNumber.get_attribute_name(settings) + if not hasattr(element, attribute_name): + return None + if attribute_name == "Name" and number is None: + number = element.is_a().strip("Ifc") #Reset Name to name of type + setattr(element, attribute_name, number) + numbers_cache[get_id(element)] = number + return 1 + if settings.get("save_type") == "Pset": + pset_name = SaveNumber.get_pset_name(element, settings) + if not pset_name: + return None + if pset := get_pset(element, pset_name): + pset = ifc_file.by_id(pset["id"]) + else: + pset = ifc_api.run("pset.add_pset", ifc_file, product=element, name=pset_name) + ifc_api.run("pset.edit_pset", ifc_file, pset=pset, properties={settings["property_name"]: number}, should_purge=True) + if number is None and not pset.HasProperties: + ifc_api.run("pset.remove_pset", ifc_file, product=element, pset=pset) + numbers_cache[get_id(element)] = number + return 1 + return None + + @staticmethod + def remove_number(ifc_file, element, settings, numbers_cache=None): + count = SaveNumber.save_number(ifc_file, element, None, settings, numbers_cache) + return int(count or 0) + + def get_attribute_name(settings): + if settings.get("attribute_name") == "Other": + return settings.get("attribute_name_other") + return settings.get("attribute_name") + + @staticmethod + def get_pset_name(element, settings): + if settings.get("pset_name") == "Common": + ifc_type = element.is_a() + name = SaveNumber.pset_common_names.get(ifc_type, None) + return name + if settings.get("pset_name") == "Custom Pset": + return settings.get("custom_pset_name") + return settings.get("pset_name") + + @staticmethod + def update_pset_names(prop, context): + settings = Settings.to_dict(context.scene.BIMNumberingProperties) + pset_names_sets = [set(SaveNumber.pset_qto.get_applicable_names(ifc_type)) for ifc_type in LoadSelection.get_selected_types(settings)] + intersection = set.intersection(*pset_names_sets) if pset_names_sets else set() + SaveNumber.pset_names = [('Custom Pset', 'Custom Pset', 'Store in custom Pset with selected name'), + ('Common', 'Pset_Common', 'Store in Pset common of the type, e.g. Pset_WallCommon')] + \ + [(name, name, f"Store in Pset called {name}") for name in intersection] + + @staticmethod + def get_pset_common_names(elements): + SaveNumber.pset_common_names = {} + pset_qto = PsetQto(SaveNumber.ifc_file.schema) + for element in elements: + ifc_type = element.is_a() + if ifc_type in SaveNumber.pset_common_names: + continue + pset_names = pset_qto.get_applicable_names(ifc_type) + if (name_guess := "Pset_" + ifc_type.strip("Ifc") + "Common") in pset_names: + pset_common_name = name_guess + elif (name_guess := "Pset_" + ifc_type.strip("Ifc") + "TypeCommon") in pset_names: + pset_common_name = name_guess + elif common_names := [name for name in pset_names if 'Common' in name]: + pset_common_name = common_names[0] + else: + pset_common_name = None + SaveNumber.pset_common_names[ifc_type] = pset_common_name + +class LoadSelection: + + all_objects = [] + selected_objects = [] + possible_types = [] + + @staticmethod + def get_parent_type(settings): + """Get the parent type from the settings.""" + if settings.get("parent_type") == "Other": + return settings.get("parent_type_other") + return settings.get("parent_type") + + @staticmethod + def load_selected_objects(settings): + """Load the selected objects based on the current context.""" + objects = bpy.context.selected_objects if settings.get("selected_toggle") else bpy.context.scene.objects + if settings.get("visible_toggle"): + objects = [obj for obj in objects if obj.visible_get()] + return objects + + @staticmethod + def get_selected_types(settings): + """Get the selected IFC types from the settings, processing if All types are selected""" + selected_types = settings.get("selected_types", []) + if "All" in selected_types: + selected_types = [type_tuple[0] for type_tuple in LoadSelection.possible_types[1:]] + return selected_types + + @staticmethod + def load_possible_types(objects, parent_type): + """Load the available IFC types and their counts from the selected elements.""" + if not objects: + return [("All", "All", "element")], {"All": 0} + + ifc_types = [("All", "All", "element")] + seen_types = [] + number_counts = {"All": 0} + + for obj in objects: + element = tool.Ifc.get_entity(obj) + if element is None or not element.is_a(parent_type): + continue + ifc_type = element.is_a() #Starts with "Ifc", which we can strip by starting from index 3 + + if ifc_type not in seen_types: + seen_types.append(ifc_type) + ifc_types.append((ifc_type, ifc_type[3:], ifc_type[3:].lower())) # Store type as (id, name, name_lower) + number_counts[ifc_type] = 0 + + number_counts["All"] += 1 + number_counts[ifc_type] += 1 + + ifc_types.sort(key=lambda ifc_type: ifc_type[0]) + + return ifc_types, number_counts + + @staticmethod + def update_objects(prop, context): + settings = Settings.to_dict(context.scene.BIMNumberingProperties) + ifc_types, number_counts = LoadSelection.load_possible_types(LoadSelection.selected_objects, LoadSelection.get_parent_type(settings)) + LoadSelection.possible_types = [(id, name + f": {number_counts[id]}", "") for (id, name, _) in ifc_types] + NumberFormatting.update_format_preview(prop, context) + SaveNumber.update_pset_names(prop, context) + SaveNumber.update_ifc_file() + + @staticmethod + def get_possible_types(prop, context): + """Return the list of available types for selection.""" + props = context.scene.BIMNumberingProperties + settings = {"selected_toggle": props.selected_toggle, "visible_toggle": props.visible_toggle} + all_objects = list(bpy.context.scene.objects) + objects = LoadSelection.load_selected_objects(settings) + if all_objects != LoadSelection.all_objects or objects != LoadSelection.selected_objects: + LoadSelection.all_objects = all_objects + LoadSelection.selected_objects = objects + LoadSelection.update_objects(prop, context) + return LoadSelection.possible_types + +class Storeys: + + settings = {"save_type": "Pset", + "pset_name": "Pset_Numbering", + "property_name": "CustomStoreyNumber"} + + @staticmethod + def get_storeys(settings): + """Get all storeys from the current scene.""" + storeys = [] + storey_locations = {} + for obj in bpy.context.scene.objects: + element = tool.Ifc.get_entity(obj) + if element is not None and element.is_a("IfcBuildingStorey"): + storeys.append(element) + storey_locations[element] = ObjectGeometry.get_object_location(obj, settings) + storeys.sort(key=ft.cmp_to_key(lambda a, b: ObjectGeometry.cmp_within_precision(storey_locations[a], storey_locations[b], settings, use_dir=False))) + return storeys + + @staticmethod + def update_custom_storey(props, context): + storeys = Storeys.get_storeys(Settings.to_dict(context.scene.BIMNumberingProperties)) + storey = next((storey for storey in storeys if storey.Name == props.custom_storey), None) + number = SaveNumber.get_number(storey, Storeys.settings) + if number is None: # If the number is not set, use the index + number = storeys.index(storey) + props["_custom_storey_number"] = int(number) + + @staticmethod + def get_custom_storey_number(props): + return int(props.get("_custom_storey_number", 0)) + + @staticmethod + def set_custom_storey_number(props, value): + ifc_file = tool.Ifc.get() + storeys = Storeys.get_storeys(Settings.to_dict(props)) + storey = next((storey for storey in storeys if storey.Name == props.custom_storey), None) + index = storeys.index(storey) + if value == index: # If the value is the same as the index, remove the number + SaveNumber.save_number(ifc_file, storey, None, Storeys.settings) + else: + SaveNumber.save_number(ifc_file, storey, str(value), Storeys.settings) + props["_custom_storey_number"] = value + + @staticmethod + def get_storey_number(element, storeys, settings, storeys_numbers): + storey_number = None + if structure := getattr(element, "ContainedInStructure", None): + storey = getattr(structure[0], "RelatingStructure", None) + if storey and storeys_numbers: + storey_number = storeys_numbers.get(storey, None) + if storey and settings.get("storey_numbering") == "custom": + storey_number = SaveNumber.get_number(storey, Storeys.settings) + if storey_number is not None: + storey_number = int(storey_number) + if storey_number is None: + storey_number = storeys.index(storey) if storey in storeys else None + return storey_number + +class NumberFormatting: + + format_preview = "" + + @staticmethod + def format_number(settings, number_values = (0, 0, None), max_number_values=(100, 100, 1), type_name=""): + """Return the formatted number for the given element, type and storey number""" + format = settings.get("format", None) + if format is None: + return format + if "{E}" in format: + format = format.replace("{E}", NumberingSystems.to_numbering_string(settings.get("initial_element_number", 0) + number_values[0], settings.get("element_numbering"), max_number_values[0])) + if "{T}" in format: + format = format.replace("{T}", NumberingSystems.to_numbering_string(settings.get("initial_type_number", 0) + number_values[1], settings.get("type_numbering"), max_number_values[1])) + if "{S}" in format: + if number_values[2] is not None: + format = format.replace("{S}", NumberingSystems.to_numbering_string(settings.get("initial_storey_number", 0) + number_values[2], settings.get("storey_numbering"), max_number_values[2])) + else: + format = format.replace("{S}", "x") + if "[T]" in format and len(type_name) > 0: + format = format.replace("[T]", type_name[0]) + if "[TT]" in format and len(type_name) > 1: + format = format.replace("[TT]", "".join([c for c in type_name if c.isupper()])) + if "[TF]" in format: + format = format.replace("[TF]", type_name) + return format + + @staticmethod + def get_type_name(settings): + """Return type name used in preview, based on selected types""" + if not settings.get("selected_types"): + #If no types selected, return "Type" + return "Type" + #Get the type name of the selected type, excluding 'IfcElement' + types = settings.get("selected_types") + if 'All' in types: + types.remove('All') + if len(types)>0: + return str(list(types)[0][3:]) + #If all selected, return type name of one of the selected types + all_types = LoadSelection.possible_types + if len(all_types) > 1: + return str(all_types[1][0][3:]) + #If none selected, return "Type" + return "Type" + + @staticmethod + def get_max_numbers(settings, type_name): + """Return number of selected elements used in preview, based on selected types""" + max_element, max_type, max_storey = 0, 0, 0 + if settings.get("storey_numbering") == 'number_ext': + max_storey = len(Storeys.get_storeys(settings)) + if settings.get("element_numbering") == 'number_ext' or settings.get("type_numbering") == 'number_ext': + if not settings.get("selected_types"): + return max_element, max_type, max_storey + type_counts = {type_tuple[0]: int(''.join([c for c in type_tuple[1] if c.isdigit()])) \ + for type_tuple in LoadSelection.possible_types} + if "All" in settings.get("selected_types"): + max_element = type_counts.get("All", 0) + else: + max_element = sum(type_counts.get(t, 0) for t in LoadSelection.get_selected_types(settings)) + max_type = type_counts.get('Ifc' + type_name, max_element) + return max_element, max_type, max_storey + + @staticmethod + def update_format_preview(prop, context): + settings = Settings.to_dict(context.scene.BIMNumberingProperties) + type_name = NumberFormatting.get_type_name(settings) + NumberFormatting.format_preview = NumberFormatting.format_number(settings, (0, 0, 0), NumberFormatting.get_max_numbers(settings, type_name), type_name) + +class NumberingSystems: + + @staticmethod + def to_number(i): + """Convert a number to a string.""" + if i < 0: + return "(" + str(-i) + ")" + return str(i) + + @staticmethod + def to_number_ext(i, length=2): + """Convert a number to a string with leading zeroes.""" + if i < 0: + return "(" + NumberingSystems.to_number_ext(-i, length) + ")" + res = str(i) + while len(res) < length: + res = "0" + res + return res + + @staticmethod + def to_letter(i, upper=False): + """Convert a number to a letter or sequence of letters.""" + if i == 0: + return "0" + if i < 0: + return "(" + NumberingSystems.to_letter(-i, upper) + ")" + + num2alphadict = dict(zip(range(1, 27), string.ascii_uppercase if upper else string.ascii_lowercase)) + res = "" + numloops = (i-1) // 26 + + if numloops > 0: + res = res + NumberingSystems.to_letter(numloops, upper) + + remainder = i % 26 + if remainder == 0: + remainder += 26 + return res + num2alphadict[remainder] + + @staticmethod + def get_numberings(): + return { + "number": NumberingSystems.to_number, + "number_ext": NumberingSystems.to_number_ext, + "lower_letter": NumberingSystems.to_letter, + "upper_letter": lambda x: NumberingSystems.to_letter(x, True) + } + + def to_numbering_string(i, numbering_system, max_number): + """Convert a number to a string based on the numbering system.""" + if numbering_system == "number_ext": + # Determine the length based on the maximum number + length = len(str(max_number)) + return NumberingSystems.to_number_ext(i, length) + if numbering_system == "custom": + return NumberingSystems.to_number(i) + return NumberingSystems.get_numberings()[numbering_system](i) + + def get_numbering_preview(numbering_system, initial): + """Get a preview of the numbering string for a given number and type.""" + numbers = [NumberingSystems.to_numbering_string(i, numbering_system, 10) for i in range(initial, initial + 3)] + return "{0}, {1}, {2}, ...".format(*numbers) + +class ObjectGeometry: + @staticmethod + def get_object_location(obj, settings): + """Get the location of a Blender object.""" + mat = obj.matrix_world + bbox_vectors = [mat @ Vector(b) for b in obj.bound_box] + + if settings.get("location_type", "CENTER") == "CENTER": + return 0.125 * sum(bbox_vectors, Vector()) + + elif settings.get("location_type") == "BOUNDING_BOX": + bbox_vector = Vector((0, 0, 0)) + # Determine the coordinates based on the direction and axis order + direction = (int(settings.get("x_direction")), int(settings.get("y_direction")), int(settings.get("z_direction"))) + for i in range(3): + if direction[i] == -1: + bbox_vector[i] = max(v[i] for v in bbox_vectors) + else: + bbox_vector[i] = min(v[i] for v in bbox_vectors) + return bbox_vector + + + @staticmethod + def get_object_dimensions(obj): + """Get the dimensions of a Blender object.""" + # Get the object's bounding box corners in world space + mat = obj.matrix_world + coords = [mat @ Vector(corner) for corner in obj.bound_box] + + # Compute min and max coordinates + min_corner = Vector((min(v[i] for v in coords) for i in range(3))) + max_corner = Vector((max(v[i] for v in coords) for i in range(3))) + + # Dimensions in global space + dimensions = max_corner - min_corner + return dimensions + + @staticmethod + def cmp_within_precision(a, b, settings, use_dir=True): + """Compare two vectors within a given precision.""" + direction = (int(settings.get("x_direction", 1)), int(settings.get("y_direction", 1)), int(settings.get("z_direction", 1))) if use_dir else (1, 1, 1) + for axis in settings.get("axis_order", "XYZ"): + idx = "XYZ".index(axis) + diff = (a[idx] - b[idx]) * direction[idx] + if 1000 * abs(diff) > settings.get("precision", [0, 0, 0])[idx]: + return 1 if diff > 0 else -1 + return 0 + +class ElementGeometry: + @staticmethod + def get_element_location(element, settings): + """Get the location of an IFC element.""" + geom_settings = geom.settings() + geom_settings.set("use-world-coords", True) + shape = geom.create_shape(geom_settings, element) + + verts = ifc_shape.get_shape_vertices(shape, shape.geometry) + if settings.get("location_type") == "CENTER": + return np.mean(verts, axis=0) + + elif settings.get("location_type") == "BOUNDING_BOX": + direction = (int(settings.get("x_direction", 1)), int(settings.get("y_direction", 1)), int(settings.get("z_direction", 1))) + bbox_min = np.min(verts, axis=0) + bbox_max = np.max(verts, axis=0) + bbox_vector = np.zeros(3) + for i in range(3): + if direction[i] == -1: + bbox_vector[i] = bbox_max[i] + else: + bbox_vector[i] = bbox_min[i] + return bbox_vector + + + @staticmethod + def get_element_dimensions(element): + """Get the dimensions of an IFC element.""" + geom_settings = geom.settings() + geom_settings.set("use-world-coords", True) + shape = geom.create_shape(geom_settings, element) + + verts = ifc_shape.get_shape_vertices(shape, shape.geometry) + bbox_min = np.min(verts, axis=0) + bbox_max = np.max(verts, axis=0) + return bbox_max - bbox_min + +class Settings: + + pset_name = "Pset_NumberingSettings" + + settings_names = None + + def import_settings(filepath): + """Import settings from a JSON file, e.g. as exported from the UI""" + with open(filepath, 'r') as file: + settings = json.load(file) + return settings + + def default_settings(): + """"Return a default dictionary of settings for numbering elements.""" + return { + "x_direction": 1, + "y_direction": 1, + "z_direction": 1, + "axis_order": "ZYX", + "location_type": "CENTER", + "precision": (1, 1, 1), + "initial_element_number": 1, + "initial_type_number": 1, + "initial_storey_number": 0, + "element_numbering": "number", + "type_numbering": "number", + "storey_numbering": "number", + "format": "E{E}S{S}[T]{T}", + "save_type": "Attribute", + "attribute_name": "Tag", + "pset_name": "Common", + "custom_pset_name": "Pset_Numbering", + "property_name": "Number" + } + + @staticmethod + def to_dict(props): + """Convert the properties to a dictionary for saving.""" + return { + "selected_toggle": props.selected_toggle, + "visible_toggle": props.visible_toggle, + "parent_type": props.parent_type, + "parent_type_other": props.parent_type_other, + "selected_types": list(props.selected_types), + "x_direction": props.x_direction, + "y_direction": props.y_direction, + "z_direction": props.z_direction, + "axis_order": props.axis_order, + "location_type": props.location_type, + "precision": (props.precision[0], props.precision[1], props.precision[2]), + "initial_element_number": props.initial_element_number, + "initial_type_number": props.initial_type_number, + "initial_storey_number": props.initial_storey_number, + "element_numbering": props.element_numbering, + "type_numbering": props.type_numbering, + "storey_numbering": props.storey_numbering, + "format": props.format, + "save_type": props.save_type, + "attribute_name": props.attribute_name, + "attribute_name_other": props.attribute_name_other, + "pset_name": props.pset_name, + "custom_pset_name": props.custom_pset_name, + "property_name": props.property_name, + "remove_toggle": props.remove_toggle, + "check_duplicates_toggle": props.check_duplicates_toggle + } + + @staticmethod + def save_settings(operator, props, ifc_file): + """Save the numbering settings to the IFC file.""" + # Save multiple settings by name in a dictionary + project = ifc_file.by_type("IfcProject")[0] + settings_name = props.settings_name.strip() + if not settings_name: + operator.report({'ERROR'}, "Please enter a name for the settings.") + return {'CANCELLED'} + if pset_settings := get_pset(project, Settings.pset_name): + pset_settings = ifc_file.by_id(pset_settings["id"]) + else: + pset_settings = ifc_api.run("pset.add_pset", ifc_file, product=project, name=Settings.pset_name) + if not pset_settings: + operator.report({'ERROR'}, "Could not create property set") + return {'CANCELLED'} + ifc_api.run("pset.edit_pset", ifc_file, pset=pset_settings, properties={settings_name: json.dumps(Settings.to_dict(props))}) + Settings.settings_names.add(settings_name) + operator.report({'INFO'}, f"Saved settings '{settings_name}' to IFCProject element") + return {'FINISHED'} + + @staticmethod + def read_settings(operator, settings, props): + for key, value in settings.items(): + if key == "selected_types": + possible_type_names = [t[0] for t in LoadSelection.possible_types] + value = set([type_name for type_name in value if type_name in possible_type_names]) + try: + setattr(props, key, value) + except Exception as e: + operator.report({'ERROR'}, f"Failed to set property {key} to {value}. Error: {e}") + + @staticmethod + def get_settings_names(): + ifc_file = tool.Ifc.get() + if Settings.settings_names is None: + if pset := get_pset(ifc_file.by_type("IfcProject")[0], Settings.pset_name): + names = set(pset.keys()) + names.remove("id") + else: + names = set() + Settings.settings_names = names + return Settings.settings_names + + @staticmethod + def load_settings(operator, props, ifc_file): + # Load selected settings by name + settings_name = props.saved_settings + if settings_name == "NONE": + operator.report({'WARNING'}, "No saved settings to load.") + return {'CANCELLED'} + if pset_settings := get_pset(ifc_file.by_type("IfcProject")[0], Settings.pset_name): + settings = pset_settings.get(settings_name, None) + if settings is None: + operator.report({'WARNING'}, f"Settings '{settings_name}' not found.") + return {'CANCELLED'} + settings = json.loads(settings) + Settings.read_settings(operator, settings, props) + operator.report({'INFO'}, f"Loaded settings '{settings_name}' from IFCProject element") + return {'FINISHED'} + else: + operator.report({'WARNING'}, "No settings found") + return {'CANCELLED'} + + @staticmethod + def delete_settings(operator, props): + ifc_file = tool.Ifc.get() + settings_name = props.saved_settings + if settings_name == "NONE": + operator.report({'WARNING'}, "No saved settings to delete.") + return {'CANCELLED'} + if pset_settings := get_pset(ifc_file.by_type("IfcProject")[0], Settings.pset_name): + if settings_name in pset_settings: + pset_settings = ifc_file.by_id(pset_settings["id"]) + ifc_api.run("pset.edit_pset", ifc_file, pset=pset_settings, properties={settings_name: None}, should_purge=True) + Settings.settings_names.remove(settings_name) + operator.report({'INFO'}, f"Deleted settings '{settings_name}' from IFCProject element") + + if not pset_settings.HasProperties: + ifc_api.run("pset.remove_pset", ifc_file, product=ifc_file.by_type("IfcProject")[0], pset=pset_settings) + return {'FINISHED'} + else: + operator.report({'WARNING'}, f"Settings '{settings_name}' not found.") + return {'CANCELLED'} + else: + operator.report({'WARNING'}, "No settings found") + return {'CANCELLED'} + + @staticmethod + def clear_settings(operator, props): + ifc_file = tool.Ifc.get() + project = ifc_file.by_type("IfcProject")[0] + if pset_settings := get_pset(project, Settings.pset_name): + pset_settings = ifc_file.by_id(pset_settings["id"]) + ifc_api.run("pset.remove_pset", ifc_file, product=project, pset=pset_settings) + operator.report({'INFO'}, f"Cleared settings from IFCProject element") + Settings.settings_names = set() + return {'FINISHED'} + else: + operator.report({'WARNING'}, "No settings found") + return {'CANCELLED'} + +class Numbering: + + @staticmethod + def number_elements(elements, ifc_file, settings, elements_locations = None, elements_dimensions = None, storeys = None, numbers_cache = {}, storeys_numbers={}, report=None, remove_count=None): + """Number elements in the IFC file with the provided settings. If element locations or dimensions are specified, these are used for sorting. + Providing numbers_cache, a dictionary with element-> currently saved number, speeds up execution. + If storeys_numbers is provided, as a dictionary storey->number, this is used for assigning storey numbers.""" + if report is None: + def report(report_type, message): + if report_type == {"INFO"}: + print("INFO: ", message) + if report_type == {"WARNING"}: + raise Exception(message) + if storeys is None: + storeys = [] + + number_count = 0 + + if elements_dimensions: + elements.sort(key=ft.cmp_to_key(lambda a, b: ObjectGeometry.cmp_within_precision(elements_dimensions[a], elements_dimensions[b], settings, use_dir=False))) + if elements_locations: + elements.sort(key=ft.cmp_to_key(lambda a, b: ObjectGeometry.cmp_within_precision(elements_locations[a], elements_locations[b], settings))) + + selected_types = LoadSelection.get_selected_types(settings) + + if not selected_types: + selected_types = list(set(element.is_a() for element in elements)) + + elements_by_type = [[element for element in elements if element.is_a() == ifc_type] for ifc_type in selected_types] + + failed_types = set() + for (element_number, element) in enumerate(elements): + + type_index = selected_types.index(element.is_a()) + type_elements = elements_by_type[type_index] + type_number = type_elements.index(element) + type_name = selected_types[type_index][3:] + + if storeys: + storey_number = Storeys.get_storey_number(element, storeys, settings, storeys_numbers) + if storey_number is None and "{S}" in settings.get("format"): + if report is not None: + report({'WARNING'}, f"Element {getattr(element, 'Name', '')} of type {element.is_a()} with ID {get_id(element)} is not contained in any storey.") + else: + raise Exception(f"Element {getattr(element, 'Name', '')} of type {element.is_a()} with ID {get_id(element)} is not contained in any storey.") + else: + storey_number = None + + number = NumberFormatting.format_number(settings, (element_number, type_number, storey_number), (len(elements), len(type_elements), len(storeys)), type_name) + count = SaveNumber.save_number(ifc_file, element, number, settings, numbers_cache) + if count is None: + report({'WARNING'}, f"Failed to save number for element {getattr(element, 'Name', '')} of type {element.is_a()} with ID {get_id(element)}.") + failed_types.add(element.is_a()) + else: + number_count += count + + if failed_types: + report({'WARNING'}, f"Failed to renumber the following types: {failed_types}") + + if settings.get("remove_toggle") and remove_count is not None: + report({'INFO'}, f"Renumbered {number_count} objects, removed number from {remove_count} objects.") + else: + report({'INFO'}, f"Renumbered {number_count} objects.") + + return {'FINISHED'}, number_count + + @staticmethod + def assign_numbers(operator, settings, numbers_cache): + """Assign numbers to selected objects based on their IFC type and location.""" + ifc_file = tool.Ifc.get() + + remove_count = 0 + + if settings.get("remove_toggle"): + for obj in bpy.context.scene.objects: + if (settings.get("selected_toggle") and obj not in bpy.context.selected_objects) or \ + (settings.get("visible_toggle") and not obj.visible_get()): + element = tool.Ifc.get_entity(obj) + if element is not None and element.is_a(LoadSelection.get_parent_type(settings)): + count_diff = SaveNumber.remove_number(ifc_file, element, settings, numbers_cache) + remove_count += count_diff + + objects = LoadSelection.load_selected_objects(settings) + + if not objects: + operator.report({'WARNING'}, f"No objects selected or available for numbering, removed {remove_count} existing numbers.") + return {'CANCELLED'} + + selected_types = LoadSelection.get_selected_types(settings) + possible_types = [tupl[0] for tupl in LoadSelection.possible_types] + + selected_elements = [] + elements_locations = {} + elements_dimensions = {} + for obj in objects: + element = tool.Ifc.get_entity(obj) + if element is None: + continue + if element.is_a() in selected_types: + selected_elements.append(element) + elements_locations[element] = ObjectGeometry.get_object_location(obj, settings) + elements_dimensions[element] = ObjectGeometry.get_object_dimensions(obj) + elif settings.get("remove_toggle") and element.is_a() in possible_types: + remove_count += SaveNumber.remove_number(ifc_file, element, settings, numbers_cache) + + if not selected_elements: + operator.report({'WARNING'}, f"No elements selected or available for numbering, removed {remove_count} existing numbers.") + + storeys = Storeys.get_storeys(settings) + res, _ = Numbering.number_elements(selected_elements, + ifc_file, settings, + elements_locations, + elements_dimensions, + storeys, + numbers_cache, + report = operator.report, + remove_count=remove_count) + + if settings.get("check_duplicates_toggle"): + numbers = [] + for obj in bpy.context.scene.objects: + element = tool.Ifc.get_entity(obj) + if element is None or not element.is_a(LoadSelection.get_parent_type(settings)): + continue + number = SaveNumber.get_number(element, settings, numbers_cache) + if number in numbers: + operator.report({'WARNING'}, f"The model contains duplicate numbers") + return {'FINISHED'} + if number is not None: + numbers.append(number) + + return res + + def remove_numbers(self, settings, numbers_cache): + """Remove numbers from selected objects""" + ifc_file = tool.Ifc.get() + + remove_count = 0 + + objects = bpy.context.selected_objects if settings.get("selected_toggle") else bpy.context.scene.objects + if settings.get("visible_toggle"): + objects = [obj for obj in objects if obj.visible_get()] + + if not objects: + self.report({'WARNING'}, f"No objects selected or available for removal.") + return {'CANCELLED'} + + for obj in objects: + element = tool.Ifc.get_entity(obj) + if element is not None and element.is_a(LoadSelection.get_parent_type(settings)): + remove_count += SaveNumber.remove_number(ifc_file, element, settings, numbers_cache) + numbers_cache[get_id(element)] = None + + if remove_count == 0: + self.report({'WARNING'}, f"No elements selected or available for removal.") + return {'CANCELLED'} + + self.report({'INFO'}, f"Removed {remove_count} existing numbers.") + return {'FINISHED'} \ No newline at end of file From b6591363e0811000465bd58225e4113c7d8db9ec Mon Sep 17 00:00:00 2001 From: BjornFidder Date: Wed, 27 Aug 2025 14:54:19 +0200 Subject: [PATCH 5/9] Use black formatter This commit refactors and formats the BIM numbering module for improved readability and consistency. Changes include reformatting code, updating property definitions, improving error reporting, and cleaning up whitespace and indentation across multiple files. No functional changes were made. --- .../bonsai/bim/module/numbering/__init__.py | 3 +- .../bonsai/bim/module/numbering/data.py | 9 +- .../bonsai/bim/module/numbering/operator.py | 71 ++-- .../bonsai/bim/module/numbering/prop.py | 266 ++++++------ src/bonsai/bonsai/bim/module/numbering/ui.py | 13 +- .../bonsai/bim/module/numbering/workspace.py | 2 + src/bonsai/bonsai/core/numbering.py | 399 ++++++++++++------ src/bonsai/bonsai/core/tool.py | 1 - src/bonsai/bonsai/tool/numbering.py | 35 -- 9 files changed, 449 insertions(+), 350 deletions(-) diff --git a/src/bonsai/bonsai/bim/module/numbering/__init__.py b/src/bonsai/bonsai/bim/module/numbering/__init__.py index 2b96ce5d625..921b3e47a9e 100644 --- a/src/bonsai/bonsai/bim/module/numbering/__init__.py +++ b/src/bonsai/bonsai/bim/module/numbering/__init__.py @@ -30,9 +30,10 @@ operator.ImportSettings, operator.ExportSettings, operator.ShowMessage, - ui.BIM_PT_Numbering + ui.BIM_PT_Numbering, ) + def register(): if not bpy.app.background: bpy.utils.register_tool(workspace.NumberingTool, after={"bim.structural_tool"}, separator=False, group=False) diff --git a/src/bonsai/bonsai/bim/module/numbering/data.py b/src/bonsai/bonsai/bim/module/numbering/data.py index 77bddd85995..0160499d453 100644 --- a/src/bonsai/bonsai/bim/module/numbering/data.py +++ b/src/bonsai/bonsai/bim/module/numbering/data.py @@ -32,17 +32,12 @@ def load(cls): cls.is_loaded = True cls.data["poll"] = cls.poll() if cls.data["poll"]: - cls.data.update( - { - "has_project": cls.has_project(), - "project": cls.project() - } - ) + cls.data.update({"has_project": cls.has_project(), "project": cls.project()}) @classmethod def poll(cls): return cls.has_project() - + @classmethod def has_project(cls): return bool(tool.Ifc.get()) diff --git a/src/bonsai/bonsai/bim/module/numbering/operator.py b/src/bonsai/bonsai/bim/module/numbering/operator.py index 7a5d4982ec6..3ea9137b4a0 100644 --- a/src/bonsai/bonsai/bim/module/numbering/operator.py +++ b/src/bonsai/bonsai/bim/module/numbering/operator.py @@ -24,6 +24,7 @@ import functools as ft from bonsai.core.numbering import Settings, LoadSelection, SaveNumber, Numbering, get_id + class UndoOperator: @staticmethod def execute_with_undo(operator, context, method): @@ -33,11 +34,11 @@ def execute_with_undo(operator, context, method): settings = Settings.to_dict(context.scene.BIMNumberingProperties) parent_type = LoadSelection.get_parent_type(settings) - try: + try: elements = ifc_file.by_type(parent_type) except RuntimeError: - operator.report({'ERROR'}, f"Parent type {parent_type} not found in {ifc_file.schema} schema.") - return {'CANCELLED'} + operator.report({"ERROR"}, f"Parent type {parent_type} not found in {ifc_file.schema} schema.") + return {"CANCELLED"} if settings.get("pset_name") == "Common": SaveNumber.get_pset_common_names(elements) @@ -54,7 +55,7 @@ def execute_with_undo(operator, context, method): bpy.context.view_layer.objects.active = bpy.context.active_object return result - + @staticmethod def rollback(operator, data): """Support undo of number assignment""" @@ -64,9 +65,11 @@ def rollback(operator, data): settings = Settings.to_dict(bpy.context.scene.BIMNumberingProperties) for element in ifc_file.by_type(LoadSelection.get_parent_type(settings)): old_number = data["old_value"].get(get_id(element), None) - rollback_count += int(SaveNumber.save_number(ifc_file, element, old_number, settings, data["new_value"]) or 0) - bpy.ops.bim.show_message('EXEC_DEFAULT', message=f"Rollback {rollback_count} numbers.") - + rollback_count += int( + SaveNumber.save_number(ifc_file, element, old_number, settings, data["new_value"]) or 0 + ) + bpy.ops.bim.show_message("EXEC_DEFAULT", message=f"Rollback {rollback_count} numbers.") + @staticmethod def commit(operator, data): """Support redo of number assignment""" @@ -78,9 +81,12 @@ def commit(operator, data): element = tool.Ifc.get_entity(obj) if element is not None and element.is_a(LoadSelection.get_parent_type(settings)): new_number = data["new_value"].get(obj.name, None) - commit_count += int(SaveNumber.save_number(ifc_file, element, new_number, settings, data["old_value"]) or 0) - bpy.ops.bim.show_message('EXEC_DEFAULT', message=f"Commit {commit_count} numbers.") - + commit_count += int( + SaveNumber.save_number(ifc_file, element, new_number, settings, data["old_value"]) or 0 + ) + bpy.ops.bim.show_message("EXEC_DEFAULT", message=f"Commit {commit_count} numbers.") + + class AssignNumbers(bpy.types.Operator): bl_idname = "bim.assign_numbers" bl_label = "Assign numbers" @@ -92,35 +98,37 @@ def execute(self, context): def rollback(self, data): UndoOperator.rollback(self, data) - + def commit(self, data): UndoOperator.commit(self, data) + class RemoveNumbers(bpy.types.Operator): bl_idname = "bim.remove_numbers" bl_label = "Remove numbers" bl_description = "Remove numbers from selected objects, from the selected attribute or Pset" bl_options = {"REGISTER", "UNDO"} - def execute(self, context): return UndoOperator.execute_with_undo(self, context, Numbering.remove_numbers) def rollback(self, data): UndoOperator.rollback(self, data) - + def commit(self, data): UndoOperator.commit(self, data) + class ShowMessage(bpy.types.Operator): bl_idname = "bim.show_message" bl_label = "Show Message" bl_description = "Show a message in the info area" - message: bpy.props.StringProperty() # pyright: ignore[reportInvalidTypeForm] + message: bpy.props.StringProperty() # pyright: ignore[reportInvalidTypeForm] def execute(self, context): - self.report({'INFO'}, self.message) - return {'FINISHED'} + self.report({"INFO"}, self.message) + return {"FINISHED"} + class SaveSettings(bpy.types.Operator): bl_idname = "bim.save_settings" @@ -131,7 +139,8 @@ def execute(self, context): props = context.scene.BIMNumberingProperties ifc_file = tool.Ifc.get() return Settings.save_settings(self, props, ifc_file) - + + class LoadSettings(bpy.types.Operator): bl_idname = "bim.load_settings" bl_label = "Load Settings" @@ -142,6 +151,7 @@ def execute(self, context): ifc_file = tool.Ifc.get() return Settings.load_settings(self, props, ifc_file) + class DeleteSettings(bpy.types.Operator): bl_idname = "bim.delete_settings" bl_label = "Delete Settings" @@ -151,6 +161,7 @@ def execute(self, context): props = context.scene.BIMNumberingProperties return Settings.delete_settings(self, props) + class ClearSettings(bpy.types.Operator): bl_idname = "bim.clear_settings" bl_label = "Clear Settings" @@ -160,39 +171,41 @@ def execute(self, context): props = context.scene.BIMNumberingProperties return Settings.clear_settings(self, props) + class ExportSettings(bpy.types.Operator): bl_idname = "bim.export_settings" bl_label = "Export Settings" bl_description = f"Export the current numbering settings to a JSON file" - filepath: bpy.props.StringProperty(subtype="FILE_PATH") # pyright: ignore[reportInvalidTypeForm] + filepath: bpy.props.StringProperty(subtype="FILE_PATH") # pyright: ignore[reportInvalidTypeForm] def execute(self, context): props = context.scene.BIMNumberingProperties - with open(self.filepath, 'w') as f: + with open(self.filepath, "w") as f: json.dump(Settings.settings_dict(props), f) - self.report({'INFO'}, f"Exported settings to {self.filepath}") - return {'FINISHED'} + self.report({"INFO"}, f"Exported settings to {self.filepath}") + return {"FINISHED"} def invoke(self, context, event): self.filepath = "settings.json" context.window_manager.fileselect_add(self) - return {'RUNNING_MODAL'} - + return {"RUNNING_MODAL"} + + class ImportSettings(bpy.types.Operator): bl_idname = "bim.import_settings" bl_label = "Import Settings" bl_description = f"Import numbering settings from a JSON file" - filepath: bpy.props.StringProperty(subtype="FILE_PATH") # pyright: ignore[reportInvalidTypeForm] + filepath: bpy.props.StringProperty(subtype="FILE_PATH") # pyright: ignore[reportInvalidTypeForm] def execute(self, context): props = context.scene.BIMNumberingProperties - with open(self.filepath, 'r') as f: + with open(self.filepath, "r") as f: settings = json.load(f) Settings.read_settings(self, settings, props) - self.report({'INFO'}, f"Imported settings from {self.filepath}") - return {'FINISHED'} - + self.report({"INFO"}, f"Imported settings from {self.filepath}") + return {"FINISHED"} + def invoke(self, context, event): context.window_manager.fileselect_add(self) - return {'RUNNING_MODAL'} + return {"RUNNING_MODAL"} diff --git a/src/bonsai/bonsai/bim/module/numbering/prop.py b/src/bonsai/bonsai/bim/module/numbering/prop.py index 07c0a13998f..faa6462bc54 100644 --- a/src/bonsai/bonsai/bim/module/numbering/prop.py +++ b/src/bonsai/bonsai/bim/module/numbering/prop.py @@ -3,21 +3,14 @@ import bpy from bpy.types import PropertyGroup -from bpy.props import ( - IntProperty, - StringProperty, - EnumProperty, - BoolProperty, - IntVectorProperty -) +from bpy.props import IntProperty, StringProperty, EnumProperty, BoolProperty, IntVectorProperty from bonsai.core.numbering import Settings, LoadSelection, NumberFormatting, NumberingSystems, SaveNumber, Storeys - + + class BIMNumberingProperties(PropertyGroup): settings_name: StringProperty( - name="Settings name", - description="Name for saving the current settings", - default="" - ) # pyright: ignore[reportInvalidTypeForm] + name="Settings name", description="Name for saving the current settings", default="" + ) # pyright: ignore[reportInvalidTypeForm] def get_saved_settings_items(self, context): settings_names = Settings.get_settings_names() @@ -25,26 +18,24 @@ def get_saved_settings_items(self, context): return [("NONE", "No saved settings", "")] return [(name, name, "") for name in settings_names] - saved_settings : EnumProperty( - name="Load settings", - description="Select which saved settings to load", - items=get_saved_settings_items - ) # pyright: ignore[reportInvalidTypeForm] + saved_settings: EnumProperty( + name="Load settings", description="Select which saved settings to load", items=get_saved_settings_items + ) # pyright: ignore[reportInvalidTypeForm] selected_toggle: BoolProperty( name="Selected Only", description="Only number selected objects", default=False, - update=NumberFormatting.update_format_preview - ) # pyright: ignore[reportInvalidTypeForm] + update=NumberFormatting.update_format_preview, + ) # pyright: ignore[reportInvalidTypeForm] visible_toggle: BoolProperty( name="Visible Only", description="Only number visible objects", default=False, - update=NumberFormatting.update_format_preview - ) # pyright: ignore[reportInvalidTypeForm] - + update=NumberFormatting.update_format_preview, + ) # pyright: ignore[reportInvalidTypeForm] + parent_type: EnumProperty( name="Parent Type", description="Select the parent type for numbering", @@ -52,60 +43,60 @@ def get_saved_settings_items(self, context): ("IfcElement", "IfcElement", "Number IFC elements"), ("IfcProduct", "IfcProduct", "Number IFC products"), ("IfcGridAxis", "IfcGridAxis", "Number IFC grid axes"), - ("Other", "Other", "Input which IFC entities to number") + ("Other", "Other", "Input which IFC entities to number"), ], default="IfcElement", - update = LoadSelection.update_objects - ) # pyright: ignore[reportInvalidTypeForm] + update=LoadSelection.update_objects, + ) # pyright: ignore[reportInvalidTypeForm] - parent_type_other : StringProperty( + parent_type_other: StringProperty( name="Other Parent Type", description="Input which IFC entities to number", default="IfcElement", - update = LoadSelection.update_objects - ) # pyright: ignore[reportInvalidTypeForm] + update=LoadSelection.update_objects, + ) # pyright: ignore[reportInvalidTypeForm] def update_selected_types(self, context): NumberFormatting.update_format_preview(self, context) - SaveNumber.update_pset_names(self, context) + SaveNumber.update_pset_names(self, context) selected_types: EnumProperty( name="Selected Types", description="Select which types of elements to number", - items= LoadSelection.get_possible_types, - options={'ENUM_FLAG'}, - update=update_selected_types - ) # pyright: ignore[reportInvalidTypeForm] + items=LoadSelection.get_possible_types, + options={"ENUM_FLAG"}, + update=update_selected_types, + ) # pyright: ignore[reportInvalidTypeForm] x_direction: EnumProperty( name="X", description="Select axis direction for numbering elements", items=[ ("1", "+", "Number elements in order of increasing X coordinate"), - ("-1", "-", "Number elements in order of decreasing X coordinate") + ("-1", "-", "Number elements in order of decreasing X coordinate"), ], default="1", - ) # pyright: ignore[reportInvalidTypeForm] + ) # pyright: ignore[reportInvalidTypeForm] y_direction: EnumProperty( name="Y", description="Select axis direction for numbering elements", items=[ ("1", "+", "Number elements in order of increasing Y coordinate"), - ("-1", "-", "Number elements in order of decreasing Y coordinate") + ("-1", "-", "Number elements in order of decreasing Y coordinate"), ], - default="1" - ) # pyright: ignore[reportInvalidTypeForm] + default="1", + ) # pyright: ignore[reportInvalidTypeForm] z_direction: EnumProperty( name="Z", description="Select axis direction for numbering elements", items=[ ("1", "+", "Number elements in order of increasing Z coordinate"), - ("-1", "-", "Number elements in order of decreasing Z coordinate") + ("-1", "-", "Number elements in order of decreasing Z coordinate"), ], - default="1" - ) # pyright: ignore[reportInvalidTypeForm] + default="1", + ) # pyright: ignore[reportInvalidTypeForm] axis_order: EnumProperty( name="Axis Order", @@ -116,10 +107,10 @@ def update_selected_types(self, context): ("YXZ", "Y, X, Z", "Number elements in Y, X, Z order"), ("YZX", "Y, Z, X", "Number elements in Y, Z, X order"), ("ZXY", "Z, X, Y", "Number elements in Z, X, Y order"), - ("ZYX", "Z, Y, X", "Number elements in Z, Y, X order") + ("ZYX", "Z, Y, X", "Number elements in Z, Y, X order"), ], - default="ZYX" - ) # pyright: ignore[reportInvalidTypeForm] + default="ZYX", + ) # pyright: ignore[reportInvalidTypeForm] location_type: EnumProperty( name="Reference Location", @@ -128,43 +119,59 @@ def update_selected_types(self, context): ("CENTER", "Center", "Use object center for sorting"), ("BOUNDING_BOX", "Bounding Box", "Use object bounding box for sorting"), ], - default="BOUNDING_BOX" - ) # pyright: ignore[reportInvalidTypeForm] + default="BOUNDING_BOX", + ) # pyright: ignore[reportInvalidTypeForm] precision: IntVectorProperty( name="Precision", description="Precision for sorting elements in X, Y and Z direction", default=(1, 1, 1), min=1, - size=3 - ) # pyright: ignore[reportInvalidTypeForm] + size=3, + ) # pyright: ignore[reportInvalidTypeForm] initial_element_number: IntProperty( name="{E}", description="Initial number for numbering elements", default=1, - update=NumberFormatting.update_format_preview - ) # pyright: ignore[reportInvalidTypeForm] + update=NumberFormatting.update_format_preview, + ) # pyright: ignore[reportInvalidTypeForm] initial_type_number: IntProperty( name="{T}", description="Initial number for numbering elements within type", default=1, - update=NumberFormatting.update_format_preview - ) # pyright: ignore[reportInvalidTypeForm] + update=NumberFormatting.update_format_preview, + ) # pyright: ignore[reportInvalidTypeForm] initial_storey_number: IntProperty( name="{S}", description="Initial number for numbering storeys", default=0, - update=NumberFormatting.update_format_preview - ) # pyright: ignore[reportInvalidTypeForm] - - numberings_enum = lambda self, initial : [ - ("number", NumberingSystems.get_numbering_preview("number", initial), "Use numbers. Negative numbers are shown with brackets"), - ("number_ext", NumberingSystems.get_numbering_preview("number_ext", initial), "Use numbers padded with zeroes to a fixed length based on the number of objects selected. Negative numbers are shown with brackets."), - ("lower_letter", NumberingSystems.get_numbering_preview("lower_letter", initial), "Use lowercase letters, continuing with aa, ab, ... where negative numbers are shown with brackets."), - ("upper_letter", NumberingSystems.get_numbering_preview("upper_letter", initial), "Use uppercase letters, continuing with AA, AB, ... where negative numbers are shown with brackets."), + update=NumberFormatting.update_format_preview, + ) # pyright: ignore[reportInvalidTypeForm] + + numberings_enum = lambda self, initial: [ + ( + "number", + NumberingSystems.get_numbering_preview("number", initial), + "Use numbers. Negative numbers are shown with brackets", + ), + ( + "number_ext", + NumberingSystems.get_numbering_preview("number_ext", initial), + "Use numbers padded with zeroes to a fixed length based on the number of objects selected. Negative numbers are shown with brackets.", + ), + ( + "lower_letter", + NumberingSystems.get_numbering_preview("lower_letter", initial), + "Use lowercase letters, continuing with aa, ab, ... where negative numbers are shown with brackets.", + ), + ( + "upper_letter", + NumberingSystems.get_numbering_preview("upper_letter", initial), + "Use uppercase letters, continuing with AA, AB, ... where negative numbers are shown with brackets.", + ), ] custom_storey_enum = [("custom", "Custom", "Use custom numbering for storeys")] @@ -173,113 +180,110 @@ def update_selected_types(self, context): name="{E}", description="Select numbering system for element numbering", items=lambda self, context: self.numberings_enum(self.initial_element_number), - update=NumberFormatting.update_format_preview - ) # pyright: ignore[reportInvalidTypeForm] + update=NumberFormatting.update_format_preview, + ) # pyright: ignore[reportInvalidTypeForm] type_numbering: EnumProperty( name="{T}", description="Select numbering system for numbering within types", items=lambda self, context: self.numberings_enum(self.initial_type_number), - update=NumberFormatting.update_format_preview - ) # pyright: ignore[reportInvalidTypeForm] + update=NumberFormatting.update_format_preview, + ) # pyright: ignore[reportInvalidTypeForm] def update_storey_numbering(self, context): if self.storey_numbering == "custom": self.initial_storey_number = 0 - + storey_numbering: EnumProperty( name="{S}", description="Select numbering system for numbering storeys. Storeys are numbered in positive Z-order by default.", items=lambda self, context: self.numberings_enum(self.initial_storey_number) + self.custom_storey_enum, - update=update_storey_numbering - ) # pyright: ignore[reportInvalidTypeForm] + update=update_storey_numbering, + ) # pyright: ignore[reportInvalidTypeForm] custom_storey: EnumProperty( - name = "Storey", - description = "Select storey to number", - items = lambda self, _: [(storey.Name, storey.Name, f"{storey.Name}\nID: {storey.GlobalId}") for storey in Storeys.get_storeys(Settings.to_dict(self))], - update = Storeys.update_custom_storey - ) # pyright: ignore[reportInvalidTypeForm] + name="Storey", + description="Select storey to number", + items=lambda self, _: [ + (storey.Name, storey.Name, f"{storey.Name}\nID: {storey.GlobalId}") + for storey in Storeys.get_storeys(Settings.to_dict(self)) + ], + update=Storeys.update_custom_storey, + ) # pyright: ignore[reportInvalidTypeForm] custom_storey_number: IntProperty( - name = "Storey Number", - description = f"Set custom storey number for selected storey, stored in {Storeys.settings['pset_name']} in the IFC element", - get = Storeys.get_custom_storey_number, - set = Storeys.set_custom_storey_number - ) # pyright: ignore[reportInvalidTypeForm] - + name="Storey Number", + description=f"Set custom storey number for selected storey, stored in {Storeys.settings['pset_name']} in the IFC element", + get=Storeys.get_custom_storey_number, + set=Storeys.set_custom_storey_number, + ) # pyright: ignore[reportInvalidTypeForm] + format: StringProperty( name="Format", - description="Format string for selected IFC type.\n" \ - "{E}: element number \n" \ - "{T}: number within type \n" \ - "{S}: number of storey\n" \ - "[T]: first letter of type name\n" \ - "[TT] : all capitalized letters in type name\n" \ + description="Format string for selected IFC type.\n" + "{E}: element number \n" + "{T}: number within type \n" + "{S}: number of storey\n" + "[T]: first letter of type name\n" + "[TT] : all capitalized letters in type name\n" "[TF]: full type name", default="E{E}S{S}[T]{T}", - update=NumberFormatting.update_format_preview - ) # pyright: ignore[reportInvalidTypeForm] + update=NumberFormatting.update_format_preview, + ) # pyright: ignore[reportInvalidTypeForm] - save_type : EnumProperty( + save_type: EnumProperty( name="Type of Number Storage", - items = [("Attribute", "Attribute", "Store number in an attribute of the IFC element"), - ("Pset", "Pset", "Store number in a Pset of the IFC element") + items=[ + ("Attribute", "Attribute", "Store number in an attribute of the IFC element"), + ("Pset", "Pset", "Store number in a Pset of the IFC element"), ], - default = "Attribute", - update = SaveNumber.update_pset_names - ) # pyright: ignore[reportInvalidTypeForm] + default="Attribute", + update=SaveNumber.update_pset_names, + ) # pyright: ignore[reportInvalidTypeForm] - attribute_name : EnumProperty( + attribute_name: EnumProperty( name="Attribute Name", description="Name of the attribute to store the number", - items = [("Tag", "Tag", "Store number in IFC Tag attribute"), - ("Name", "Name", "Store number in IFC Name attribute"), - ("Description", "Description", "Store number in IFC Description attribute"), - ("AxisTag", "AxisTag", "Store number in IFC AxisTag attribute, used for IFCGridAxis"), - ("Other", "Other", "Input in which IFC attribute to store the number") - ], - default="Tag" - ) # pyright: ignore[reportInvalidTypeForm] - - attribute_name_other : StringProperty( - name="Other Attribute Name", - description="Name of the other attribute to store the number", - default="Tag" - ) # pyright: ignore[reportInvalidTypeForm] + items=[ + ("Tag", "Tag", "Store number in IFC Tag attribute"), + ("Name", "Name", "Store number in IFC Name attribute"), + ("Description", "Description", "Store number in IFC Description attribute"), + ("AxisTag", "AxisTag", "Store number in IFC AxisTag attribute, used for IFCGridAxis"), + ("Other", "Other", "Input in which IFC attribute to store the number"), + ], + default="Tag", + ) # pyright: ignore[reportInvalidTypeForm] + + attribute_name_other: StringProperty( + name="Other Attribute Name", description="Name of the other attribute to store the number", default="Tag" + ) # pyright: ignore[reportInvalidTypeForm] def get_pset_names(self, context): return SaveNumber.pset_names - - pset_name : EnumProperty( - name="Pset Name", - description="Name of the Pset to store the number", - items = get_pset_names - ) # pyright: ignore[reportInvalidTypeForm] - - property_name : StringProperty( - name="Property Name", - description="Name of the property to store the number", - default="Number" - ) # pyright: ignore[reportInvalidTypeForm] - - custom_pset_name : StringProperty( - name="Custom Pset Name", - description="Name of the custom Pset to store the number", - default="Pset_Numbering" - ) # pyright: ignore[reportInvalidTypeForm] + + pset_name: EnumProperty( + name="Pset Name", description="Name of the Pset to store the number", items=get_pset_names + ) # pyright: ignore[reportInvalidTypeForm] + + property_name: StringProperty( + name="Property Name", description="Name of the property to store the number", default="Number" + ) # pyright: ignore[reportInvalidTypeForm] + + custom_pset_name: StringProperty( + name="Custom Pset Name", description="Name of the custom Pset to store the number", default="Pset_Numbering" + ) # pyright: ignore[reportInvalidTypeForm] remove_toggle: BoolProperty( name="Remove Numbers from Unselected Objects", description="Remove numbers from unselected objects in the scene", - default=True - ) # pyright: ignore[reportInvalidTypeForm] + default=True, + ) # pyright: ignore[reportInvalidTypeForm] check_duplicates_toggle: BoolProperty( name="Check for Duplicate Numbers", description="Check for duplicate numbers in all objects in the scene", - default=True - ) # pyright: ignore[reportInvalidTypeForm] + default=True, + ) # pyright: ignore[reportInvalidTypeForm] if TYPE_CHECKING: settings_name: str @@ -293,7 +297,7 @@ def get_pset_names(self, context): y_direction: str z_direction: str axis_order: str - location_type: str + location_type: str precision: tuple[int, int, int] initial_element_number: int initial_type_number: int @@ -311,4 +315,4 @@ def get_pset_names(self, context): property_name: str custom_pset_name: str remove_toggle: bool - check_duplicates: bool \ No newline at end of file + check_duplicates: bool diff --git a/src/bonsai/bonsai/bim/module/numbering/ui.py b/src/bonsai/bonsai/bim/module/numbering/ui.py index 02a09d6740d..45db0e541bf 100644 --- a/src/bonsai/bonsai/bim/module/numbering/ui.py +++ b/src/bonsai/bonsai/bim/module/numbering/ui.py @@ -21,6 +21,7 @@ from bonsai.core.numbering import NumberFormatting from bonsai.bim.module.numbering.data import NumberingData + class BIM_PT_Numbering(Panel): bl_label = "Numbering settings" bl_space_type = "VIEW_3D" @@ -31,14 +32,14 @@ class BIM_PT_Numbering(Panel): def poll(cls, context): return context.workspace.tools.from_space_view3d_mode(context.mode).idname == "bim.numbering_tool" - def draw(cls, context): + def draw(cls, context): assert (layout := cls.layout) if not NumberingData.is_loaded: NumberingData.load() - + props = tool.Numbering.get_numbering_props() - + cls.draw_selection(layout, props) cls.draw_numbering_order(layout, props) cls.draw_numbering_systems(layout, props) @@ -47,7 +48,7 @@ def draw(cls, context): cls.draw_settings(layout, props) @classmethod - def draw_selection(cls, layout, props): + def draw_selection(cls, layout, props): box = layout.box() box.alignment = "EXPAND" box.label(text="Elements to number") @@ -79,7 +80,7 @@ def draw_numbering_order(cls, layout, props): grid.prop(props, "precision", index=1, text="Y") grid.prop(props, "precision", index=2, text="Z") - # Axis order and reference point + # Axis order and reference point grid = box.grid_flow(row_major=True, align=True, columns=4) grid.label(text="Order:") grid.prop(props, "axis_order", text="") @@ -142,7 +143,7 @@ def draw_storage_options(cls, layout, props): box.prop(props, "remove_toggle") box.prop(props, "check_duplicates_toggle") - @classmethod + @classmethod def draw_settings(cls, layout, props): box = layout.box() box.alignment = "EXPAND" diff --git a/src/bonsai/bonsai/bim/module/numbering/workspace.py b/src/bonsai/bonsai/bim/module/numbering/workspace.py index 5a41f565fd4..436f6595be7 100644 --- a/src/bonsai/bonsai/bim/module/numbering/workspace.py +++ b/src/bonsai/bonsai/bim/module/numbering/workspace.py @@ -26,6 +26,7 @@ from bonsai.bim.module.numbering.data import NumberingData + class NumberingTool(WorkSpaceTool): bl_space_type = "VIEW_3D" bl_context_mode = "OBJECT" @@ -44,6 +45,7 @@ def draw_settings(context, layout, ws_tool): # Unlike operators, Blender doesn't treat workspace tools as a class, so we'll create our own. NumberingToolUI.draw(context, layout) + class NumberingToolUI: @classmethod diff --git a/src/bonsai/bonsai/core/numbering.py b/src/bonsai/bonsai/core/numbering.py index 68d4e1f3313..f4c218cbf0d 100644 --- a/src/bonsai/bonsai/core/numbering.py +++ b/src/bonsai/bonsai/core/numbering.py @@ -32,11 +32,13 @@ import string import json + def get_id(element): return getattr(element, "GlobalId", element.id()) + class SaveNumber: - + pset_names = [("Custom", "Custom Pset", "")] pset_common_names = {} ifc_file = None @@ -60,10 +62,10 @@ def get_number(element, settings, numbers_cache=None): return getattr(element, SaveNumber.get_attribute_name(settings), None) if settings.get("save_type") == "Pset": pset_name = SaveNumber.get_pset_name(element, settings) - if (pset := get_pset(element, pset_name)): + if pset := get_pset(element, pset_name): return pset.get(settings.get("property_name")) return None - + @staticmethod def save_number(ifc_file, element, number, settings, numbers_cache=None): if element is None: @@ -77,7 +79,7 @@ def save_number(ifc_file, element, number, settings, numbers_cache=None): if not hasattr(element, attribute_name): return None if attribute_name == "Name" and number is None: - number = element.is_a().strip("Ifc") #Reset Name to name of type + number = element.is_a().strip("Ifc") # Reset Name to name of type setattr(element, attribute_name, number) numbers_cache[get_id(element)] = number return 1 @@ -86,10 +88,12 @@ def save_number(ifc_file, element, number, settings, numbers_cache=None): if not pset_name: return None if pset := get_pset(element, pset_name): - pset = ifc_file.by_id(pset["id"]) + pset = ifc_file.by_id(pset["id"]) else: pset = ifc_api.run("pset.add_pset", ifc_file, product=element, name=pset_name) - ifc_api.run("pset.edit_pset", ifc_file, pset=pset, properties={settings["property_name"]: number}, should_purge=True) + ifc_api.run( + "pset.edit_pset", ifc_file, pset=pset, properties={settings["property_name"]: number}, should_purge=True + ) if number is None and not pset.HasProperties: ifc_api.run("pset.remove_pset", ifc_file, product=element, pset=pset) numbers_cache[get_id(element)] = number @@ -119,11 +123,15 @@ def get_pset_name(element, settings): @staticmethod def update_pset_names(prop, context): settings = Settings.to_dict(context.scene.BIMNumberingProperties) - pset_names_sets = [set(SaveNumber.pset_qto.get_applicable_names(ifc_type)) for ifc_type in LoadSelection.get_selected_types(settings)] + pset_names_sets = [ + set(SaveNumber.pset_qto.get_applicable_names(ifc_type)) + for ifc_type in LoadSelection.get_selected_types(settings) + ] intersection = set.intersection(*pset_names_sets) if pset_names_sets else set() - SaveNumber.pset_names = [('Custom Pset', 'Custom Pset', 'Store in custom Pset with selected name'), - ('Common', 'Pset_Common', 'Store in Pset common of the type, e.g. Pset_WallCommon')] + \ - [(name, name, f"Store in Pset called {name}") for name in intersection] + SaveNumber.pset_names = [ + ("Custom Pset", "Custom Pset", "Store in custom Pset with selected name"), + ("Common", "Pset_Common", "Store in Pset common of the type, e.g. Pset_WallCommon"), + ] + [(name, name, f"Store in Pset called {name}") for name in intersection] @staticmethod def get_pset_common_names(elements): @@ -138,18 +146,19 @@ def get_pset_common_names(elements): pset_common_name = name_guess elif (name_guess := "Pset_" + ifc_type.strip("Ifc") + "TypeCommon") in pset_names: pset_common_name = name_guess - elif common_names := [name for name in pset_names if 'Common' in name]: + elif common_names := [name for name in pset_names if "Common" in name]: pset_common_name = common_names[0] else: pset_common_name = None SaveNumber.pset_common_names[ifc_type] = pset_common_name + class LoadSelection: all_objects = [] selected_objects = [] possible_types = [] - + @staticmethod def get_parent_type(settings): """Get the parent type from the settings.""" @@ -172,13 +181,13 @@ def get_selected_types(settings): if "All" in selected_types: selected_types = [type_tuple[0] for type_tuple in LoadSelection.possible_types[1:]] return selected_types - + @staticmethod def load_possible_types(objects, parent_type): """Load the available IFC types and their counts from the selected elements.""" if not objects: return [("All", "All", "element")], {"All": 0} - + ifc_types = [("All", "All", "element")] seen_types = [] number_counts = {"All": 0} @@ -187,24 +196,26 @@ def load_possible_types(objects, parent_type): element = tool.Ifc.get_entity(obj) if element is None or not element.is_a(parent_type): continue - ifc_type = element.is_a() #Starts with "Ifc", which we can strip by starting from index 3 - + ifc_type = element.is_a() # Starts with "Ifc", which we can strip by starting from index 3 + if ifc_type not in seen_types: - seen_types.append(ifc_type) - ifc_types.append((ifc_type, ifc_type[3:], ifc_type[3:].lower())) # Store type as (id, name, name_lower) + seen_types.append(ifc_type) + ifc_types.append((ifc_type, ifc_type[3:], ifc_type[3:].lower())) # Store type as (id, name, name_lower) number_counts[ifc_type] = 0 number_counts["All"] += 1 number_counts[ifc_type] += 1 - - ifc_types.sort(key=lambda ifc_type: ifc_type[0]) - + + ifc_types.sort(key=lambda ifc_type: ifc_type[0]) + return ifc_types, number_counts @staticmethod def update_objects(prop, context): settings = Settings.to_dict(context.scene.BIMNumberingProperties) - ifc_types, number_counts = LoadSelection.load_possible_types(LoadSelection.selected_objects, LoadSelection.get_parent_type(settings)) + ifc_types, number_counts = LoadSelection.load_possible_types( + LoadSelection.selected_objects, LoadSelection.get_parent_type(settings) + ) LoadSelection.possible_types = [(id, name + f": {number_counts[id]}", "") for (id, name, _) in ifc_types] NumberFormatting.update_format_preview(prop, context) SaveNumber.update_pset_names(prop, context) @@ -223,11 +234,10 @@ def get_possible_types(prop, context): LoadSelection.update_objects(prop, context) return LoadSelection.possible_types + class Storeys: - settings = {"save_type": "Pset", - "pset_name": "Pset_Numbering", - "property_name": "CustomStoreyNumber"} + settings = {"save_type": "Pset", "pset_name": "Pset_Numbering", "property_name": "CustomStoreyNumber"} @staticmethod def get_storeys(settings): @@ -239,7 +249,13 @@ def get_storeys(settings): if element is not None and element.is_a("IfcBuildingStorey"): storeys.append(element) storey_locations[element] = ObjectGeometry.get_object_location(obj, settings) - storeys.sort(key=ft.cmp_to_key(lambda a, b: ObjectGeometry.cmp_within_precision(storey_locations[a], storey_locations[b], settings, use_dir=False))) + storeys.sort( + key=ft.cmp_to_key( + lambda a, b: ObjectGeometry.cmp_within_precision( + storey_locations[a], storey_locations[b], settings, use_dir=False + ) + ) + ) return storeys @staticmethod @@ -247,7 +263,7 @@ def update_custom_storey(props, context): storeys = Storeys.get_storeys(Settings.to_dict(context.scene.BIMNumberingProperties)) storey = next((storey for storey in storeys if storey.Name == props.custom_storey), None) number = SaveNumber.get_number(storey, Storeys.settings) - if number is None: # If the number is not set, use the index + if number is None: # If the number is not set, use the index number = storeys.index(storey) props["_custom_storey_number"] = int(number) @@ -261,7 +277,7 @@ def set_custom_storey_number(props, value): storeys = Storeys.get_storeys(Settings.to_dict(props)) storey = next((storey for storey in storeys if storey.Name == props.custom_storey), None) index = storeys.index(storey) - if value == index: # If the value is the same as the index, remove the number + if value == index: # If the value is the same as the index, remove the number SaveNumber.save_number(ifc_file, storey, None, Storeys.settings) else: SaveNumber.save_number(ifc_file, storey, str(value), Storeys.settings) @@ -282,23 +298,45 @@ def get_storey_number(element, storeys, settings, storeys_numbers): storey_number = storeys.index(storey) if storey in storeys else None return storey_number + class NumberFormatting: format_preview = "" @staticmethod - def format_number(settings, number_values = (0, 0, None), max_number_values=(100, 100, 1), type_name=""): + def format_number(settings, number_values=(0, 0, None), max_number_values=(100, 100, 1), type_name=""): """Return the formatted number for the given element, type and storey number""" format = settings.get("format", None) if format is None: return format if "{E}" in format: - format = format.replace("{E}", NumberingSystems.to_numbering_string(settings.get("initial_element_number", 0) + number_values[0], settings.get("element_numbering"), max_number_values[0])) + format = format.replace( + "{E}", + NumberingSystems.to_numbering_string( + settings.get("initial_element_number", 0) + number_values[0], + settings.get("element_numbering"), + max_number_values[0], + ), + ) if "{T}" in format: - format = format.replace("{T}", NumberingSystems.to_numbering_string(settings.get("initial_type_number", 0) + number_values[1], settings.get("type_numbering"), max_number_values[1])) + format = format.replace( + "{T}", + NumberingSystems.to_numbering_string( + settings.get("initial_type_number", 0) + number_values[1], + settings.get("type_numbering"), + max_number_values[1], + ), + ) if "{S}" in format: if number_values[2] is not None: - format = format.replace("{S}", NumberingSystems.to_numbering_string(settings.get("initial_storey_number", 0) + number_values[2], settings.get("storey_numbering"), max_number_values[2])) + format = format.replace( + "{S}", + NumberingSystems.to_numbering_string( + settings.get("initial_storey_number", 0) + number_values[2], + settings.get("storey_numbering"), + max_number_values[2], + ), + ) else: format = format.replace("{S}", "x") if "[T]" in format and len(type_name) > 0: @@ -313,47 +351,52 @@ def format_number(settings, number_values = (0, 0, None), max_number_values=(100 def get_type_name(settings): """Return type name used in preview, based on selected types""" if not settings.get("selected_types"): - #If no types selected, return "Type" + # If no types selected, return "Type" return "Type" - #Get the type name of the selected type, excluding 'IfcElement' - types = settings.get("selected_types") - if 'All' in types: - types.remove('All') - if len(types)>0: + # Get the type name of the selected type, excluding 'IfcElement' + types = settings.get("selected_types") + if "All" in types: + types.remove("All") + if len(types) > 0: return str(list(types)[0][3:]) - #If all selected, return type name of one of the selected types + # If all selected, return type name of one of the selected types all_types = LoadSelection.possible_types if len(all_types) > 1: return str(all_types[1][0][3:]) - #If none selected, return "Type" + # If none selected, return "Type" return "Type" @staticmethod def get_max_numbers(settings, type_name): """Return number of selected elements used in preview, based on selected types""" max_element, max_type, max_storey = 0, 0, 0 - if settings.get("storey_numbering") == 'number_ext': + if settings.get("storey_numbering") == "number_ext": max_storey = len(Storeys.get_storeys(settings)) - if settings.get("element_numbering") == 'number_ext' or settings.get("type_numbering") == 'number_ext': + if settings.get("element_numbering") == "number_ext" or settings.get("type_numbering") == "number_ext": if not settings.get("selected_types"): return max_element, max_type, max_storey - type_counts = {type_tuple[0]: int(''.join([c for c in type_tuple[1] if c.isdigit()])) \ - for type_tuple in LoadSelection.possible_types} + type_counts = { + type_tuple[0]: int("".join([c for c in type_tuple[1] if c.isdigit()])) + for type_tuple in LoadSelection.possible_types + } if "All" in settings.get("selected_types"): max_element = type_counts.get("All", 0) else: max_element = sum(type_counts.get(t, 0) for t in LoadSelection.get_selected_types(settings)) - max_type = type_counts.get('Ifc' + type_name, max_element) + max_type = type_counts.get("Ifc" + type_name, max_element) return max_element, max_type, max_storey @staticmethod def update_format_preview(prop, context): settings = Settings.to_dict(context.scene.BIMNumberingProperties) type_name = NumberFormatting.get_type_name(settings) - NumberFormatting.format_preview = NumberFormatting.format_number(settings, (0, 0, 0), NumberFormatting.get_max_numbers(settings, type_name), type_name) + NumberFormatting.format_preview = NumberFormatting.format_number( + settings, (0, 0, 0), NumberFormatting.get_max_numbers(settings, type_name), type_name + ) + class NumberingSystems: - + @staticmethod def to_number(i): """Convert a number to a string.""" @@ -378,26 +421,26 @@ def to_letter(i, upper=False): return "0" if i < 0: return "(" + NumberingSystems.to_letter(-i, upper) + ")" - + num2alphadict = dict(zip(range(1, 27), string.ascii_uppercase if upper else string.ascii_lowercase)) res = "" - numloops = (i-1) // 26 - + numloops = (i - 1) // 26 + if numloops > 0: res = res + NumberingSystems.to_letter(numloops, upper) - + remainder = i % 26 if remainder == 0: remainder += 26 return res + num2alphadict[remainder] - + @staticmethod def get_numberings(): return { "number": NumberingSystems.to_number, "number_ext": NumberingSystems.to_number_ext, "lower_letter": NumberingSystems.to_letter, - "upper_letter": lambda x: NumberingSystems.to_letter(x, True) + "upper_letter": lambda x: NumberingSystems.to_letter(x, True), } def to_numbering_string(i, numbering_system, max_number): @@ -415,6 +458,7 @@ def get_numbering_preview(numbering_system, initial): numbers = [NumberingSystems.to_numbering_string(i, numbering_system, 10) for i in range(initial, initial + 3)] return "{0}, {1}, {2}, ...".format(*numbers) + class ObjectGeometry: @staticmethod def get_object_location(obj, settings): @@ -428,14 +472,17 @@ def get_object_location(obj, settings): elif settings.get("location_type") == "BOUNDING_BOX": bbox_vector = Vector((0, 0, 0)) # Determine the coordinates based on the direction and axis order - direction = (int(settings.get("x_direction")), int(settings.get("y_direction")), int(settings.get("z_direction"))) + direction = ( + int(settings.get("x_direction")), + int(settings.get("y_direction")), + int(settings.get("z_direction")), + ) for i in range(3): if direction[i] == -1: bbox_vector[i] = max(v[i] for v in bbox_vectors) else: bbox_vector[i] = min(v[i] for v in bbox_vectors) return bbox_vector - @staticmethod def get_object_dimensions(obj): @@ -455,7 +502,15 @@ def get_object_dimensions(obj): @staticmethod def cmp_within_precision(a, b, settings, use_dir=True): """Compare two vectors within a given precision.""" - direction = (int(settings.get("x_direction", 1)), int(settings.get("y_direction", 1)), int(settings.get("z_direction", 1))) if use_dir else (1, 1, 1) + direction = ( + ( + int(settings.get("x_direction", 1)), + int(settings.get("y_direction", 1)), + int(settings.get("z_direction", 1)), + ) + if use_dir + else (1, 1, 1) + ) for axis in settings.get("axis_order", "XYZ"): idx = "XYZ".index(axis) diff = (a[idx] - b[idx]) * direction[idx] @@ -463,6 +518,7 @@ def cmp_within_precision(a, b, settings, use_dir=True): return 1 if diff > 0 else -1 return 0 + class ElementGeometry: @staticmethod def get_element_location(element, settings): @@ -474,9 +530,13 @@ def get_element_location(element, settings): verts = ifc_shape.get_shape_vertices(shape, shape.geometry) if settings.get("location_type") == "CENTER": return np.mean(verts, axis=0) - + elif settings.get("location_type") == "BOUNDING_BOX": - direction = (int(settings.get("x_direction", 1)), int(settings.get("y_direction", 1)), int(settings.get("z_direction", 1))) + direction = ( + int(settings.get("x_direction", 1)), + int(settings.get("y_direction", 1)), + int(settings.get("z_direction", 1)), + ) bbox_min = np.min(verts, axis=0) bbox_max = np.max(verts, axis=0) bbox_vector = np.zeros(3) @@ -486,7 +546,6 @@ def get_element_location(element, settings): else: bbox_vector[i] = bbox_min[i] return bbox_vector - @staticmethod def get_element_dimensions(element): @@ -500,20 +559,21 @@ def get_element_dimensions(element): bbox_max = np.max(verts, axis=0) return bbox_max - bbox_min + class Settings: pset_name = "Pset_NumberingSettings" settings_names = None - + def import_settings(filepath): """Import settings from a JSON file, e.g. as exported from the UI""" - with open(filepath, 'r') as file: + with open(filepath, "r") as file: settings = json.load(file) return settings def default_settings(): - """"Return a default dictionary of settings for numbering elements.""" + """ "Return a default dictionary of settings for numbering elements.""" return { "x_direction": 1, "y_direction": 1, @@ -532,9 +592,9 @@ def default_settings(): "attribute_name": "Tag", "pset_name": "Common", "custom_pset_name": "Pset_Numbering", - "property_name": "Number" - } - + "property_name": "Number", + } + @staticmethod def to_dict(props): """Convert the properties to a dictionary for saving.""" @@ -564,8 +624,8 @@ def to_dict(props): "custom_pset_name": props.custom_pset_name, "property_name": props.property_name, "remove_toggle": props.remove_toggle, - "check_duplicates_toggle": props.check_duplicates_toggle - } + "check_duplicates_toggle": props.check_duplicates_toggle, + } @staticmethod def save_settings(operator, props, ifc_file): @@ -574,19 +634,24 @@ def save_settings(operator, props, ifc_file): project = ifc_file.by_type("IfcProject")[0] settings_name = props.settings_name.strip() if not settings_name: - operator.report({'ERROR'}, "Please enter a name for the settings.") - return {'CANCELLED'} + operator.report({"ERROR"}, "Please enter a name for the settings.") + return {"CANCELLED"} if pset_settings := get_pset(project, Settings.pset_name): pset_settings = ifc_file.by_id(pset_settings["id"]) else: pset_settings = ifc_api.run("pset.add_pset", ifc_file, product=project, name=Settings.pset_name) if not pset_settings: - operator.report({'ERROR'}, "Could not create property set") - return {'CANCELLED'} - ifc_api.run("pset.edit_pset", ifc_file, pset=pset_settings, properties={settings_name: json.dumps(Settings.to_dict(props))}) + operator.report({"ERROR"}, "Could not create property set") + return {"CANCELLED"} + ifc_api.run( + "pset.edit_pset", + ifc_file, + pset=pset_settings, + properties={settings_name: json.dumps(Settings.to_dict(props))}, + ) Settings.settings_names.add(settings_name) - operator.report({'INFO'}, f"Saved settings '{settings_name}' to IFCProject element") - return {'FINISHED'} + operator.report({"INFO"}, f"Saved settings '{settings_name}' to IFCProject element") + return {"FINISHED"} @staticmethod def read_settings(operator, settings, props): @@ -597,7 +662,7 @@ def read_settings(operator, settings, props): try: setattr(props, key, value) except Exception as e: - operator.report({'ERROR'}, f"Failed to set property {key} to {value}. Error: {e}") + operator.report({"ERROR"}, f"Failed to set property {key} to {value}. Error: {e}") @staticmethod def get_settings_names(): @@ -616,44 +681,48 @@ def load_settings(operator, props, ifc_file): # Load selected settings by name settings_name = props.saved_settings if settings_name == "NONE": - operator.report({'WARNING'}, "No saved settings to load.") - return {'CANCELLED'} + operator.report({"WARNING"}, "No saved settings to load.") + return {"CANCELLED"} if pset_settings := get_pset(ifc_file.by_type("IfcProject")[0], Settings.pset_name): settings = pset_settings.get(settings_name, None) if settings is None: - operator.report({'WARNING'}, f"Settings '{settings_name}' not found.") - return {'CANCELLED'} + operator.report({"WARNING"}, f"Settings '{settings_name}' not found.") + return {"CANCELLED"} settings = json.loads(settings) Settings.read_settings(operator, settings, props) - operator.report({'INFO'}, f"Loaded settings '{settings_name}' from IFCProject element") - return {'FINISHED'} + operator.report({"INFO"}, f"Loaded settings '{settings_name}' from IFCProject element") + return {"FINISHED"} else: - operator.report({'WARNING'}, "No settings found") - return {'CANCELLED'} - + operator.report({"WARNING"}, "No settings found") + return {"CANCELLED"} + @staticmethod def delete_settings(operator, props): ifc_file = tool.Ifc.get() settings_name = props.saved_settings if settings_name == "NONE": - operator.report({'WARNING'}, "No saved settings to delete.") - return {'CANCELLED'} + operator.report({"WARNING"}, "No saved settings to delete.") + return {"CANCELLED"} if pset_settings := get_pset(ifc_file.by_type("IfcProject")[0], Settings.pset_name): if settings_name in pset_settings: pset_settings = ifc_file.by_id(pset_settings["id"]) - ifc_api.run("pset.edit_pset", ifc_file, pset=pset_settings, properties={settings_name: None}, should_purge=True) + ifc_api.run( + "pset.edit_pset", ifc_file, pset=pset_settings, properties={settings_name: None}, should_purge=True + ) Settings.settings_names.remove(settings_name) - operator.report({'INFO'}, f"Deleted settings '{settings_name}' from IFCProject element") + operator.report({"INFO"}, f"Deleted settings '{settings_name}' from IFCProject element") if not pset_settings.HasProperties: - ifc_api.run("pset.remove_pset", ifc_file, product=ifc_file.by_type("IfcProject")[0], pset=pset_settings) - return {'FINISHED'} + ifc_api.run( + "pset.remove_pset", ifc_file, product=ifc_file.by_type("IfcProject")[0], pset=pset_settings + ) + return {"FINISHED"} else: - operator.report({'WARNING'}, f"Settings '{settings_name}' not found.") - return {'CANCELLED'} + operator.report({"WARNING"}, f"Settings '{settings_name}' not found.") + return {"CANCELLED"} else: - operator.report({'WARNING'}, "No settings found") - return {'CANCELLED'} + operator.report({"WARNING"}, "No settings found") + return {"CANCELLED"} @staticmethod def clear_settings(operator, props): @@ -662,45 +731,73 @@ def clear_settings(operator, props): if pset_settings := get_pset(project, Settings.pset_name): pset_settings = ifc_file.by_id(pset_settings["id"]) ifc_api.run("pset.remove_pset", ifc_file, product=project, pset=pset_settings) - operator.report({'INFO'}, f"Cleared settings from IFCProject element") + operator.report({"INFO"}, f"Cleared settings from IFCProject element") Settings.settings_names = set() - return {'FINISHED'} + return {"FINISHED"} else: - operator.report({'WARNING'}, "No settings found") - return {'CANCELLED'} + operator.report({"WARNING"}, "No settings found") + return {"CANCELLED"} + class Numbering: @staticmethod - def number_elements(elements, ifc_file, settings, elements_locations = None, elements_dimensions = None, storeys = None, numbers_cache = {}, storeys_numbers={}, report=None, remove_count=None): + def number_elements( + elements, + ifc_file, + settings, + elements_locations=None, + elements_dimensions=None, + storeys=None, + numbers_cache={}, + storeys_numbers={}, + report=None, + remove_count=None, + ): """Number elements in the IFC file with the provided settings. If element locations or dimensions are specified, these are used for sorting. Providing numbers_cache, a dictionary with element-> currently saved number, speeds up execution. If storeys_numbers is provided, as a dictionary storey->number, this is used for assigning storey numbers.""" if report is None: + def report(report_type, message): if report_type == {"INFO"}: print("INFO: ", message) if report_type == {"WARNING"}: raise Exception(message) + if storeys is None: storeys = [] number_count = 0 if elements_dimensions: - elements.sort(key=ft.cmp_to_key(lambda a, b: ObjectGeometry.cmp_within_precision(elements_dimensions[a], elements_dimensions[b], settings, use_dir=False))) + elements.sort( + key=ft.cmp_to_key( + lambda a, b: ObjectGeometry.cmp_within_precision( + elements_dimensions[a], elements_dimensions[b], settings, use_dir=False + ) + ) + ) if elements_locations: - elements.sort(key=ft.cmp_to_key(lambda a, b: ObjectGeometry.cmp_within_precision(elements_locations[a], elements_locations[b], settings))) + elements.sort( + key=ft.cmp_to_key( + lambda a, b: ObjectGeometry.cmp_within_precision( + elements_locations[a], elements_locations[b], settings + ) + ) + ) selected_types = LoadSelection.get_selected_types(settings) if not selected_types: selected_types = list(set(element.is_a() for element in elements)) - elements_by_type = [[element for element in elements if element.is_a() == ifc_type] for ifc_type in selected_types] + elements_by_type = [ + [element for element in elements if element.is_a() == ifc_type] for ifc_type in selected_types + ] failed_types = set() - for (element_number, element) in enumerate(elements): + for element_number, element in enumerate(elements): type_index = selected_types.index(element.is_a()) type_elements = elements_by_type[type_index] @@ -711,29 +808,42 @@ def report(report_type, message): storey_number = Storeys.get_storey_number(element, storeys, settings, storeys_numbers) if storey_number is None and "{S}" in settings.get("format"): if report is not None: - report({'WARNING'}, f"Element {getattr(element, 'Name', '')} of type {element.is_a()} with ID {get_id(element)} is not contained in any storey.") + report( + {"WARNING"}, + f"Element {getattr(element, 'Name', '')} of type {element.is_a()} with ID {get_id(element)} is not contained in any storey.", + ) else: - raise Exception(f"Element {getattr(element, 'Name', '')} of type {element.is_a()} with ID {get_id(element)} is not contained in any storey.") + raise Exception( + f"Element {getattr(element, 'Name', '')} of type {element.is_a()} with ID {get_id(element)} is not contained in any storey." + ) else: storey_number = None - - number = NumberFormatting.format_number(settings, (element_number, type_number, storey_number), (len(elements), len(type_elements), len(storeys)), type_name) + + number = NumberFormatting.format_number( + settings, + (element_number, type_number, storey_number), + (len(elements), len(type_elements), len(storeys)), + type_name, + ) count = SaveNumber.save_number(ifc_file, element, number, settings, numbers_cache) if count is None: - report({'WARNING'}, f"Failed to save number for element {getattr(element, 'Name', '')} of type {element.is_a()} with ID {get_id(element)}.") + report( + {"WARNING"}, + f"Failed to save number for element {getattr(element, 'Name', '')} of type {element.is_a()} with ID {get_id(element)}.", + ) failed_types.add(element.is_a()) else: number_count += count if failed_types: - report({'WARNING'}, f"Failed to renumber the following types: {failed_types}") + report({"WARNING"}, f"Failed to renumber the following types: {failed_types}") if settings.get("remove_toggle") and remove_count is not None: - report({'INFO'}, f"Renumbered {number_count} objects, removed number from {remove_count} objects.") + report({"INFO"}, f"Renumbered {number_count} objects, removed number from {remove_count} objects.") else: - report({'INFO'}, f"Renumbered {number_count} objects.") + report({"INFO"}, f"Renumbered {number_count} objects.") - return {'FINISHED'}, number_count + return {"FINISHED"}, number_count @staticmethod def assign_numbers(operator, settings, numbers_cache): @@ -744,8 +854,9 @@ def assign_numbers(operator, settings, numbers_cache): if settings.get("remove_toggle"): for obj in bpy.context.scene.objects: - if (settings.get("selected_toggle") and obj not in bpy.context.selected_objects) or \ - (settings.get("visible_toggle") and not obj.visible_get()): + if (settings.get("selected_toggle") and obj not in bpy.context.selected_objects) or ( + settings.get("visible_toggle") and not obj.visible_get() + ): element = tool.Ifc.get_entity(obj) if element is not None and element.is_a(LoadSelection.get_parent_type(settings)): count_diff = SaveNumber.remove_number(ifc_file, element, settings, numbers_cache) @@ -754,16 +865,18 @@ def assign_numbers(operator, settings, numbers_cache): objects = LoadSelection.load_selected_objects(settings) if not objects: - operator.report({'WARNING'}, f"No objects selected or available for numbering, removed {remove_count} existing numbers.") - return {'CANCELLED'} - + operator.report( + {"WARNING"}, f"No objects selected or available for numbering, removed {remove_count} existing numbers." + ) + return {"CANCELLED"} + selected_types = LoadSelection.get_selected_types(settings) possible_types = [tupl[0] for tupl in LoadSelection.possible_types] - + selected_elements = [] elements_locations = {} elements_dimensions = {} - for obj in objects: + for obj in objects: element = tool.Ifc.get_entity(obj) if element is None: continue @@ -773,19 +886,25 @@ def assign_numbers(operator, settings, numbers_cache): elements_dimensions[element] = ObjectGeometry.get_object_dimensions(obj) elif settings.get("remove_toggle") and element.is_a() in possible_types: remove_count += SaveNumber.remove_number(ifc_file, element, settings, numbers_cache) - + if not selected_elements: - operator.report({'WARNING'}, f"No elements selected or available for numbering, removed {remove_count} existing numbers.") + operator.report( + {"WARNING"}, + f"No elements selected or available for numbering, removed {remove_count} existing numbers.", + ) storeys = Storeys.get_storeys(settings) - res, _ = Numbering.number_elements(selected_elements, - ifc_file, settings, - elements_locations, - elements_dimensions, - storeys, - numbers_cache, - report = operator.report, - remove_count=remove_count) + res, _ = Numbering.number_elements( + selected_elements, + ifc_file, + settings, + elements_locations, + elements_dimensions, + storeys, + numbers_cache, + report=operator.report, + remove_count=remove_count, + ) if settings.get("check_duplicates_toggle"): numbers = [] @@ -795,8 +914,8 @@ def assign_numbers(operator, settings, numbers_cache): continue number = SaveNumber.get_number(element, settings, numbers_cache) if number in numbers: - operator.report({'WARNING'}, f"The model contains duplicate numbers") - return {'FINISHED'} + operator.report({"WARNING"}, f"The model contains duplicate numbers") + return {"FINISHED"} if number is not None: numbers.append(number) @@ -805,7 +924,7 @@ def assign_numbers(operator, settings, numbers_cache): def remove_numbers(self, settings, numbers_cache): """Remove numbers from selected objects""" ifc_file = tool.Ifc.get() - + remove_count = 0 objects = bpy.context.selected_objects if settings.get("selected_toggle") else bpy.context.scene.objects @@ -813,9 +932,9 @@ def remove_numbers(self, settings, numbers_cache): objects = [obj for obj in objects if obj.visible_get()] if not objects: - self.report({'WARNING'}, f"No objects selected or available for removal.") - return {'CANCELLED'} - + self.report({"WARNING"}, f"No objects selected or available for removal.") + return {"CANCELLED"} + for obj in objects: element = tool.Ifc.get_entity(obj) if element is not None and element.is_a(LoadSelection.get_parent_type(settings)): @@ -823,8 +942,8 @@ def remove_numbers(self, settings, numbers_cache): numbers_cache[get_id(element)] = None if remove_count == 0: - self.report({'WARNING'}, f"No elements selected or available for removal.") - return {'CANCELLED'} - - self.report({'INFO'}, f"Removed {remove_count} existing numbers.") - return {'FINISHED'} \ No newline at end of file + self.report({"WARNING"}, f"No elements selected or available for removal.") + return {"CANCELLED"} + + self.report({"INFO"}, f"Removed {remove_count} existing numbers.") + return {"FINISHED"} diff --git a/src/bonsai/bonsai/core/tool.py b/src/bonsai/bonsai/core/tool.py index b6a67c85323..52e30977cc9 100644 --- a/src/bonsai/bonsai/core/tool.py +++ b/src/bonsai/bonsai/core/tool.py @@ -610,7 +610,6 @@ def get_container(cls, element): pass @interface class Numbering: def get_numbering_props(cls): pass - def get_project(cls): pass @interface class Patch: diff --git a/src/bonsai/bonsai/tool/numbering.py b/src/bonsai/bonsai/tool/numbering.py index f74c439d8c1..be0fea24292 100644 --- a/src/bonsai/bonsai/tool/numbering.py +++ b/src/bonsai/bonsai/tool/numbering.py @@ -32,7 +32,6 @@ from __future__ import annotations import bpy -import ifcopenshell import bonsai.core.tool import bonsai.tool as tool from typing import TYPE_CHECKING @@ -41,42 +40,8 @@ from bonsai.bim.module.numbering.prop import BIMNumberingProperties -# There is always one class in each tool file, which implements the interface -# defined by `core/tool.py`. class Numbering(bonsai.core.tool.Numbering): @classmethod def get_numbering_props(cls) -> BIMNumberingProperties: assert (scene := bpy.context.scene) return scene.BIMNumberingProperties # pyright: ignore[reportAttributeAccessIssue] - - @classmethod - def clear_name_field(cls) -> None: - # In this concrete implementation, we see that "clear name field" - # actually translates to "set this Blender string property to empty - # string". In this case, it's pretty simple - but even simple scenarios - # like these are important to implement in the tool, as it makes the - # pseudocode easier to read in the core, and makes it easier to test - # implementations separately from control flow. It also makes it easy to - # refactor and share functions, where every tool function is captured by - # a function name that describes its intention. - props = cls.get_numbering_props() - props.name = "" - - @classmethod - def get_project(cls) -> ifcopenshell.entity_instance: - return tool.Ifc.get().by_type("IfcProject")[0] - - @classmethod - def hide_user_hints(cls) -> None: - props = cls.get_numbering_props() - props.show_hints = False - - @classmethod - def set_message(cls, message) -> None: - props = cls.get_numbering_props() - props.message = message - - @classmethod - def show_user_hints(cls) -> None: - props = cls.get_numbering_props() - props.show_hints = True From 46057c6311382e160aadf5142235407fc1fbe822 Mon Sep 17 00:00:00 2001 From: BjornFidder Date: Wed, 27 Aug 2025 15:09:41 +0200 Subject: [PATCH 6/9] Implement ruff fixes Simplified type extraction in NumberFormatting by removing unnecessary list and str conversions. Updated ObjectGeometry to streamline Vector creation for min and max corners, improving code readability. --- src/bonsai/bonsai/core/numbering.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/src/bonsai/bonsai/core/numbering.py b/src/bonsai/bonsai/core/numbering.py index f4c218cbf0d..703eeb1960c 100644 --- a/src/bonsai/bonsai/core/numbering.py +++ b/src/bonsai/bonsai/core/numbering.py @@ -358,11 +358,11 @@ def get_type_name(settings): if "All" in types: types.remove("All") if len(types) > 0: - return str(list(types)[0][3:]) + return next(iter(types))[3:] # If all selected, return type name of one of the selected types all_types = LoadSelection.possible_types if len(all_types) > 1: - return str(all_types[1][0][3:]) + return all_types[1][0][3:] # If none selected, return "Type" return "Type" @@ -492,8 +492,8 @@ def get_object_dimensions(obj): coords = [mat @ Vector(corner) for corner in obj.bound_box] # Compute min and max coordinates - min_corner = Vector((min(v[i] for v in coords) for i in range(3))) - max_corner = Vector((max(v[i] for v in coords) for i in range(3))) + min_corner = Vector(min(v[i] for v in coords) for i in range(3)) + max_corner = Vector(max(v[i] for v in coords) for i in range(3)) # Dimensions in global space dimensions = max_corner - min_corner From 2b42afc4e576edf3bb9e6bf8ad79324db449adbb Mon Sep 17 00:00:00 2001 From: BjornFidder Date: Wed, 27 Aug 2025 15:15:34 +0200 Subject: [PATCH 7/9] Remove unused project data from NumberingData Eliminated the 'project' key and related method from NumberingData, as it is no longer used when updating data after polling. This simplifies the data structure and removes unnecessary code. --- src/bonsai/bonsai/bim/module/numbering/data.py | 8 +------- 1 file changed, 1 insertion(+), 7 deletions(-) diff --git a/src/bonsai/bonsai/bim/module/numbering/data.py b/src/bonsai/bonsai/bim/module/numbering/data.py index 0160499d453..b458b419aab 100644 --- a/src/bonsai/bonsai/bim/module/numbering/data.py +++ b/src/bonsai/bonsai/bim/module/numbering/data.py @@ -32,7 +32,7 @@ def load(cls): cls.is_loaded = True cls.data["poll"] = cls.poll() if cls.data["poll"]: - cls.data.update({"has_project": cls.has_project(), "project": cls.project()}) + cls.data.update({"has_project": cls.has_project()}) @classmethod def poll(cls): @@ -41,9 +41,3 @@ def poll(cls): @classmethod def has_project(cls): return bool(tool.Ifc.get()) - - @classmethod - def project(cls): - ifc = tool.Ifc.get() - if ifc: - return ifc.by_type("IfcProject")[0] or None From c09079f3b70e401647d859e84d0e6e101e9d3637 Mon Sep 17 00:00:00 2001 From: BjornFidder Date: Wed, 27 Aug 2025 16:04:32 +0200 Subject: [PATCH 8/9] Move numbering logic from core to tool module Refactored numbering-related classes and functions from src/bonsai/bonsai/core/numbering.py to src/bonsai/bonsai/tool/numbering.py. Updated imports in operator, prop, and ui modules to use bonsai.tool.numbering. Adjusted workspace keymap properties and extended the Numbering interface in core/tool.py. This improves modularity and separation of concerns between core and tool logic. --- .../bonsai/bim/module/numbering/operator.py | 9 +- .../bonsai/bim/module/numbering/prop.py | 22 +- src/bonsai/bonsai/bim/module/numbering/ui.py | 2 +- .../bonsai/bim/module/numbering/workspace.py | 8 +- src/bonsai/bonsai/core/numbering.py | 932 ------------------ src/bonsai/bonsai/core/tool.py | 3 + src/bonsai/bonsai/tool/numbering.py | 931 ++++++++++++++++- 7 files changed, 959 insertions(+), 948 deletions(-) diff --git a/src/bonsai/bonsai/bim/module/numbering/operator.py b/src/bonsai/bonsai/bim/module/numbering/operator.py index 3ea9137b4a0..1321b4b071b 100644 --- a/src/bonsai/bonsai/bim/module/numbering/operator.py +++ b/src/bonsai/bonsai/bim/module/numbering/operator.py @@ -21,8 +21,7 @@ from bonsai.bim.ifc import IfcStore import json -import functools as ft -from bonsai.core.numbering import Settings, LoadSelection, SaveNumber, Numbering, get_id +from bonsai.tool.numbering import Numbering, Settings, LoadSelection, SaveNumber class UndoOperator: @@ -43,7 +42,7 @@ def execute_with_undo(operator, context, method): if settings.get("pset_name") == "Common": SaveNumber.get_pset_common_names(elements) - old_numbers = {get_id(element): SaveNumber.get_number(element, settings) for element in elements} + old_numbers = {SaveNumber.get_id(element): SaveNumber.get_number(element, settings) for element in elements} new_numbers = old_numbers.copy() result = method(operator, settings, new_numbers) @@ -64,7 +63,7 @@ def rollback(operator, data): rollback_count = 0 settings = Settings.to_dict(bpy.context.scene.BIMNumberingProperties) for element in ifc_file.by_type(LoadSelection.get_parent_type(settings)): - old_number = data["old_value"].get(get_id(element), None) + old_number = data["old_value"].get(SaveNumber.get_id(element), None) rollback_count += int( SaveNumber.save_number(ifc_file, element, old_number, settings, data["new_value"]) or 0 ) @@ -181,7 +180,7 @@ class ExportSettings(bpy.types.Operator): def execute(self, context): props = context.scene.BIMNumberingProperties with open(self.filepath, "w") as f: - json.dump(Settings.settings_dict(props), f) + json.dump(Settings.to_dict(props), f) self.report({"INFO"}, f"Exported settings to {self.filepath}") return {"FINISHED"} diff --git a/src/bonsai/bonsai/bim/module/numbering/prop.py b/src/bonsai/bonsai/bim/module/numbering/prop.py index faa6462bc54..ec4ed43bd12 100644 --- a/src/bonsai/bonsai/bim/module/numbering/prop.py +++ b/src/bonsai/bonsai/bim/module/numbering/prop.py @@ -1,10 +1,26 @@ -from typing import TYPE_CHECKING +# 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 +from typing import TYPE_CHECKING from bpy.types import PropertyGroup from bpy.props import IntProperty, StringProperty, EnumProperty, BoolProperty, IntVectorProperty -from bonsai.core.numbering import Settings, LoadSelection, NumberFormatting, NumberingSystems, SaveNumber, Storeys +from bonsai.tool.numbering import Settings, LoadSelection, NumberFormatting, NumberingSystems, SaveNumber, Storeys class BIMNumberingProperties(PropertyGroup): diff --git a/src/bonsai/bonsai/bim/module/numbering/ui.py b/src/bonsai/bonsai/bim/module/numbering/ui.py index 45db0e541bf..6fa09fb3aa9 100644 --- a/src/bonsai/bonsai/bim/module/numbering/ui.py +++ b/src/bonsai/bonsai/bim/module/numbering/ui.py @@ -18,7 +18,7 @@ from bpy.types import Panel import bonsai.tool as tool -from bonsai.core.numbering import NumberFormatting +from bonsai.tool.numbering import NumberFormatting from bonsai.bim.module.numbering.data import NumberingData diff --git a/src/bonsai/bonsai/bim/module/numbering/workspace.py b/src/bonsai/bonsai/bim/module/numbering/workspace.py index 436f6595be7..8db9785977a 100644 --- a/src/bonsai/bonsai/bim/module/numbering/workspace.py +++ b/src/bonsai/bonsai/bim/module/numbering/workspace.py @@ -16,13 +16,9 @@ # You should have received a copy of the GNU General Public License # along with Bonsai. If not, see . - import os import bonsai.tool as tool from bpy.types import WorkSpaceTool -import bpy.ops - -from functools import partial from bonsai.bim.module.numbering.data import NumberingData @@ -37,8 +33,8 @@ class NumberingTool(WorkSpaceTool): bl_icon = os.path.join(os.path.dirname(__file__), "ops.authoring.numbering") bl_widget = None bl_keymap = tool.Blender.get_default_selection_keypmap() + ( - ("bim.assign_numbers", {"type": "A", "value": "PRESS", "shift": True}, {"properties": [("hotkey", "S_A")]}), - ("bim.remove_numbers", {"type": "R", "value": "PRESS", "shift": True}, {"properties": [("hotkey", "S_R")]}), + ("bim.assign_numbers", {"type": "A", "value": "PRESS", "shift": True}, {}), + ("bim.remove_numbers", {"type": "R", "value": "PRESS", "shift": True}, {}), ) def draw_settings(context, layout, ws_tool): diff --git a/src/bonsai/bonsai/core/numbering.py b/src/bonsai/bonsai/core/numbering.py index 703eeb1960c..25cf3753b34 100644 --- a/src/bonsai/bonsai/core/numbering.py +++ b/src/bonsai/bonsai/core/numbering.py @@ -15,935 +15,3 @@ # # 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 - -import ifcopenshell.api as ifc_api -from ifcopenshell.util.element import get_pset -from ifcopenshell.util.pset import PsetQto - -from mathutils import Vector -import functools as ft -import numpy as np -import ifcopenshell.geom as geom -import ifcopenshell.util.shape as ifc_shape - -import string -import json - - -def get_id(element): - return getattr(element, "GlobalId", element.id()) - - -class SaveNumber: - - pset_names = [("Custom", "Custom Pset", "")] - pset_common_names = {} - ifc_file = None - pset_qto = None - - @staticmethod - def update_ifc_file(): - if (ifc_file := tool.Ifc.get()) != SaveNumber.ifc_file: - SaveNumber.ifc_file = ifc_file - SaveNumber.pset_qto = PsetQto(ifc_file.schema) - - @staticmethod - def get_number(element, settings, numbers_cache=None): - if element is None: - return None - if numbers_cache is None: - numbers_cache = {} - if get_id(element) in numbers_cache: - return numbers_cache[get_id(element)] - if settings.get("save_type") == "Attribute": - return getattr(element, SaveNumber.get_attribute_name(settings), None) - if settings.get("save_type") == "Pset": - pset_name = SaveNumber.get_pset_name(element, settings) - if pset := get_pset(element, pset_name): - return pset.get(settings.get("property_name")) - return None - - @staticmethod - def save_number(ifc_file, element, number, settings, numbers_cache=None): - if element is None: - return None - if numbers_cache is None: - numbers_cache = {} - if number == SaveNumber.get_number(element, settings, numbers_cache): - return 0 - if settings.get("save_type") == "Attribute": - attribute_name = SaveNumber.get_attribute_name(settings) - if not hasattr(element, attribute_name): - return None - if attribute_name == "Name" and number is None: - number = element.is_a().strip("Ifc") # Reset Name to name of type - setattr(element, attribute_name, number) - numbers_cache[get_id(element)] = number - return 1 - if settings.get("save_type") == "Pset": - pset_name = SaveNumber.get_pset_name(element, settings) - if not pset_name: - return None - if pset := get_pset(element, pset_name): - pset = ifc_file.by_id(pset["id"]) - else: - pset = ifc_api.run("pset.add_pset", ifc_file, product=element, name=pset_name) - ifc_api.run( - "pset.edit_pset", ifc_file, pset=pset, properties={settings["property_name"]: number}, should_purge=True - ) - if number is None and not pset.HasProperties: - ifc_api.run("pset.remove_pset", ifc_file, product=element, pset=pset) - numbers_cache[get_id(element)] = number - return 1 - return None - - @staticmethod - def remove_number(ifc_file, element, settings, numbers_cache=None): - count = SaveNumber.save_number(ifc_file, element, None, settings, numbers_cache) - return int(count or 0) - - def get_attribute_name(settings): - if settings.get("attribute_name") == "Other": - return settings.get("attribute_name_other") - return settings.get("attribute_name") - - @staticmethod - def get_pset_name(element, settings): - if settings.get("pset_name") == "Common": - ifc_type = element.is_a() - name = SaveNumber.pset_common_names.get(ifc_type, None) - return name - if settings.get("pset_name") == "Custom Pset": - return settings.get("custom_pset_name") - return settings.get("pset_name") - - @staticmethod - def update_pset_names(prop, context): - settings = Settings.to_dict(context.scene.BIMNumberingProperties) - pset_names_sets = [ - set(SaveNumber.pset_qto.get_applicable_names(ifc_type)) - for ifc_type in LoadSelection.get_selected_types(settings) - ] - intersection = set.intersection(*pset_names_sets) if pset_names_sets else set() - SaveNumber.pset_names = [ - ("Custom Pset", "Custom Pset", "Store in custom Pset with selected name"), - ("Common", "Pset_Common", "Store in Pset common of the type, e.g. Pset_WallCommon"), - ] + [(name, name, f"Store in Pset called {name}") for name in intersection] - - @staticmethod - def get_pset_common_names(elements): - SaveNumber.pset_common_names = {} - pset_qto = PsetQto(SaveNumber.ifc_file.schema) - for element in elements: - ifc_type = element.is_a() - if ifc_type in SaveNumber.pset_common_names: - continue - pset_names = pset_qto.get_applicable_names(ifc_type) - if (name_guess := "Pset_" + ifc_type.strip("Ifc") + "Common") in pset_names: - pset_common_name = name_guess - elif (name_guess := "Pset_" + ifc_type.strip("Ifc") + "TypeCommon") in pset_names: - pset_common_name = name_guess - elif common_names := [name for name in pset_names if "Common" in name]: - pset_common_name = common_names[0] - else: - pset_common_name = None - SaveNumber.pset_common_names[ifc_type] = pset_common_name - - -class LoadSelection: - - all_objects = [] - selected_objects = [] - possible_types = [] - - @staticmethod - def get_parent_type(settings): - """Get the parent type from the settings.""" - if settings.get("parent_type") == "Other": - return settings.get("parent_type_other") - return settings.get("parent_type") - - @staticmethod - def load_selected_objects(settings): - """Load the selected objects based on the current context.""" - objects = bpy.context.selected_objects if settings.get("selected_toggle") else bpy.context.scene.objects - if settings.get("visible_toggle"): - objects = [obj for obj in objects if obj.visible_get()] - return objects - - @staticmethod - def get_selected_types(settings): - """Get the selected IFC types from the settings, processing if All types are selected""" - selected_types = settings.get("selected_types", []) - if "All" in selected_types: - selected_types = [type_tuple[0] for type_tuple in LoadSelection.possible_types[1:]] - return selected_types - - @staticmethod - def load_possible_types(objects, parent_type): - """Load the available IFC types and their counts from the selected elements.""" - if not objects: - return [("All", "All", "element")], {"All": 0} - - ifc_types = [("All", "All", "element")] - seen_types = [] - number_counts = {"All": 0} - - for obj in objects: - element = tool.Ifc.get_entity(obj) - if element is None or not element.is_a(parent_type): - continue - ifc_type = element.is_a() # Starts with "Ifc", which we can strip by starting from index 3 - - if ifc_type not in seen_types: - seen_types.append(ifc_type) - ifc_types.append((ifc_type, ifc_type[3:], ifc_type[3:].lower())) # Store type as (id, name, name_lower) - number_counts[ifc_type] = 0 - - number_counts["All"] += 1 - number_counts[ifc_type] += 1 - - ifc_types.sort(key=lambda ifc_type: ifc_type[0]) - - return ifc_types, number_counts - - @staticmethod - def update_objects(prop, context): - settings = Settings.to_dict(context.scene.BIMNumberingProperties) - ifc_types, number_counts = LoadSelection.load_possible_types( - LoadSelection.selected_objects, LoadSelection.get_parent_type(settings) - ) - LoadSelection.possible_types = [(id, name + f": {number_counts[id]}", "") for (id, name, _) in ifc_types] - NumberFormatting.update_format_preview(prop, context) - SaveNumber.update_pset_names(prop, context) - SaveNumber.update_ifc_file() - - @staticmethod - def get_possible_types(prop, context): - """Return the list of available types for selection.""" - props = context.scene.BIMNumberingProperties - settings = {"selected_toggle": props.selected_toggle, "visible_toggle": props.visible_toggle} - all_objects = list(bpy.context.scene.objects) - objects = LoadSelection.load_selected_objects(settings) - if all_objects != LoadSelection.all_objects or objects != LoadSelection.selected_objects: - LoadSelection.all_objects = all_objects - LoadSelection.selected_objects = objects - LoadSelection.update_objects(prop, context) - return LoadSelection.possible_types - - -class Storeys: - - settings = {"save_type": "Pset", "pset_name": "Pset_Numbering", "property_name": "CustomStoreyNumber"} - - @staticmethod - def get_storeys(settings): - """Get all storeys from the current scene.""" - storeys = [] - storey_locations = {} - for obj in bpy.context.scene.objects: - element = tool.Ifc.get_entity(obj) - if element is not None and element.is_a("IfcBuildingStorey"): - storeys.append(element) - storey_locations[element] = ObjectGeometry.get_object_location(obj, settings) - storeys.sort( - key=ft.cmp_to_key( - lambda a, b: ObjectGeometry.cmp_within_precision( - storey_locations[a], storey_locations[b], settings, use_dir=False - ) - ) - ) - return storeys - - @staticmethod - def update_custom_storey(props, context): - storeys = Storeys.get_storeys(Settings.to_dict(context.scene.BIMNumberingProperties)) - storey = next((storey for storey in storeys if storey.Name == props.custom_storey), None) - number = SaveNumber.get_number(storey, Storeys.settings) - if number is None: # If the number is not set, use the index - number = storeys.index(storey) - props["_custom_storey_number"] = int(number) - - @staticmethod - def get_custom_storey_number(props): - return int(props.get("_custom_storey_number", 0)) - - @staticmethod - def set_custom_storey_number(props, value): - ifc_file = tool.Ifc.get() - storeys = Storeys.get_storeys(Settings.to_dict(props)) - storey = next((storey for storey in storeys if storey.Name == props.custom_storey), None) - index = storeys.index(storey) - if value == index: # If the value is the same as the index, remove the number - SaveNumber.save_number(ifc_file, storey, None, Storeys.settings) - else: - SaveNumber.save_number(ifc_file, storey, str(value), Storeys.settings) - props["_custom_storey_number"] = value - - @staticmethod - def get_storey_number(element, storeys, settings, storeys_numbers): - storey_number = None - if structure := getattr(element, "ContainedInStructure", None): - storey = getattr(structure[0], "RelatingStructure", None) - if storey and storeys_numbers: - storey_number = storeys_numbers.get(storey, None) - if storey and settings.get("storey_numbering") == "custom": - storey_number = SaveNumber.get_number(storey, Storeys.settings) - if storey_number is not None: - storey_number = int(storey_number) - if storey_number is None: - storey_number = storeys.index(storey) if storey in storeys else None - return storey_number - - -class NumberFormatting: - - format_preview = "" - - @staticmethod - def format_number(settings, number_values=(0, 0, None), max_number_values=(100, 100, 1), type_name=""): - """Return the formatted number for the given element, type and storey number""" - format = settings.get("format", None) - if format is None: - return format - if "{E}" in format: - format = format.replace( - "{E}", - NumberingSystems.to_numbering_string( - settings.get("initial_element_number", 0) + number_values[0], - settings.get("element_numbering"), - max_number_values[0], - ), - ) - if "{T}" in format: - format = format.replace( - "{T}", - NumberingSystems.to_numbering_string( - settings.get("initial_type_number", 0) + number_values[1], - settings.get("type_numbering"), - max_number_values[1], - ), - ) - if "{S}" in format: - if number_values[2] is not None: - format = format.replace( - "{S}", - NumberingSystems.to_numbering_string( - settings.get("initial_storey_number", 0) + number_values[2], - settings.get("storey_numbering"), - max_number_values[2], - ), - ) - else: - format = format.replace("{S}", "x") - if "[T]" in format and len(type_name) > 0: - format = format.replace("[T]", type_name[0]) - if "[TT]" in format and len(type_name) > 1: - format = format.replace("[TT]", "".join([c for c in type_name if c.isupper()])) - if "[TF]" in format: - format = format.replace("[TF]", type_name) - return format - - @staticmethod - def get_type_name(settings): - """Return type name used in preview, based on selected types""" - if not settings.get("selected_types"): - # If no types selected, return "Type" - return "Type" - # Get the type name of the selected type, excluding 'IfcElement' - types = settings.get("selected_types") - if "All" in types: - types.remove("All") - if len(types) > 0: - return next(iter(types))[3:] - # If all selected, return type name of one of the selected types - all_types = LoadSelection.possible_types - if len(all_types) > 1: - return all_types[1][0][3:] - # If none selected, return "Type" - return "Type" - - @staticmethod - def get_max_numbers(settings, type_name): - """Return number of selected elements used in preview, based on selected types""" - max_element, max_type, max_storey = 0, 0, 0 - if settings.get("storey_numbering") == "number_ext": - max_storey = len(Storeys.get_storeys(settings)) - if settings.get("element_numbering") == "number_ext" or settings.get("type_numbering") == "number_ext": - if not settings.get("selected_types"): - return max_element, max_type, max_storey - type_counts = { - type_tuple[0]: int("".join([c for c in type_tuple[1] if c.isdigit()])) - for type_tuple in LoadSelection.possible_types - } - if "All" in settings.get("selected_types"): - max_element = type_counts.get("All", 0) - else: - max_element = sum(type_counts.get(t, 0) for t in LoadSelection.get_selected_types(settings)) - max_type = type_counts.get("Ifc" + type_name, max_element) - return max_element, max_type, max_storey - - @staticmethod - def update_format_preview(prop, context): - settings = Settings.to_dict(context.scene.BIMNumberingProperties) - type_name = NumberFormatting.get_type_name(settings) - NumberFormatting.format_preview = NumberFormatting.format_number( - settings, (0, 0, 0), NumberFormatting.get_max_numbers(settings, type_name), type_name - ) - - -class NumberingSystems: - - @staticmethod - def to_number(i): - """Convert a number to a string.""" - if i < 0: - return "(" + str(-i) + ")" - return str(i) - - @staticmethod - def to_number_ext(i, length=2): - """Convert a number to a string with leading zeroes.""" - if i < 0: - return "(" + NumberingSystems.to_number_ext(-i, length) + ")" - res = str(i) - while len(res) < length: - res = "0" + res - return res - - @staticmethod - def to_letter(i, upper=False): - """Convert a number to a letter or sequence of letters.""" - if i == 0: - return "0" - if i < 0: - return "(" + NumberingSystems.to_letter(-i, upper) + ")" - - num2alphadict = dict(zip(range(1, 27), string.ascii_uppercase if upper else string.ascii_lowercase)) - res = "" - numloops = (i - 1) // 26 - - if numloops > 0: - res = res + NumberingSystems.to_letter(numloops, upper) - - remainder = i % 26 - if remainder == 0: - remainder += 26 - return res + num2alphadict[remainder] - - @staticmethod - def get_numberings(): - return { - "number": NumberingSystems.to_number, - "number_ext": NumberingSystems.to_number_ext, - "lower_letter": NumberingSystems.to_letter, - "upper_letter": lambda x: NumberingSystems.to_letter(x, True), - } - - def to_numbering_string(i, numbering_system, max_number): - """Convert a number to a string based on the numbering system.""" - if numbering_system == "number_ext": - # Determine the length based on the maximum number - length = len(str(max_number)) - return NumberingSystems.to_number_ext(i, length) - if numbering_system == "custom": - return NumberingSystems.to_number(i) - return NumberingSystems.get_numberings()[numbering_system](i) - - def get_numbering_preview(numbering_system, initial): - """Get a preview of the numbering string for a given number and type.""" - numbers = [NumberingSystems.to_numbering_string(i, numbering_system, 10) for i in range(initial, initial + 3)] - return "{0}, {1}, {2}, ...".format(*numbers) - - -class ObjectGeometry: - @staticmethod - def get_object_location(obj, settings): - """Get the location of a Blender object.""" - mat = obj.matrix_world - bbox_vectors = [mat @ Vector(b) for b in obj.bound_box] - - if settings.get("location_type", "CENTER") == "CENTER": - return 0.125 * sum(bbox_vectors, Vector()) - - elif settings.get("location_type") == "BOUNDING_BOX": - bbox_vector = Vector((0, 0, 0)) - # Determine the coordinates based on the direction and axis order - direction = ( - int(settings.get("x_direction")), - int(settings.get("y_direction")), - int(settings.get("z_direction")), - ) - for i in range(3): - if direction[i] == -1: - bbox_vector[i] = max(v[i] for v in bbox_vectors) - else: - bbox_vector[i] = min(v[i] for v in bbox_vectors) - return bbox_vector - - @staticmethod - def get_object_dimensions(obj): - """Get the dimensions of a Blender object.""" - # Get the object's bounding box corners in world space - mat = obj.matrix_world - coords = [mat @ Vector(corner) for corner in obj.bound_box] - - # Compute min and max coordinates - min_corner = Vector(min(v[i] for v in coords) for i in range(3)) - max_corner = Vector(max(v[i] for v in coords) for i in range(3)) - - # Dimensions in global space - dimensions = max_corner - min_corner - return dimensions - - @staticmethod - def cmp_within_precision(a, b, settings, use_dir=True): - """Compare two vectors within a given precision.""" - direction = ( - ( - int(settings.get("x_direction", 1)), - int(settings.get("y_direction", 1)), - int(settings.get("z_direction", 1)), - ) - if use_dir - else (1, 1, 1) - ) - for axis in settings.get("axis_order", "XYZ"): - idx = "XYZ".index(axis) - diff = (a[idx] - b[idx]) * direction[idx] - if 1000 * abs(diff) > settings.get("precision", [0, 0, 0])[idx]: - return 1 if diff > 0 else -1 - return 0 - - -class ElementGeometry: - @staticmethod - def get_element_location(element, settings): - """Get the location of an IFC element.""" - geom_settings = geom.settings() - geom_settings.set("use-world-coords", True) - shape = geom.create_shape(geom_settings, element) - - verts = ifc_shape.get_shape_vertices(shape, shape.geometry) - if settings.get("location_type") == "CENTER": - return np.mean(verts, axis=0) - - elif settings.get("location_type") == "BOUNDING_BOX": - direction = ( - int(settings.get("x_direction", 1)), - int(settings.get("y_direction", 1)), - int(settings.get("z_direction", 1)), - ) - bbox_min = np.min(verts, axis=0) - bbox_max = np.max(verts, axis=0) - bbox_vector = np.zeros(3) - for i in range(3): - if direction[i] == -1: - bbox_vector[i] = bbox_max[i] - else: - bbox_vector[i] = bbox_min[i] - return bbox_vector - - @staticmethod - def get_element_dimensions(element): - """Get the dimensions of an IFC element.""" - geom_settings = geom.settings() - geom_settings.set("use-world-coords", True) - shape = geom.create_shape(geom_settings, element) - - verts = ifc_shape.get_shape_vertices(shape, shape.geometry) - bbox_min = np.min(verts, axis=0) - bbox_max = np.max(verts, axis=0) - return bbox_max - bbox_min - - -class Settings: - - pset_name = "Pset_NumberingSettings" - - settings_names = None - - def import_settings(filepath): - """Import settings from a JSON file, e.g. as exported from the UI""" - with open(filepath, "r") as file: - settings = json.load(file) - return settings - - def default_settings(): - """ "Return a default dictionary of settings for numbering elements.""" - return { - "x_direction": 1, - "y_direction": 1, - "z_direction": 1, - "axis_order": "ZYX", - "location_type": "CENTER", - "precision": (1, 1, 1), - "initial_element_number": 1, - "initial_type_number": 1, - "initial_storey_number": 0, - "element_numbering": "number", - "type_numbering": "number", - "storey_numbering": "number", - "format": "E{E}S{S}[T]{T}", - "save_type": "Attribute", - "attribute_name": "Tag", - "pset_name": "Common", - "custom_pset_name": "Pset_Numbering", - "property_name": "Number", - } - - @staticmethod - def to_dict(props): - """Convert the properties to a dictionary for saving.""" - return { - "selected_toggle": props.selected_toggle, - "visible_toggle": props.visible_toggle, - "parent_type": props.parent_type, - "parent_type_other": props.parent_type_other, - "selected_types": list(props.selected_types), - "x_direction": props.x_direction, - "y_direction": props.y_direction, - "z_direction": props.z_direction, - "axis_order": props.axis_order, - "location_type": props.location_type, - "precision": (props.precision[0], props.precision[1], props.precision[2]), - "initial_element_number": props.initial_element_number, - "initial_type_number": props.initial_type_number, - "initial_storey_number": props.initial_storey_number, - "element_numbering": props.element_numbering, - "type_numbering": props.type_numbering, - "storey_numbering": props.storey_numbering, - "format": props.format, - "save_type": props.save_type, - "attribute_name": props.attribute_name, - "attribute_name_other": props.attribute_name_other, - "pset_name": props.pset_name, - "custom_pset_name": props.custom_pset_name, - "property_name": props.property_name, - "remove_toggle": props.remove_toggle, - "check_duplicates_toggle": props.check_duplicates_toggle, - } - - @staticmethod - def save_settings(operator, props, ifc_file): - """Save the numbering settings to the IFC file.""" - # Save multiple settings by name in a dictionary - project = ifc_file.by_type("IfcProject")[0] - settings_name = props.settings_name.strip() - if not settings_name: - operator.report({"ERROR"}, "Please enter a name for the settings.") - return {"CANCELLED"} - if pset_settings := get_pset(project, Settings.pset_name): - pset_settings = ifc_file.by_id(pset_settings["id"]) - else: - pset_settings = ifc_api.run("pset.add_pset", ifc_file, product=project, name=Settings.pset_name) - if not pset_settings: - operator.report({"ERROR"}, "Could not create property set") - return {"CANCELLED"} - ifc_api.run( - "pset.edit_pset", - ifc_file, - pset=pset_settings, - properties={settings_name: json.dumps(Settings.to_dict(props))}, - ) - Settings.settings_names.add(settings_name) - operator.report({"INFO"}, f"Saved settings '{settings_name}' to IFCProject element") - return {"FINISHED"} - - @staticmethod - def read_settings(operator, settings, props): - for key, value in settings.items(): - if key == "selected_types": - possible_type_names = [t[0] for t in LoadSelection.possible_types] - value = set([type_name for type_name in value if type_name in possible_type_names]) - try: - setattr(props, key, value) - except Exception as e: - operator.report({"ERROR"}, f"Failed to set property {key} to {value}. Error: {e}") - - @staticmethod - def get_settings_names(): - ifc_file = tool.Ifc.get() - if Settings.settings_names is None: - if pset := get_pset(ifc_file.by_type("IfcProject")[0], Settings.pset_name): - names = set(pset.keys()) - names.remove("id") - else: - names = set() - Settings.settings_names = names - return Settings.settings_names - - @staticmethod - def load_settings(operator, props, ifc_file): - # Load selected settings by name - settings_name = props.saved_settings - if settings_name == "NONE": - operator.report({"WARNING"}, "No saved settings to load.") - return {"CANCELLED"} - if pset_settings := get_pset(ifc_file.by_type("IfcProject")[0], Settings.pset_name): - settings = pset_settings.get(settings_name, None) - if settings is None: - operator.report({"WARNING"}, f"Settings '{settings_name}' not found.") - return {"CANCELLED"} - settings = json.loads(settings) - Settings.read_settings(operator, settings, props) - operator.report({"INFO"}, f"Loaded settings '{settings_name}' from IFCProject element") - return {"FINISHED"} - else: - operator.report({"WARNING"}, "No settings found") - return {"CANCELLED"} - - @staticmethod - def delete_settings(operator, props): - ifc_file = tool.Ifc.get() - settings_name = props.saved_settings - if settings_name == "NONE": - operator.report({"WARNING"}, "No saved settings to delete.") - return {"CANCELLED"} - if pset_settings := get_pset(ifc_file.by_type("IfcProject")[0], Settings.pset_name): - if settings_name in pset_settings: - pset_settings = ifc_file.by_id(pset_settings["id"]) - ifc_api.run( - "pset.edit_pset", ifc_file, pset=pset_settings, properties={settings_name: None}, should_purge=True - ) - Settings.settings_names.remove(settings_name) - operator.report({"INFO"}, f"Deleted settings '{settings_name}' from IFCProject element") - - if not pset_settings.HasProperties: - ifc_api.run( - "pset.remove_pset", ifc_file, product=ifc_file.by_type("IfcProject")[0], pset=pset_settings - ) - return {"FINISHED"} - else: - operator.report({"WARNING"}, f"Settings '{settings_name}' not found.") - return {"CANCELLED"} - else: - operator.report({"WARNING"}, "No settings found") - return {"CANCELLED"} - - @staticmethod - def clear_settings(operator, props): - ifc_file = tool.Ifc.get() - project = ifc_file.by_type("IfcProject")[0] - if pset_settings := get_pset(project, Settings.pset_name): - pset_settings = ifc_file.by_id(pset_settings["id"]) - ifc_api.run("pset.remove_pset", ifc_file, product=project, pset=pset_settings) - operator.report({"INFO"}, f"Cleared settings from IFCProject element") - Settings.settings_names = set() - return {"FINISHED"} - else: - operator.report({"WARNING"}, "No settings found") - return {"CANCELLED"} - - -class Numbering: - - @staticmethod - def number_elements( - elements, - ifc_file, - settings, - elements_locations=None, - elements_dimensions=None, - storeys=None, - numbers_cache={}, - storeys_numbers={}, - report=None, - remove_count=None, - ): - """Number elements in the IFC file with the provided settings. If element locations or dimensions are specified, these are used for sorting. - Providing numbers_cache, a dictionary with element-> currently saved number, speeds up execution. - If storeys_numbers is provided, as a dictionary storey->number, this is used for assigning storey numbers.""" - if report is None: - - def report(report_type, message): - if report_type == {"INFO"}: - print("INFO: ", message) - if report_type == {"WARNING"}: - raise Exception(message) - - if storeys is None: - storeys = [] - - number_count = 0 - - if elements_dimensions: - elements.sort( - key=ft.cmp_to_key( - lambda a, b: ObjectGeometry.cmp_within_precision( - elements_dimensions[a], elements_dimensions[b], settings, use_dir=False - ) - ) - ) - if elements_locations: - elements.sort( - key=ft.cmp_to_key( - lambda a, b: ObjectGeometry.cmp_within_precision( - elements_locations[a], elements_locations[b], settings - ) - ) - ) - - selected_types = LoadSelection.get_selected_types(settings) - - if not selected_types: - selected_types = list(set(element.is_a() for element in elements)) - - elements_by_type = [ - [element for element in elements if element.is_a() == ifc_type] for ifc_type in selected_types - ] - - failed_types = set() - for element_number, element in enumerate(elements): - - type_index = selected_types.index(element.is_a()) - type_elements = elements_by_type[type_index] - type_number = type_elements.index(element) - type_name = selected_types[type_index][3:] - - if storeys: - storey_number = Storeys.get_storey_number(element, storeys, settings, storeys_numbers) - if storey_number is None and "{S}" in settings.get("format"): - if report is not None: - report( - {"WARNING"}, - f"Element {getattr(element, 'Name', '')} of type {element.is_a()} with ID {get_id(element)} is not contained in any storey.", - ) - else: - raise Exception( - f"Element {getattr(element, 'Name', '')} of type {element.is_a()} with ID {get_id(element)} is not contained in any storey." - ) - else: - storey_number = None - - number = NumberFormatting.format_number( - settings, - (element_number, type_number, storey_number), - (len(elements), len(type_elements), len(storeys)), - type_name, - ) - count = SaveNumber.save_number(ifc_file, element, number, settings, numbers_cache) - if count is None: - report( - {"WARNING"}, - f"Failed to save number for element {getattr(element, 'Name', '')} of type {element.is_a()} with ID {get_id(element)}.", - ) - failed_types.add(element.is_a()) - else: - number_count += count - - if failed_types: - report({"WARNING"}, f"Failed to renumber the following types: {failed_types}") - - if settings.get("remove_toggle") and remove_count is not None: - report({"INFO"}, f"Renumbered {number_count} objects, removed number from {remove_count} objects.") - else: - report({"INFO"}, f"Renumbered {number_count} objects.") - - return {"FINISHED"}, number_count - - @staticmethod - def assign_numbers(operator, settings, numbers_cache): - """Assign numbers to selected objects based on their IFC type and location.""" - ifc_file = tool.Ifc.get() - - remove_count = 0 - - if settings.get("remove_toggle"): - for obj in bpy.context.scene.objects: - if (settings.get("selected_toggle") and obj not in bpy.context.selected_objects) or ( - settings.get("visible_toggle") and not obj.visible_get() - ): - element = tool.Ifc.get_entity(obj) - if element is not None and element.is_a(LoadSelection.get_parent_type(settings)): - count_diff = SaveNumber.remove_number(ifc_file, element, settings, numbers_cache) - remove_count += count_diff - - objects = LoadSelection.load_selected_objects(settings) - - if not objects: - operator.report( - {"WARNING"}, f"No objects selected or available for numbering, removed {remove_count} existing numbers." - ) - return {"CANCELLED"} - - selected_types = LoadSelection.get_selected_types(settings) - possible_types = [tupl[0] for tupl in LoadSelection.possible_types] - - selected_elements = [] - elements_locations = {} - elements_dimensions = {} - for obj in objects: - element = tool.Ifc.get_entity(obj) - if element is None: - continue - if element.is_a() in selected_types: - selected_elements.append(element) - elements_locations[element] = ObjectGeometry.get_object_location(obj, settings) - elements_dimensions[element] = ObjectGeometry.get_object_dimensions(obj) - elif settings.get("remove_toggle") and element.is_a() in possible_types: - remove_count += SaveNumber.remove_number(ifc_file, element, settings, numbers_cache) - - if not selected_elements: - operator.report( - {"WARNING"}, - f"No elements selected or available for numbering, removed {remove_count} existing numbers.", - ) - - storeys = Storeys.get_storeys(settings) - res, _ = Numbering.number_elements( - selected_elements, - ifc_file, - settings, - elements_locations, - elements_dimensions, - storeys, - numbers_cache, - report=operator.report, - remove_count=remove_count, - ) - - if settings.get("check_duplicates_toggle"): - numbers = [] - for obj in bpy.context.scene.objects: - element = tool.Ifc.get_entity(obj) - if element is None or not element.is_a(LoadSelection.get_parent_type(settings)): - continue - number = SaveNumber.get_number(element, settings, numbers_cache) - if number in numbers: - operator.report({"WARNING"}, f"The model contains duplicate numbers") - return {"FINISHED"} - if number is not None: - numbers.append(number) - - return res - - def remove_numbers(self, settings, numbers_cache): - """Remove numbers from selected objects""" - ifc_file = tool.Ifc.get() - - remove_count = 0 - - objects = bpy.context.selected_objects if settings.get("selected_toggle") else bpy.context.scene.objects - if settings.get("visible_toggle"): - objects = [obj for obj in objects if obj.visible_get()] - - if not objects: - self.report({"WARNING"}, f"No objects selected or available for removal.") - return {"CANCELLED"} - - for obj in objects: - element = tool.Ifc.get_entity(obj) - if element is not None and element.is_a(LoadSelection.get_parent_type(settings)): - remove_count += SaveNumber.remove_number(ifc_file, element, settings, numbers_cache) - numbers_cache[get_id(element)] = None - - if remove_count == 0: - self.report({"WARNING"}, f"No elements selected or available for removal.") - return {"CANCELLED"} - - self.report({"INFO"}, f"Removed {remove_count} existing numbers.") - return {"FINISHED"} diff --git a/src/bonsai/bonsai/core/tool.py b/src/bonsai/bonsai/core/tool.py index 52e30977cc9..30cdc8b0224 100644 --- a/src/bonsai/bonsai/core/tool.py +++ b/src/bonsai/bonsai/core/tool.py @@ -610,6 +610,9 @@ def get_container(cls, element): pass @interface class Numbering: def get_numbering_props(cls): pass + def number_elements(elements, ifc_file, settings, elements_locations=None, elements_dimensions=None, storeys=None, numbers_cache={}, storeys_numbers={}, report=None, remove_count=None): pass + def assign_numbers(operator, settings, numbers_cache): pass + def remove_numbers(operator, settings, numbers_cache): pass @interface class Patch: diff --git a/src/bonsai/bonsai/tool/numbering.py b/src/bonsai/bonsai/tool/numbering.py index be0fea24292..1146711eebd 100644 --- a/src/bonsai/bonsai/tool/numbering.py +++ b/src/bonsai/bonsai/tool/numbering.py @@ -31,17 +31,946 @@ from __future__ import annotations + import bpy -import bonsai.core.tool import bonsai.tool as tool +import bonsai.core.tool + +import ifcopenshell.api as ifc_api +from ifcopenshell.util.element import get_pset +from ifcopenshell.util.pset import PsetQto + +from mathutils import Vector +import functools as ft +import numpy as np +import ifcopenshell.geom as geom +import ifcopenshell.util.shape as ifc_shape + +import string +import json + from typing import TYPE_CHECKING if TYPE_CHECKING: from bonsai.bim.module.numbering.prop import BIMNumberingProperties +class SaveNumber: + + pset_names = [("Custom", "Custom Pset", "")] + pset_common_names = {} + ifc_file = None + pset_qto = None + + @staticmethod + def get_id(element): + return getattr(element, "GlobalId", element.id()) + + @staticmethod + def update_ifc_file(): + if (ifc_file := tool.Ifc.get()) != SaveNumber.ifc_file: + SaveNumber.ifc_file = ifc_file + SaveNumber.pset_qto = PsetQto(ifc_file.schema) + + @staticmethod + def get_number(element, settings, numbers_cache=None): + if element is None: + return None + if numbers_cache is None: + numbers_cache = {} + if SaveNumber.get_id(element) in numbers_cache: + return numbers_cache[SaveNumber.get_id(element)] + if settings.get("save_type") == "Attribute": + return getattr(element, SaveNumber.get_attribute_name(settings), None) + if settings.get("save_type") == "Pset": + pset_name = SaveNumber.get_pset_name(element, settings) + if pset := get_pset(element, pset_name): + return pset.get(settings.get("property_name")) + return None + + @staticmethod + def save_number(ifc_file, element, number, settings, numbers_cache=None): + if element is None: + return None + if numbers_cache is None: + numbers_cache = {} + if number == SaveNumber.get_number(element, settings, numbers_cache): + return 0 + if settings.get("save_type") == "Attribute": + attribute_name = SaveNumber.get_attribute_name(settings) + if not hasattr(element, attribute_name): + return None + if attribute_name == "Name" and number is None: + number = element.is_a().strip("Ifc") # Reset Name to name of type + setattr(element, attribute_name, number) + numbers_cache[SaveNumber.get_id(element)] = number + return 1 + if settings.get("save_type") == "Pset": + pset_name = SaveNumber.get_pset_name(element, settings) + if not pset_name: + return None + if pset := get_pset(element, pset_name): + pset = ifc_file.by_id(pset["id"]) + else: + pset = ifc_api.run("pset.add_pset", ifc_file, product=element, name=pset_name) + ifc_api.run( + "pset.edit_pset", ifc_file, pset=pset, properties={settings["property_name"]: number}, should_purge=True + ) + if number is None and not pset.HasProperties: + ifc_api.run("pset.remove_pset", ifc_file, product=element, pset=pset) + numbers_cache[SaveNumber.get_id(element)] = number + return 1 + return None + + @staticmethod + def remove_number(ifc_file, element, settings, numbers_cache=None): + count = SaveNumber.save_number(ifc_file, element, None, settings, numbers_cache) + return int(count or 0) + + def get_attribute_name(settings): + if settings.get("attribute_name") == "Other": + return settings.get("attribute_name_other") + return settings.get("attribute_name") + + @staticmethod + def get_pset_name(element, settings): + if settings.get("pset_name") == "Common": + ifc_type = element.is_a() + name = SaveNumber.pset_common_names.get(ifc_type, None) + return name + if settings.get("pset_name") == "Custom Pset": + return settings.get("custom_pset_name") + return settings.get("pset_name") + + @staticmethod + def update_pset_names(prop, context): + settings = Settings.to_dict(context.scene.BIMNumberingProperties) + pset_names_sets = [ + set(SaveNumber.pset_qto.get_applicable_names(ifc_type)) + for ifc_type in LoadSelection.get_selected_types(settings) + ] + intersection = set.intersection(*pset_names_sets) if pset_names_sets else set() + SaveNumber.pset_names = [ + ("Custom Pset", "Custom Pset", "Store in custom Pset with selected name"), + ("Common", "Pset_Common", "Store in Pset common of the type, e.g. Pset_WallCommon"), + ] + [(name, name, f"Store in Pset called {name}") for name in intersection] + + @staticmethod + def get_pset_common_names(elements): + SaveNumber.pset_common_names = {} + pset_qto = PsetQto(SaveNumber.ifc_file.schema) + for element in elements: + ifc_type = element.is_a() + if ifc_type in SaveNumber.pset_common_names: + continue + pset_names = pset_qto.get_applicable_names(ifc_type) + if (name_guess := "Pset_" + ifc_type.strip("Ifc") + "Common") in pset_names: + pset_common_name = name_guess + elif (name_guess := "Pset_" + ifc_type.strip("Ifc") + "TypeCommon") in pset_names: + pset_common_name = name_guess + elif common_names := [name for name in pset_names if "Common" in name]: + pset_common_name = common_names[0] + else: + pset_common_name = None + SaveNumber.pset_common_names[ifc_type] = pset_common_name + + +class LoadSelection: + + all_objects = [] + selected_objects = [] + possible_types = [] + + @staticmethod + def get_parent_type(settings): + """Get the parent type from the settings.""" + if settings.get("parent_type") == "Other": + return settings.get("parent_type_other") + return settings.get("parent_type") + + @staticmethod + def load_selected_objects(settings): + """Load the selected objects based on the current context.""" + objects = bpy.context.selected_objects if settings.get("selected_toggle") else bpy.context.scene.objects + if settings.get("visible_toggle"): + objects = [obj for obj in objects if obj.visible_get()] + return objects + + @staticmethod + def get_selected_types(settings): + """Get the selected IFC types from the settings, processing if All types are selected""" + selected_types = settings.get("selected_types", []) + if "All" in selected_types: + selected_types = [type_tuple[0] for type_tuple in LoadSelection.possible_types[1:]] + return selected_types + + @staticmethod + def load_possible_types(objects, parent_type): + """Load the available IFC types and their counts from the selected elements.""" + if not objects: + return [("All", "All", "element")], {"All": 0} + + ifc_types = [("All", "All", "element")] + seen_types = [] + number_counts = {"All": 0} + + for obj in objects: + element = tool.Ifc.get_entity(obj) + if element is None or not element.is_a(parent_type): + continue + ifc_type = element.is_a() # Starts with "Ifc", which we can strip by starting from index 3 + + if ifc_type not in seen_types: + seen_types.append(ifc_type) + ifc_types.append((ifc_type, ifc_type[3:], ifc_type[3:].lower())) # Store type as (id, name, name_lower) + number_counts[ifc_type] = 0 + + number_counts["All"] += 1 + number_counts[ifc_type] += 1 + + ifc_types.sort(key=lambda ifc_type: ifc_type[0]) + + return ifc_types, number_counts + + @staticmethod + def update_objects(prop, context): + settings = Settings.to_dict(context.scene.BIMNumberingProperties) + ifc_types, number_counts = LoadSelection.load_possible_types( + LoadSelection.selected_objects, LoadSelection.get_parent_type(settings) + ) + LoadSelection.possible_types = [(id, name + f": {number_counts[id]}", "") for (id, name, _) in ifc_types] + NumberFormatting.update_format_preview(prop, context) + SaveNumber.update_pset_names(prop, context) + SaveNumber.update_ifc_file() + + @staticmethod + def get_possible_types(prop, context): + """Return the list of available types for selection.""" + props = context.scene.BIMNumberingProperties + settings = {"selected_toggle": props.selected_toggle, "visible_toggle": props.visible_toggle} + all_objects = list(bpy.context.scene.objects) + objects = LoadSelection.load_selected_objects(settings) + if all_objects != LoadSelection.all_objects or objects != LoadSelection.selected_objects: + LoadSelection.all_objects = all_objects + LoadSelection.selected_objects = objects + LoadSelection.update_objects(prop, context) + return LoadSelection.possible_types + + +class Storeys: + + settings = {"save_type": "Pset", "pset_name": "Pset_Numbering", "property_name": "CustomStoreyNumber"} + + @staticmethod + def get_storeys(settings): + """Get all storeys from the current scene.""" + storeys = [] + storey_locations = {} + for obj in bpy.context.scene.objects: + element = tool.Ifc.get_entity(obj) + if element is not None and element.is_a("IfcBuildingStorey"): + storeys.append(element) + storey_locations[element] = ObjectGeometry.get_object_location(obj, settings) + storeys.sort( + key=ft.cmp_to_key( + lambda a, b: ObjectGeometry.cmp_within_precision( + storey_locations[a], storey_locations[b], settings, use_dir=False + ) + ) + ) + return storeys + + @staticmethod + def update_custom_storey(props, context): + storeys = Storeys.get_storeys(Settings.to_dict(context.scene.BIMNumberingProperties)) + storey = next((storey for storey in storeys if storey.Name == props.custom_storey), None) + number = SaveNumber.get_number(storey, Storeys.settings) + if number is None: # If the number is not set, use the index + number = storeys.index(storey) + props["_custom_storey_number"] = int(number) + + @staticmethod + def get_custom_storey_number(props): + return int(props.get("_custom_storey_number", 0)) + + @staticmethod + def set_custom_storey_number(props, value): + ifc_file = tool.Ifc.get() + storeys = Storeys.get_storeys(Settings.to_dict(props)) + storey = next((storey for storey in storeys if storey.Name == props.custom_storey), None) + index = storeys.index(storey) + if value == index: # If the value is the same as the index, remove the number + SaveNumber.save_number(ifc_file, storey, None, Storeys.settings) + else: + SaveNumber.save_number(ifc_file, storey, str(value), Storeys.settings) + props["_custom_storey_number"] = value + + @staticmethod + def get_storey_number(element, storeys, settings, storeys_numbers): + storey_number = None + if structure := getattr(element, "ContainedInStructure", None): + storey = getattr(structure[0], "RelatingStructure", None) + if storey and storeys_numbers: + storey_number = storeys_numbers.get(storey, None) + if storey and settings.get("storey_numbering") == "custom": + storey_number = SaveNumber.get_number(storey, Storeys.settings) + if storey_number is not None: + storey_number = int(storey_number) + if storey_number is None: + storey_number = storeys.index(storey) if storey in storeys else None + return storey_number + + +class NumberFormatting: + + format_preview = "" + + @staticmethod + def format_number(settings, number_values=(0, 0, None), max_number_values=(100, 100, 1), type_name=""): + """Return the formatted number for the given element, type and storey number""" + format = settings.get("format", None) + if format is None: + return format + if "{E}" in format: + format = format.replace( + "{E}", + NumberingSystems.to_numbering_string( + settings.get("initial_element_number", 0) + number_values[0], + settings.get("element_numbering"), + max_number_values[0], + ), + ) + if "{T}" in format: + format = format.replace( + "{T}", + NumberingSystems.to_numbering_string( + settings.get("initial_type_number", 0) + number_values[1], + settings.get("type_numbering"), + max_number_values[1], + ), + ) + if "{S}" in format: + if number_values[2] is not None: + format = format.replace( + "{S}", + NumberingSystems.to_numbering_string( + settings.get("initial_storey_number", 0) + number_values[2], + settings.get("storey_numbering"), + max_number_values[2], + ), + ) + else: + format = format.replace("{S}", "x") + if "[T]" in format and len(type_name) > 0: + format = format.replace("[T]", type_name[0]) + if "[TT]" in format and len(type_name) > 1: + format = format.replace("[TT]", "".join([c for c in type_name if c.isupper()])) + if "[TF]" in format: + format = format.replace("[TF]", type_name) + return format + + @staticmethod + def get_type_name(settings): + """Return type name used in preview, based on selected types""" + if not settings.get("selected_types"): + # If no types selected, return "Type" + return "Type" + # Get the type name of the selected type, excluding 'IfcElement' + types = settings.get("selected_types") + if "All" in types: + types.remove("All") + if len(types) > 0: + return next(iter(types))[3:] + # If all selected, return type name of one of the selected types + all_types = LoadSelection.possible_types + if len(all_types) > 1: + return all_types[1][0][3:] + # If none selected, return "Type" + return "Type" + + @staticmethod + def get_max_numbers(settings, type_name): + """Return number of selected elements used in preview, based on selected types""" + max_element, max_type, max_storey = 0, 0, 0 + if settings.get("storey_numbering") == "number_ext": + max_storey = len(Storeys.get_storeys(settings)) + if settings.get("element_numbering") == "number_ext" or settings.get("type_numbering") == "number_ext": + if not settings.get("selected_types"): + return max_element, max_type, max_storey + type_counts = { + type_tuple[0]: int("".join([c for c in type_tuple[1] if c.isdigit()])) + for type_tuple in LoadSelection.possible_types + } + if "All" in settings.get("selected_types"): + max_element = type_counts.get("All", 0) + else: + max_element = sum(type_counts.get(t, 0) for t in LoadSelection.get_selected_types(settings)) + max_type = type_counts.get("Ifc" + type_name, max_element) + return max_element, max_type, max_storey + + @staticmethod + def update_format_preview(prop, context): + settings = Settings.to_dict(context.scene.BIMNumberingProperties) + type_name = NumberFormatting.get_type_name(settings) + NumberFormatting.format_preview = NumberFormatting.format_number( + settings, (0, 0, 0), NumberFormatting.get_max_numbers(settings, type_name), type_name + ) + + +class NumberingSystems: + + @staticmethod + def to_number(i): + """Convert a number to a string.""" + if i < 0: + return "(" + str(-i) + ")" + return str(i) + + @staticmethod + def to_number_ext(i, length=2): + """Convert a number to a string with leading zeroes.""" + if i < 0: + return "(" + NumberingSystems.to_number_ext(-i, length) + ")" + res = str(i) + while len(res) < length: + res = "0" + res + return res + + @staticmethod + def to_letter(i, upper=False): + """Convert a number to a letter or sequence of letters.""" + if i == 0: + return "0" + if i < 0: + return "(" + NumberingSystems.to_letter(-i, upper) + ")" + + num2alphadict = dict(zip(range(1, 27), string.ascii_uppercase if upper else string.ascii_lowercase)) + res = "" + numloops = (i - 1) // 26 + + if numloops > 0: + res = res + NumberingSystems.to_letter(numloops, upper) + + remainder = i % 26 + if remainder == 0: + remainder += 26 + return res + num2alphadict[remainder] + + @staticmethod + def get_numberings(): + return { + "number": NumberingSystems.to_number, + "number_ext": NumberingSystems.to_number_ext, + "lower_letter": NumberingSystems.to_letter, + "upper_letter": lambda x: NumberingSystems.to_letter(x, True), + } + + def to_numbering_string(i, numbering_system, max_number): + """Convert a number to a string based on the numbering system.""" + if numbering_system == "number_ext": + # Determine the length based on the maximum number + length = len(str(max_number)) + return NumberingSystems.to_number_ext(i, length) + if numbering_system == "custom": + return NumberingSystems.to_number(i) + return NumberingSystems.get_numberings()[numbering_system](i) + + def get_numbering_preview(numbering_system, initial): + """Get a preview of the numbering string for a given number and type.""" + numbers = [NumberingSystems.to_numbering_string(i, numbering_system, 10) for i in range(initial, initial + 3)] + return "{0}, {1}, {2}, ...".format(*numbers) + + +class ObjectGeometry: + @staticmethod + def get_object_location(obj, settings): + """Get the location of a Blender object.""" + mat = obj.matrix_world + bbox_vectors = [mat @ Vector(b) for b in obj.bound_box] + + if settings.get("location_type", "CENTER") == "CENTER": + return 0.125 * sum(bbox_vectors, Vector()) + + elif settings.get("location_type") == "BOUNDING_BOX": + bbox_vector = Vector((0, 0, 0)) + # Determine the coordinates based on the direction and axis order + direction = ( + int(settings.get("x_direction")), + int(settings.get("y_direction")), + int(settings.get("z_direction")), + ) + for i in range(3): + if direction[i] == -1: + bbox_vector[i] = max(v[i] for v in bbox_vectors) + else: + bbox_vector[i] = min(v[i] for v in bbox_vectors) + return bbox_vector + + @staticmethod + def get_object_dimensions(obj): + """Get the dimensions of a Blender object.""" + # Get the object's bounding box corners in world space + mat = obj.matrix_world + coords = [mat @ Vector(corner) for corner in obj.bound_box] + + # Compute min and max coordinates + min_corner = Vector(min(v[i] for v in coords) for i in range(3)) + max_corner = Vector(max(v[i] for v in coords) for i in range(3)) + + # Dimensions in global space + dimensions = max_corner - min_corner + return dimensions + + @staticmethod + def cmp_within_precision(a, b, settings, use_dir=True): + """Compare two vectors within a given precision.""" + direction = ( + ( + int(settings.get("x_direction", 1)), + int(settings.get("y_direction", 1)), + int(settings.get("z_direction", 1)), + ) + if use_dir + else (1, 1, 1) + ) + for axis in settings.get("axis_order", "XYZ"): + idx = "XYZ".index(axis) + diff = (a[idx] - b[idx]) * direction[idx] + if 1000 * abs(diff) > settings.get("precision", [0, 0, 0])[idx]: + return 1 if diff > 0 else -1 + return 0 + + +class ElementGeometry: + @staticmethod + def get_element_location(element, settings): + """Get the location of an IFC element.""" + geom_settings = geom.settings() + geom_settings.set("use-world-coords", True) + shape = geom.create_shape(geom_settings, element) + + verts = ifc_shape.get_shape_vertices(shape, shape.geometry) + if settings.get("location_type") == "CENTER": + return np.mean(verts, axis=0) + + elif settings.get("location_type") == "BOUNDING_BOX": + direction = ( + int(settings.get("x_direction", 1)), + int(settings.get("y_direction", 1)), + int(settings.get("z_direction", 1)), + ) + bbox_min = np.min(verts, axis=0) + bbox_max = np.max(verts, axis=0) + bbox_vector = np.zeros(3) + for i in range(3): + if direction[i] == -1: + bbox_vector[i] = bbox_max[i] + else: + bbox_vector[i] = bbox_min[i] + return bbox_vector + + @staticmethod + def get_element_dimensions(element): + """Get the dimensions of an IFC element.""" + geom_settings = geom.settings() + geom_settings.set("use-world-coords", True) + shape = geom.create_shape(geom_settings, element) + + verts = ifc_shape.get_shape_vertices(shape, shape.geometry) + bbox_min = np.min(verts, axis=0) + bbox_max = np.max(verts, axis=0) + return bbox_max - bbox_min + + +class Settings: + + pset_name = "Pset_NumberingSettings" + + settings_names = None + + def import_settings(filepath): + """Import settings from a JSON file, e.g. as exported from the UI""" + with open(filepath, "r") as file: + settings = json.load(file) + return settings + + def default_settings(): + """ "Return a default dictionary of settings for numbering elements.""" + return { + "x_direction": 1, + "y_direction": 1, + "z_direction": 1, + "axis_order": "ZYX", + "location_type": "CENTER", + "precision": (1, 1, 1), + "initial_element_number": 1, + "initial_type_number": 1, + "initial_storey_number": 0, + "element_numbering": "number", + "type_numbering": "number", + "storey_numbering": "number", + "format": "E{E}S{S}[T]{T}", + "save_type": "Attribute", + "attribute_name": "Tag", + "pset_name": "Common", + "custom_pset_name": "Pset_Numbering", + "property_name": "Number", + } + + @staticmethod + def to_dict(props): + """Convert the properties to a dictionary for saving.""" + return { + "selected_toggle": props.selected_toggle, + "visible_toggle": props.visible_toggle, + "parent_type": props.parent_type, + "parent_type_other": props.parent_type_other, + "selected_types": list(props.selected_types), + "x_direction": props.x_direction, + "y_direction": props.y_direction, + "z_direction": props.z_direction, + "axis_order": props.axis_order, + "location_type": props.location_type, + "precision": (props.precision[0], props.precision[1], props.precision[2]), + "initial_element_number": props.initial_element_number, + "initial_type_number": props.initial_type_number, + "initial_storey_number": props.initial_storey_number, + "element_numbering": props.element_numbering, + "type_numbering": props.type_numbering, + "storey_numbering": props.storey_numbering, + "format": props.format, + "save_type": props.save_type, + "attribute_name": props.attribute_name, + "attribute_name_other": props.attribute_name_other, + "pset_name": props.pset_name, + "custom_pset_name": props.custom_pset_name, + "property_name": props.property_name, + "remove_toggle": props.remove_toggle, + "check_duplicates_toggle": props.check_duplicates_toggle, + } + + @staticmethod + def save_settings(operator, props, ifc_file): + """Save the numbering settings to the IFC file.""" + # Save multiple settings by name in a dictionary + project = ifc_file.by_type("IfcProject")[0] + settings_name = props.settings_name.strip() + if not settings_name: + operator.report({"ERROR"}, "Please enter a name for the settings.") + return {"CANCELLED"} + if pset_settings := get_pset(project, Settings.pset_name): + pset_settings = ifc_file.by_id(pset_settings["id"]) + else: + pset_settings = ifc_api.run("pset.add_pset", ifc_file, product=project, name=Settings.pset_name) + if not pset_settings: + operator.report({"ERROR"}, "Could not create property set") + return {"CANCELLED"} + ifc_api.run( + "pset.edit_pset", + ifc_file, + pset=pset_settings, + properties={settings_name: json.dumps(Settings.to_dict(props))}, + ) + Settings.settings_names.add(settings_name) + operator.report({"INFO"}, f"Saved settings '{settings_name}' to IFCProject element") + return {"FINISHED"} + + @staticmethod + def read_settings(operator, settings, props): + for key, value in settings.items(): + if key == "selected_types": + possible_type_names = [t[0] for t in LoadSelection.possible_types] + value = set([type_name for type_name in value if type_name in possible_type_names]) + try: + setattr(props, key, value) + except Exception as e: + operator.report({"ERROR"}, f"Failed to set property {key} to {value}. Error: {e}") + + @staticmethod + def get_settings_names(): + ifc_file = tool.Ifc.get() + if Settings.settings_names is None: + if pset := get_pset(ifc_file.by_type("IfcProject")[0], Settings.pset_name): + names = set(pset.keys()) + names.remove("id") + else: + names = set() + Settings.settings_names = names + return Settings.settings_names + + @staticmethod + def load_settings(operator, props, ifc_file): + # Load selected settings by name + settings_name = props.saved_settings + if settings_name == "NONE": + operator.report({"WARNING"}, "No saved settings to load.") + return {"CANCELLED"} + if pset_settings := get_pset(ifc_file.by_type("IfcProject")[0], Settings.pset_name): + settings = pset_settings.get(settings_name, None) + if settings is None: + operator.report({"WARNING"}, f"Settings '{settings_name}' not found.") + return {"CANCELLED"} + settings = json.loads(settings) + Settings.read_settings(operator, settings, props) + operator.report({"INFO"}, f"Loaded settings '{settings_name}' from IFCProject element") + return {"FINISHED"} + else: + operator.report({"WARNING"}, "No settings found") + return {"CANCELLED"} + + @staticmethod + def delete_settings(operator, props): + ifc_file = tool.Ifc.get() + settings_name = props.saved_settings + if settings_name == "NONE": + operator.report({"WARNING"}, "No saved settings to delete.") + return {"CANCELLED"} + if pset_settings := get_pset(ifc_file.by_type("IfcProject")[0], Settings.pset_name): + if settings_name in pset_settings: + pset_settings = ifc_file.by_id(pset_settings["id"]) + ifc_api.run( + "pset.edit_pset", ifc_file, pset=pset_settings, properties={settings_name: None}, should_purge=True + ) + Settings.settings_names.remove(settings_name) + operator.report({"INFO"}, f"Deleted settings '{settings_name}' from IFCProject element") + + if not pset_settings.HasProperties: + ifc_api.run( + "pset.remove_pset", ifc_file, product=ifc_file.by_type("IfcProject")[0], pset=pset_settings + ) + return {"FINISHED"} + else: + operator.report({"WARNING"}, f"Settings '{settings_name}' not found.") + return {"CANCELLED"} + else: + operator.report({"WARNING"}, "No settings found") + return {"CANCELLED"} + + @staticmethod + def clear_settings(operator, props): + ifc_file = tool.Ifc.get() + project = ifc_file.by_type("IfcProject")[0] + if pset_settings := get_pset(project, Settings.pset_name): + pset_settings = ifc_file.by_id(pset_settings["id"]) + ifc_api.run("pset.remove_pset", ifc_file, product=project, pset=pset_settings) + operator.report({"INFO"}, f"Cleared settings from IFCProject element") + Settings.settings_names = set() + return {"FINISHED"} + else: + operator.report({"WARNING"}, "No settings found") + return {"CANCELLED"} + + class Numbering(bonsai.core.tool.Numbering): @classmethod def get_numbering_props(cls) -> BIMNumberingProperties: assert (scene := bpy.context.scene) return scene.BIMNumberingProperties # pyright: ignore[reportAttributeAccessIssue] + + @staticmethod + def number_elements( + elements, + ifc_file, + settings, + elements_locations=None, + elements_dimensions=None, + storeys=None, + numbers_cache={}, + storeys_numbers={}, + report=None, + remove_count=None, + ): + """Number elements in the IFC file with the provided settings. If element locations or dimensions are specified, these are used for sorting. + Providing numbers_cache, a dictionary with element-> currently saved number, speeds up execution. + If storeys_numbers is provided, as a dictionary storey->number, this is used for assigning storey numbers.""" + if report is None: + + def report(report_type, message): + if report_type == {"INFO"}: + print("INFO: ", message) + if report_type == {"WARNING"}: + raise Exception(message) + + if storeys is None: + storeys = [] + + number_count = 0 + + if elements_dimensions: + elements.sort( + key=ft.cmp_to_key( + lambda a, b: ObjectGeometry.cmp_within_precision( + elements_dimensions[a], elements_dimensions[b], settings, use_dir=False + ) + ) + ) + if elements_locations: + elements.sort( + key=ft.cmp_to_key( + lambda a, b: ObjectGeometry.cmp_within_precision( + elements_locations[a], elements_locations[b], settings + ) + ) + ) + + selected_types = LoadSelection.get_selected_types(settings) + + if not selected_types: + selected_types = list(set(element.is_a() for element in elements)) + + elements_by_type = [ + [element for element in elements if element.is_a() == ifc_type] for ifc_type in selected_types + ] + + failed_types = set() + for element_number, element in enumerate(elements): + + type_index = selected_types.index(element.is_a()) + type_elements = elements_by_type[type_index] + type_number = type_elements.index(element) + type_name = selected_types[type_index][3:] + + if storeys: + storey_number = Storeys.get_storey_number(element, storeys, settings, storeys_numbers) + if storey_number is None and "{S}" in settings.get("format"): + if report is not None: + report( + {"WARNING"}, + f"Element {getattr(element, 'Name', '')} of type {element.is_a()} with ID {SaveNumber.get_id(element)} is not contained in any storey.", + ) + else: + raise Exception( + f"Element {getattr(element, 'Name', '')} of type {element.is_a()} with ID {SaveNumber.get_id(element)} is not contained in any storey." + ) + else: + storey_number = None + + number = NumberFormatting.format_number( + settings, + (element_number, type_number, storey_number), + (len(elements), len(type_elements), len(storeys)), + type_name, + ) + count = SaveNumber.save_number(ifc_file, element, number, settings, numbers_cache) + if count is None: + report( + {"WARNING"}, + f"Failed to save number for element {getattr(element, 'Name', '')} of type {element.is_a()} with ID {SaveNumber.get_id(element)}.", + ) + failed_types.add(element.is_a()) + else: + number_count += count + + if failed_types: + report({"WARNING"}, f"Failed to renumber the following types: {failed_types}") + + if settings.get("remove_toggle") and remove_count is not None: + report({"INFO"}, f"Renumbered {number_count} objects, removed number from {remove_count} objects.") + else: + report({"INFO"}, f"Renumbered {number_count} objects.") + + return {"FINISHED"}, number_count + + @staticmethod + def assign_numbers(operator, settings, numbers_cache): + """Assign numbers to selected objects based on their IFC type and location.""" + ifc_file = tool.Ifc.get() + + remove_count = 0 + + if settings.get("remove_toggle"): + for obj in bpy.context.scene.objects: + if (settings.get("selected_toggle") and obj not in bpy.context.selected_objects) or ( + settings.get("visible_toggle") and not obj.visible_get() + ): + element = tool.Ifc.get_entity(obj) + if element is not None and element.is_a(LoadSelection.get_parent_type(settings)): + count_diff = SaveNumber.remove_number(ifc_file, element, settings, numbers_cache) + remove_count += count_diff + + objects = LoadSelection.load_selected_objects(settings) + + if not objects: + operator.report( + {"WARNING"}, f"No objects selected or available for numbering, removed {remove_count} existing numbers." + ) + return {"CANCELLED"} + + selected_types = LoadSelection.get_selected_types(settings) + possible_types = [tupl[0] for tupl in LoadSelection.possible_types] + + selected_elements = [] + elements_locations = {} + elements_dimensions = {} + for obj in objects: + element = tool.Ifc.get_entity(obj) + if element is None: + continue + if element.is_a() in selected_types: + selected_elements.append(element) + elements_locations[element] = ObjectGeometry.get_object_location(obj, settings) + elements_dimensions[element] = ObjectGeometry.get_object_dimensions(obj) + elif settings.get("remove_toggle") and element.is_a() in possible_types: + remove_count += SaveNumber.remove_number(ifc_file, element, settings, numbers_cache) + + if not selected_elements: + operator.report( + {"WARNING"}, + f"No elements selected or available for numbering, removed {remove_count} existing numbers.", + ) + + storeys = Storeys.get_storeys(settings) + res, _ = Numbering.number_elements( + selected_elements, + ifc_file, + settings, + elements_locations, + elements_dimensions, + storeys, + numbers_cache, + report=operator.report, + remove_count=remove_count, + ) + + if settings.get("check_duplicates_toggle"): + numbers = [] + for obj in bpy.context.scene.objects: + element = tool.Ifc.get_entity(obj) + if element is None or not element.is_a(LoadSelection.get_parent_type(settings)): + continue + number = SaveNumber.get_number(element, settings, numbers_cache) + if number in numbers: + operator.report({"WARNING"}, f"The model contains duplicate numbers") + return {"FINISHED"} + if number is not None: + numbers.append(number) + + return res + + @staticmethod + def remove_numbers(operator, settings, numbers_cache): + """Remove numbers from selected objects""" + ifc_file = tool.Ifc.get() + + remove_count = 0 + + objects = bpy.context.selected_objects if settings.get("selected_toggle") else bpy.context.scene.objects + if settings.get("visible_toggle"): + objects = [obj for obj in objects if obj.visible_get()] + + if not objects: + operator.report({"WARNING"}, f"No objects selected or available for removal.") + return {"CANCELLED"} + + for obj in objects: + element = tool.Ifc.get_entity(obj) + if element is not None and element.is_a(LoadSelection.get_parent_type(settings)): + remove_count += SaveNumber.remove_number(ifc_file, element, settings, numbers_cache) + numbers_cache[SaveNumber.get_id(element)] = None + + if remove_count == 0: + operator.report({"WARNING"}, f"No elements selected or available for removal.") + return {"CANCELLED"} + + operator.report({"INFO"}, f"Removed {remove_count} existing numbers.") + return {"FINISHED"} From 10a400a3d6c05abd5c918073e778d172de8f2606 Mon Sep 17 00:00:00 2001 From: BjornFidder Date: Wed, 27 Aug 2025 16:24:54 +0200 Subject: [PATCH 9/9] Revert changes in ifcopenshell_wrapper --- .../ifcopenshell/ifcopenshell_wrapper.pyi | 51 ++++++++----------- 1 file changed, 22 insertions(+), 29 deletions(-) diff --git a/src/ifcopenshell-python/ifcopenshell/ifcopenshell_wrapper.pyi b/src/ifcopenshell-python/ifcopenshell/ifcopenshell_wrapper.pyi index 2c0a6873974..659a062cfd0 100644 --- a/src/ifcopenshell-python/ifcopenshell/ifcopenshell_wrapper.pyi +++ b/src/ifcopenshell-python/ifcopenshell/ifcopenshell_wrapper.pyi @@ -94,6 +94,7 @@ class FileDescription(HeaderEntity): class FileName(HeaderEntity): name: str + """Updated automatically only on ``file.write``.""" time_stamp: str author: tuple[str, ...] organization: tuple[str, ...] @@ -135,11 +136,6 @@ class BRepElement(Element): @property def volume(self): ... -class CgalEmitOriginalEdges: - name: Any - description: Any - defaultvalue: Any - class ColladaSerializer(WriteOnlyGeometrySerializer): def finalize(self): ... def isTesselated(self): ... @@ -150,11 +146,6 @@ class ColladaSerializer(WriteOnlyGeometrySerializer): def write(self, *args): ... def writeHeader(self): ... -class ComputeCurvature: - name: Any - description: Any - defaultvalue: Any - class ConversionResult: def ItemId(self): ... def Placement(self): ... @@ -273,16 +264,6 @@ class Element: class FileSchema(HeaderEntity): schema_identifiers: Any -class FunctionStepParam: - name: Any - description: Any - defaultvalue: Any - -class FunctionStepType: - name: Any - description: Any - defaultvalue: Any - class GeometrySerializer: READ_BREP: Any READ_TRIANGULATION: Any @@ -308,7 +289,7 @@ class HdfSerializer(GeometrySerializer): def isTesselated(self): ... def read(self, *args): ... def ready(self): ... - def remove(self, guid): ... + def remove(self, guid: str) -> None: ... def setFile(self, arg2): ... def setUnitNameAndMagnitude(self, arg2, arg3): ... def write(self, *args): ... @@ -354,6 +335,8 @@ class Iterator: def getLog(self): ... def get_native(self): ... def get_object(self, id): ... + def get_task_items(self): ... + def get_task_products(self): ... def had_error_processing_elements(self): ... def initialize(self) -> bool: """Return true if the iterator is initialized with any elements, false otherwise.""" @@ -376,7 +359,7 @@ class Iterator: """ ... - def set_cache(self, cache: GeometrySerializer): ... + def set_cache(self, cache: Union[GeometrySerializer, None]) -> None: ... def unit_magnitude(self): ... def unit_name(self): ... @@ -521,6 +504,7 @@ class Triangulation(Representation): def edges(self) -> tuple[int, ...]: ... @property def edges_buffer(self) -> bytes: ... + @property def edges_item_ids(self) -> tuple[int, ...]: ... @property def edges_item_ids_buffer(self) -> bytes: ... @@ -566,11 +550,6 @@ class TriangulationElement(Element): def geometry(self) -> Triangulation: ... def geometry_pointer(self): ... -class TriangulationType: - name: Any - description: Any - defaultvalue: Any - class TtlWktSerializer(WriteOnlyGeometrySerializer): def finalize(self): ... def isTesselated(self): ... @@ -1059,6 +1038,15 @@ class geom_item(item): matrix: Any surface_style: Any +class geometry_conversion_result: + breps: Any + elements: Any + index: Any + item: Any + products: Any + products_2: Any + representation: Any + class geometry_exception: def what(self): ... @@ -1071,6 +1059,8 @@ class gradient_function(function_item): def kind(self): ... def start(self): ... +class equal_functor: ... +class hash_functor: ... class horizontal_plan_at_element: ... class implicit_item(geom_item): ... @@ -1136,6 +1126,7 @@ class loop: fi: Any def calc_hash(self): ... def calculate_linear_edge_curves(self): ... + def centroid(self): ... @property def children(self): ... def clone_(self): ... @@ -1299,6 +1290,7 @@ class select_type(declaration): class shell: closed: Any def calc_hash(self): ... + def centroid(self): ... @property def children(self): ... def clone_(self): ... @@ -1360,8 +1352,8 @@ class style(item): - IFC style id if style assigned to the representation items directly or through material with a style; - IFC material id if both true: - - element has a material without a style; - - there are parts of the geometry that has no other style assigned to them; + - element has a material without a style; + - there are parts of the geometry that has no other style assigned to them; - -1 in case if there is no material; - 0 in case if there are default materials used. """ @@ -1591,6 +1583,7 @@ def clear_schemas(): ... def construct_iterator_with_include_exclude(geometry_library, settings, file, elems, include, num_threads): ... def construct_iterator_with_include_exclude_globalid(geometry_library, settings, file, elems, include, num_threads): ... def construct_iterator_with_include_exclude_id(geometry_library, settings, file, elems, include, num_threads): ... +def convert_loop_to_function_item(loop): ... def create_box(*args): ... def create_epeck(*args): ... def create_shape(*args): ...