From 3a59425a6453292c209f0698e4741c2050e1ea8c Mon Sep 17 00:00:00 2001 From: Andrej730 Date: Thu, 5 Mar 2026 16:59:15 +0500 Subject: [PATCH] Linked IFC models - hotkey to hide selected geometry Demo - https://files.catbox.moe/aok74w.mp4 --- .../bonsai/bim/module/project/__init__.py | 1 + .../bonsai/bim/module/project/decorator.py | 20 +-- .../bonsai/bim/module/project/operator.py | 49 +++++- src/bonsai/bonsai/bim/module/project/prop.py | 2 + .../bonsai/bim/module/project/workspace.py | 14 +- src/bonsai/bonsai/tool/blender.py | 14 +- src/bonsai/bonsai/tool/project.py | 149 ++++++++++++++++-- .../ifcopenshell/api/project/append_asset.py | 2 +- 8 files changed, 219 insertions(+), 32 deletions(-) diff --git a/src/bonsai/bonsai/bim/module/project/__init__.py b/src/bonsai/bonsai/bim/module/project/__init__.py index 81eae4902a..db642c18c2 100644 --- a/src/bonsai/bonsai/bim/module/project/__init__.py +++ b/src/bonsai/bonsai/bim/module/project/__init__.py @@ -46,6 +46,7 @@ classes = ( operator.EnableEditingLink, operator.ExportIFC, operator.FlipClippingPlane, + operator.HideQueriedLinkedElement, operator.IFCFileHandlerOperator, operator.ImageScalingTool, operator.LinkIfc, diff --git a/src/bonsai/bonsai/bim/module/project/decorator.py b/src/bonsai/bonsai/bim/module/project/decorator.py index cb1ee4b4bd..72090a769b 100644 --- a/src/bonsai/bonsai/bim/module/project/decorator.py +++ b/src/bonsai/bonsai/bim/module/project/decorator.py @@ -97,26 +97,20 @@ class ProjectDecorator: # general shader self.shader = gpu.shader.from_builtin("UNIFORM_COLOR") - selected_vertices: list[tuple[float, float, float]] = [] - selected_edges: list[tuple[int, int]] = [] - selected_tris: list[tuple[int, int, int]] = [] - props = tool.Project.get_project_props() - try: - obj = props.queried_obj - selected_vertices = obj["selected_vertices"] - selected_edges = obj["selected_edges"] - selected_tris = obj["selected_tris"] - except: + obj = props.queried_obj + if obj is None: return + geom = tool.Project.Link.get_selected_geometry(obj) + selected_vertices = geom.selected_vertices root_obj = props.queried_obj_root if root_obj and not (m := root_obj.matrix_world).is_identity: selected_vertices = [m @ Vector(v) for v in selected_vertices] - if selected_edges: - self.draw_batch("LINES", selected_vertices, selected_elements_color, selected_edges) - self.draw_batch("TRIS", selected_vertices, transparent_color(selected_elements_color), selected_tris) + if geom.selected_edges: + self.draw_batch("LINES", selected_vertices, selected_elements_color, geom.selected_edges) + self.draw_batch("TRIS", selected_vertices, transparent_color(selected_elements_color), geom.selected_tris) class ClippingPlaneDecorator: diff --git a/src/bonsai/bonsai/bim/module/project/operator.py b/src/bonsai/bonsai/bim/module/project/operator.py index d56ee9dfaa..a0768e0cf5 100644 --- a/src/bonsai/bonsai/bim/module/project/operator.py +++ b/src/bonsai/bonsai/bim/module/project/operator.py @@ -53,7 +53,7 @@ from bpy_extras.view3d_utils import ( region_2d_to_origin_3d, region_2d_to_vector_3d, ) -from mathutils import Matrix, Vector +from mathutils import Vector import bonsai.bim.handler import bonsai.bim.helper @@ -2367,6 +2367,53 @@ class QueryLinkedElement(bpy.types.Operator): return self.execute(context) +class HideQueriedLinkedElement(bpy.types.Operator): + bl_idname = "bim.hide_queried_linked_element" + bl_label = "Hide Queried Linked Element" + bl_description = ( + "Hide geometry for currently queried linked element.\n\n" + "ALT+Click (or ALT+H in Explore Tool) to unhide all geometry for currently selected linked model.\n" + "(Not Yet Implemented) SHIFT+Click to hide everything but currently queried element." + ) + bl_options = {"REGISTER", "UNDO"} + + unhide_all: bpy.props.BoolProperty(options={"SKIP_SAVE"}) # pyright: ignore[reportRedeclaration] + + if TYPE_CHECKING: + unhide_all: bool + + def invoke(self, context, event): + self.unhide_all = event.alt + return self.execute(context) + + def execute(self, context) -> set["rna_enums.OperatorReturnItems"]: + props = tool.Project.get_project_props() + + if self.unhide_all: + return self.run_unhide_all() + + obj = props.queried_obj + if not obj: + self.report({"INFO"}, "No object is queried to hide.") + return {"FINISHED"} + guid = props.queried_guid + tool.Project.Link.hide_linked_element(obj, guid) + tool.Project.Link.deselect_queried_linked_element() + + self.report({"INFO"}, "Queried object is now hidden.") + return {"FINISHED"} + + def run_unhide_all(self) -> set["rna_enums.OperatorReturnItems"]: + props = tool.Project.get_project_props() + link = props.active_link + if not link: + self.report({"INFO"}, "No linked model is currently selected.") + return {"FINISHED"} + tool.Project.Link.unhide_all_elements(link) + self.report({"INFO"}, "All linked model geometry is unhidden.") + return {"FINISHED"} + + class AppendInspectedLinkedElement(AppendLibraryElement): bl_idname = "bim.append_inspected_linked_element" bl_label = "Append Inspected Linked Element" diff --git a/src/bonsai/bonsai/bim/module/project/prop.py b/src/bonsai/bonsai/bim/module/project/prop.py index 57b153428b..5524f7efa7 100644 --- a/src/bonsai/bonsai/bim/module/project/prop.py +++ b/src/bonsai/bonsai/bim/module/project/prop.py @@ -452,6 +452,7 @@ class BIMProjectProperties(PropertyGroup): ) queried_obj: bpy.props.PointerProperty(type=bpy.types.Object) queried_obj_root: bpy.props.PointerProperty(type=bpy.types.Object) + queried_guid: bpy.props.StringProperty() clipping_planes: bpy.props.CollectionProperty(type=ObjProperty) clipping_planes_active_index: bpy.props.IntProperty(min=0, default=0, max=5) edited_objs: bpy.props.CollectionProperty(type=EditedObj) @@ -550,6 +551,7 @@ class BIMProjectProperties(PropertyGroup): should_save_metadata_for_this_file: bool queried_obj: Union[bpy.types.Object, None] queried_obj_root: Union[bpy.types.Object, None] + queried_guid: str clipping_planes: bpy.types.bpy_prop_collection_idprop[ObjProperty] clipping_planes_active_index: int edited_objs: bpy.types.bpy_prop_collection_idprop[EditedObj] diff --git a/src/bonsai/bonsai/bim/module/project/workspace.py b/src/bonsai/bonsai/bim/module/project/workspace.py index dca65f72e6..5437d1270b 100644 --- a/src/bonsai/bonsai/bim/module/project/workspace.py +++ b/src/bonsai/bonsai/bim/module/project/workspace.py @@ -40,9 +40,11 @@ class ExploreTool(bpy.types.WorkSpaceTool): ("bim.explore_hotkey", {"type": "C", "value": "PRESS", "alt": True}, {"properties": [("hotkey", "A_C")]}), ("bim.explore_hotkey", {"type": "M", "value": "PRESS", "shift": True}, {"properties": [("hotkey", "S_M")]}), ("bim.explore_hotkey", {"type": "S", "value": "PRESS", "shift": True}, {"properties": [("hotkey", "S_S")]}), + ("bim.explore_hotkey", {"type": "H", "value": "PRESS"}, {"properties": [("hotkey", "H")]}), + ("bim.explore_hotkey", {"type": "H", "value": "PRESS", "alt": True}, {"properties": [("hotkey", "A_H")]}), ) - def draw_settings(context, layout, ws_tool): + def draw_settings(context: bpy.types.Context, layout: bpy.types.UILayout, ws_tool) -> None: row = layout.row(align=True) row.label(text="Query Object", icon="MOUSE_RMB") row = layout.row(align=True) @@ -61,6 +63,9 @@ class ExploreTool(bpy.types.WorkSpaceTool): row.label(text="", icon="EVENT_ALT") row.label(text="Disable Culling" if LinksData.enable_culling else "Enable Culling", icon="EVENT_C") + row = layout.row(align=True) + row.operator("bim.hide_queried_linked_element", text="Hide Queried Element", icon="EVENT_H") + prop = tool.Project.get_measure_tool_settings() row = layout.row(align=True) row.label(text="", icon="EVENT_SHIFT") @@ -86,6 +91,7 @@ class ExploreHotkey(bpy.types.Operator): bl_idname = "bim.explore_hotkey" bl_label = "" bl_options = {"REGISTER", "UNDO", "INTERNAL"} + hotkey: bpy.props.StringProperty() description: bpy.props.StringProperty() @@ -137,3 +143,9 @@ class ExploreHotkey(bpy.types.Operator): return bpy.ops.bim.image_scaling_tool("INVOKE_DEFAULT") + + def hotkey_H(self) -> None: + bpy.ops.bim.hide_queried_linked_element() + + def hotkey_A_H(self) -> None: + bpy.ops.bim.hide_queried_linked_element(unhide_all=True) diff --git a/src/bonsai/bonsai/tool/blender.py b/src/bonsai/bonsai/tool/blender.py index 06082ebea2..205e592a35 100644 --- a/src/bonsai/bonsai/tool/blender.py +++ b/src/bonsai/bonsai/tool/blender.py @@ -2192,12 +2192,24 @@ class Blender(bonsai.core.tool.Blender): origin: Vector, direction: Vector, ) -> tuple[bool, Vector, Vector, int, bpy.types.Object, Matrix]: + """ + + The returned matrix is just ``obj.matrix_world``. + The returned object is not evaluated by the current depsgraph, + e.g. if object is modified by the depsgraph (e.g. by modifiers) + object has to be evaluated first (`obj.evaluated_get(depsgraph)`). + """ depsgraph = context.evaluated_depsgraph_get() assert context.scene - # `matrix` is just `obj.matrix_world`. result = context.scene.ray_cast( depsgraph, origin, direction, ) return result + + @classmethod + def depsgraph_evaluate(cls, obj: bpy.types.Object) -> bpy.types.Object: + depsgraph = bpy.context.evaluated_depsgraph_get() + evaluated_obj = obj.evaluated_get(depsgraph) + return evaluated_obj diff --git a/src/bonsai/bonsai/tool/project.py b/src/bonsai/bonsai/tool/project.py index b9a7a68a0c..d92bf19cf5 100644 --- a/src/bonsai/bonsai/tool/project.py +++ b/src/bonsai/bonsai/tool/project.py @@ -33,6 +33,7 @@ import ifcopenshell.util.element import ifcopenshell.util.representation import ifcopenshell.util.shape_builder import numpy as np +import numpy.typing as npt from ifcopenshell.api.project.append_asset import APPENDABLE_ASSET_TYPES from mathutils import Matrix @@ -621,28 +622,103 @@ class Project(bonsai.core.tool.Project): return guid_obj + @classmethod + def get_linked_element_guid_ids(cls, obj: bpy.types.Object, *, skip_hidden: bool) -> npt.NDArray[np.int64]: + obj_guid_ids: npt.NDArray[np.int64] = np.array(obj["guid_ids"]) + + if not skip_hidden: + return obj_guid_ids + + # 'hidden_indices' is needed, because otherwise we can't make sense of 'guid_ids', + # since part of the geometry is hidden. + obj_hidden_indices: list[int] = list(obj.get("hidden_indices") or []) + + if not obj_hidden_indices: + return obj_guid_ids + + # Skip hidden geometry indices. + hidden_indices_mask = np.zeros(len(obj_guid_ids), dtype=bool) + hidden_indices_mask[obj_hidden_indices] = True + deltas = np.diff(obj_guid_ids, prepend=0) + deltas[~hidden_indices_mask] = 0 + obj_guid_ids -= np.cumsum(deltas) + return obj_guid_ids + @classmethod def get_guid_by_face_index(cls, obj: bpy.types.Object, face_index: int) -> str | None: guids: list[str] = obj["guids"] - guid_ids: list[int] = obj["guid_ids"] + guid_ids = cls.get_linked_element_guid_ids(obj, skip_hidden=True) for guid, guid_end_index in zip(guids, guid_ids): if face_index < guid_end_index: return guid + @classmethod + def get_linked_element_geom_slice(cls, obj: bpy.types.Object, guid: str) -> slice[int, int]: + """ + Get slice for ``obj.data.polygons`` for the provided ``guid``. + """ + obj_guids: list[str] = obj["guids"] + + # Just to be safe. + obj_hidden_indices: list[int] = obj.get("hidden_indices") or [] + index = obj_guids.index(guid) + if index in obj_hidden_indices: + assert False, "Unexpected. Why would you need the geometry for the hidden element?" + obj_guid_ids = cls.get_linked_element_guid_ids(obj, skip_hidden=False) + guid_end_index = obj_guid_ids[index] + guid_start_index = index and obj_guid_ids[index - 1] + return slice(guid_start_index, guid_end_index) + + @classmethod + def hide_linked_element(cls, obj: bpy.types.Object, guid: str) -> None: + verts = tool.Project.Link.get_linked_element_verts(obj, guid) + + # `MeshPolygon.hide` works only in EDIT mode, + # so we use vertex groups + Mask modifier. + MODIFIER_VG_NAME = "BBIM_HIDE_LINKED_GEOMETRY" + + vertex_groups = obj.vertex_groups + vertex_group = vertex_groups.get(MODIFIER_VG_NAME) + if vertex_group is None: + vertex_group = vertex_groups.new(name=MODIFIER_VG_NAME) + + modifiers = obj.modifiers + modifier = modifiers.get(MODIFIER_VG_NAME) + if modifier is None: + modifier = modifiers.new(MODIFIER_VG_NAME, "MASK") + assert isinstance(modifier, bpy.types.MaskModifier) + modifier.vertex_group = MODIFIER_VG_NAME + modifier.invert_vertex_group = True + + vertex_group.add(verts, 1.0, "REPLACE") + + hidden_indices: list[int] = list(obj.get("hidden_indices") or []) + guid_ids: list[str] = obj["guids"] + index = guid_ids.index(guid) + hidden_indices.append(index) + obj["hidden_indices"] = hidden_indices + + @classmethod + def unhide_all_elements(cls, link: Link) -> None: + obj = tool.Project.get_link_empty_handle(link) + assert obj + col = obj.instance_collection + assert col + + for obj_ in col.objects: + if "hidden_indices" not in obj_: + continue + obj_.vertex_groups.clear() + obj_.modifiers.clear() + del obj_["hidden_indices"] + @classmethod def select_linked_element_geom(cls, obj: bpy.types.Object, guid: str) -> None: + slice_ = cls.get_linked_element_geom_slice(obj, guid) + mesh = obj.data assert isinstance(mesh, bpy.types.Mesh) - obj_guids: list[str] = obj["guids"] - obj_guid_ids: list[int] = obj["guid_ids"] - - index = obj_guids.index(guid) - guid_end_index = obj_guid_ids[index] - if index > 0: - guid_start_index = obj_guid_ids[index - 1] - else: - guid_start_index = 0 - guid_polygons = mesh.polygons[guid_start_index:guid_end_index] + guid_polygons = mesh.polygons[slice_] selected_tris: list[tuple[int, ...]] = [] selected_edges: list[tuple[int, ...]] = [] @@ -662,6 +738,19 @@ class Project(bonsai.core.tool.Project): obj["selected_edges"] = selected_edges obj["selected_tris"] = selected_tris + @classmethod + def get_linked_element_verts(cls, obj: bpy.types.Object, guid: str) -> set[int]: + slice_ = cls.get_linked_element_geom_slice(obj, guid) + + mesh = obj.data + assert isinstance(mesh, bpy.types.Mesh) + guid_polygons = mesh.polygons[slice_] + + guid_vertices_set: set[int] = set() + for polygon in guid_polygons: + guid_vertices_set.update(polygon.vertices) + return guid_vertices_set + @classmethod def select_linked_element( cls, @@ -686,10 +775,8 @@ class Project(bonsai.core.tool.Project): if instance_matrix is None: instance_matrix = obj.matrix_world - props = tool.Project.get_project_props() - props.queried_obj = obj - props.queried_obj_root = cls.find_obj_root(obj, instance_matrix) - + cls.deselect_queried_linked_element() + cls.set_queried_linked_element(obj, guid, instance_matrix) cls.select_linked_element_geom(obj, guid) db = sqlite3.connect(obj["db"]) c = db.cursor() @@ -743,6 +830,25 @@ class Project(bonsai.core.tool.Project): ProjectDecorator.install(context) + @classmethod + def set_queried_linked_element(cls, obj: bpy.types.Object, guid: str, instance_matrix: Matrix) -> None: + props = tool.Project.get_project_props() + props.queried_obj = obj + props.queried_obj_root = cls.find_obj_root(obj, instance_matrix) + props.queried_guid = guid + + @classmethod + def deselect_queried_linked_element(cls) -> None: + props = tool.Project.get_project_props() + obj = props.queried_obj + props.property_unset("queried_obj") + props.property_unset("queried_obj_root") + props.property_unset("queried_guid") + + if obj is not None: + for field in cls.SelectedGeometry._fields: + del obj[field] + @classmethod def find_obj_root(cls, obj: bpy.types.Object, matrix: Matrix) -> bpy.types.Object | None: collections = set(obj.users_collection) @@ -755,3 +861,16 @@ class Project(bonsai.core.tool.Project): ): continue return o + + class SelectedGeometry(NamedTuple): + selected_vertices: list[tuple[float, float, float]] + selected_edges: list[tuple[int, int]] + selected_tris: list[tuple[int, int, int]] + + @classmethod + def get_selected_geometry(cls, obj: bpy.types.Object) -> SelectedGeometry: + return cls.SelectedGeometry( + obj["selected_vertices"], + obj["selected_edges"], + obj["selected_tris"], + ) diff --git a/src/ifcopenshell-python/ifcopenshell/api/project/append_asset.py b/src/ifcopenshell-python/ifcopenshell/api/project/append_asset.py index 0a5aee0e1c..0f09bd4991 100644 --- a/src/ifcopenshell-python/ifcopenshell/api/project/append_asset.py +++ b/src/ifcopenshell-python/ifcopenshell/api/project/append_asset.py @@ -40,7 +40,7 @@ APPENDABLE_ASSET = Literal[ "IfcProfileDef", "IfcPresentationStyle", ] -APPENDABLE_ASSET_TYPES = get_args(APPENDABLE_ASSET) +APPENDABLE_ASSET_TYPES: tuple[APPENDABLE_ASSET, ...] = get_args(APPENDABLE_ASSET) MATERIAL_SETS = ("IfcMaterialLayerSet", "IfcMaterialConstituentSet", "IfcMaterialProfileSet")