diff --git a/src/bonsai/bonsai/bim/module/drawing/operator.py b/src/bonsai/bonsai/bim/module/drawing/operator.py index 66a1482acc..582c7b062b 100644 --- a/src/bonsai/bonsai/bim/module/drawing/operator.py +++ b/src/bonsai/bonsai/bim/module/drawing/operator.py @@ -18,6 +18,7 @@ import hashlib import json +import logging import multiprocessing import os import shutil @@ -39,7 +40,6 @@ from typing import ( import bmesh import bpy -import logging import ifcopenshell import ifcopenshell.api.document import ifcopenshell.api.pset @@ -58,9 +58,9 @@ from bpy_extras.io_utils import ImportHelper from lxml import etree from mathutils import Color, Vector -import bonsai.bim.import_ifc import bonsai.bim.export_ifc import bonsai.bim.handler +import bonsai.bim.import_ifc import bonsai.bim.module.drawing.sheeter as sheeter import bonsai.bim.module.drawing.svgwriter as svgwriter import bonsai.core.drawing as core diff --git a/src/bonsai/bonsai/bim/module/project/__init__.py b/src/bonsai/bonsai/bim/module/project/__init__.py index f4f4261eb5..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, @@ -66,6 +67,7 @@ classes = ( operator.RewindLibrary, operator.SaveLibraryFile, operator.SelectLibraryFile, + operator.SelectLinkedModelElement, operator.SelectLinkHandle, operator.ToggleFilterCategories, operator.ToggleLinkSelectability, diff --git a/src/bonsai/bonsai/bim/module/project/data.py b/src/bonsai/bonsai/bim/module/project/data.py index 56a77d1880..8ecf30eef5 100644 --- a/src/bonsai/bonsai/bim/module/project/data.py +++ b/src/bonsai/bonsai/bim/module/project/data.py @@ -170,6 +170,6 @@ class ProjectLibraryData: class LinksData: - linked_data = {} + linked_data: dict[str, Any] = {} enable_culling = False is_loaded = False diff --git a/src/bonsai/bonsai/bim/module/project/decorator.py b/src/bonsai/bonsai/bim/module/project/decorator.py index 8c2ec0e08b..72090a769b 100644 --- a/src/bonsai/bonsai/bim/module/project/decorator.py +++ b/src/bonsai/bonsai/bim/module/project/decorator.py @@ -16,8 +16,6 @@ # You should have received a copy of the GNU General Public License # along with Bonsai. If not, see . -from typing import Union - import bmesh import bpy import gpu @@ -54,7 +52,7 @@ class ProjectDecorator: installed = None @classmethod - def install(cls, context): + def install(cls, context: bpy.types.Context) -> None: if cls.installed: cls.uninstall() handler = cls() @@ -99,26 +97,20 @@ class ProjectDecorator: # general shader self.shader = gpu.shader.from_builtin("UNIFORM_COLOR") - selected_vertices = [] - selected_edges = [] - selected_tris = [] - 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: Union[bpy.types.Object, None] = props.queried_obj_root + 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 7bffc724e5..a0768e0cf5 100644 --- a/src/bonsai/bonsai/bim/module/project/operator.py +++ b/src/bonsai/bonsai/bim/module/project/operator.py @@ -32,6 +32,7 @@ from typing import TYPE_CHECKING, Literal, Union, get_args import bpy import ifcopenshell import ifcopenshell.api.attribute +import ifcopenshell.api.document import ifcopenshell.api.nest import ifcopenshell.api.project import ifcopenshell.api.root @@ -48,7 +49,11 @@ import ifcopenshell.util.unit import numpy as np from bpy.app.handlers import persistent from bpy_extras.io_utils import ExportHelper, ImportHelper -from mathutils import Matrix, Vector +from bpy_extras.view3d_utils import ( + region_2d_to_origin_3d, + region_2d_to_vector_3d, +) +from mathutils import Vector import bonsai.bim.handler import bonsai.bim.helper @@ -56,14 +61,6 @@ import bonsai.core.project as core import bonsai.tool as tool from bonsai.bim import export_ifc, import_ifc from bonsai.bim.ifc import IfcStore -from bonsai.bim.ui import IFCFileSelector -from bonsai.bim import import_ifc -from bonsai.bim import export_ifc -from math import radians -from pathlib import Path -from collections import defaultdict -from mathutils import Vector, Matrix -from bpy.app.handlers import persistent from bonsai.bim.module.model.decorator import FaceAreaDecorator, PolylineDecorator from bonsai.bim.module.model.polyline import PolylineOperator from bonsai.bim.module.project.data import LinksData, ProjectLibraryData @@ -1446,8 +1443,13 @@ class LoadLink(bpy.types.Operator, tool.Ifc.Operator): bl_label = "Load Link" bl_options = {"REGISTER", "UNDO"} bl_description = "Load the selected file" - link_index: bpy.props.IntProperty(name="Link Index") - use_cache: bpy.props.BoolProperty(name="Use Cache", default=True) + + link_index: bpy.props.IntProperty(name="Link Index") # pyright: ignore[reportRedeclaration] + use_cache: bpy.props.BoolProperty(name="Use Cache", default=True) # pyright: ignore[reportRedeclaration] + + if TYPE_CHECKING: + link_index: int + use_cache: bool def _execute(self, context): self.link = tool.Project.get_project_props().links[self.link_index] @@ -1472,9 +1474,10 @@ class LoadLink(bpy.types.Operator, tool.Ifc.Operator): empty = bpy.data.objects.new(empty_name, None) empty.instance_type = "COLLECTION" empty.instance_collection = collection - empty.matrix_world = Matrix(tool.Project.calculate_link_matrix(self.link)) + empty.matrix_world = tool.Project.calculate_link_matrix(self.link) tool.Project.set_link_empty_handle(self.link, empty) + assert bpy.context.scene bpy.context.scene.collection.objects.link(empty) self.link.is_loaded = True if tool.Ifc.get(): # For non-IFC projects, locking has no meaning @@ -1643,8 +1646,16 @@ class ToggleLinkVisibility(bpy.types.Operator): bl_label = "Toggle Link Visibility" bl_options = {"REGISTER", "UNDO"} bl_description = "Toggle visibility between SOLID and WIREFRAME" - link_index: bpy.props.IntProperty(name="Link Index") - mode: bpy.props.EnumProperty(name="Visibility Mode", items=((i, i, "") for i in ("WIREFRAME", "VISIBLE"))) + + link_index: bpy.props.IntProperty(name="Link Index") # pyright: ignore[reportRedeclaration] + mode: bpy.props.EnumProperty( # pyright: ignore[reportRedeclaration] + name="Visibility Mode", + items=((i, i, "") for i in ("WIREFRAME", "VISIBLE")), + ) + + if TYPE_CHECKING: + link_index: int + mode: Literal["WIREFRAME", "VISIBLE"] def execute(self, context): props = tool.Project.get_project_props() @@ -1696,8 +1707,11 @@ class EnableEditingLink(bpy.types.Operator): def execute(self, context): link = tool.Project.get_project_props().active_link + assert link link.is_editing = True - tool.Geometry.unlock_object(tool.Project.get_link_empty_handle(link)) + obj = tool.Project.get_link_empty_handle(link) + assert obj + tool.Geometry.unlock_object(obj) return {"FINISHED"} @@ -1709,9 +1723,11 @@ class DisableEditingLink(bpy.types.Operator): def execute(self, context): link = tool.Project.get_project_props().active_link + assert link link.is_editing = False obj = tool.Project.get_link_empty_handle(link) - obj.matrix_world = Matrix(tool.Project.calculate_link_matrix(link)) + assert obj + obj.matrix_world = tool.Project.calculate_link_matrix(link) tool.Geometry.lock_object(obj) return {"FINISHED"} @@ -1724,8 +1740,10 @@ class EditLink(bpy.types.Operator, tool.Ifc.Operator): def _execute(self, context): link = tool.Project.get_project_props().active_link + assert link link.is_editing = False obj = tool.Project.get_link_empty_handle(link) + assert obj new_obj_matrix = obj.matrix_world filepath = Path(tool.Ifc.resolve_uri(link.filepath)) @@ -1761,7 +1779,7 @@ class EditLink(bpy.types.Operator, tool.Ifc.Operator): else: link.transformation = transformation - obj.matrix_world = Matrix(tool.Project.calculate_link_matrix(link)) + obj.matrix_world = tool.Project.calculate_link_matrix(link) tool.Geometry.lock_object(obj) @@ -1783,6 +1801,43 @@ class SelectLinkHandle(bpy.types.Operator): return {"FINISHED"} +class SelectLinkedModelElement(bpy.types.Operator): + bl_idname = "bim.select_linked_model_element" + bl_label = "Select Linked Model Element" + bl_options = {"REGISTER"} + bl_description = "Select an element in the currently selected linked model by providing GlobalId." + + guid: bpy.props.StringProperty(name="GlobalId") # pyright: ignore[reportRedeclaration] + + if TYPE_CHECKING: + guid: str + + def invoke(self, context, event): + assert context.window_manager + return context.window_manager.invoke_props_dialog(self) + + def execute(self, context) -> set["rna_enums.OperatorReturnItems"]: + guid = self.guid.strip() + if not guid: + self.report({"ERROR"}, "GlobalId is not provided.") + return {"CANCELLED"} + + props = tool.Project.get_project_props() + active_link = props.active_link + assert active_link is not None + assert active_link.is_loaded + + guid_obj = tool.Project.Link.get_obj_by_guid(active_link, guid) + if not guid_obj: + filepath = active_link.filepath + self.report({"INFO"}, f"Element with GlobalId '{guid}' not found in the linked model at '{filepath}'.") + return {"CANCELLED"} + + tool.Project.Link.select_linked_element(context, guid_obj, guid) + self.report({"INFO"}, f"Element with GlobalId '{guid}' is selected.") + return {"FINISHED"} + + class ExportIFC(bpy.types.Operator, ExportHelper): bl_idname = "bim.save_project" bl_label = "Save IFC" @@ -2264,21 +2319,16 @@ class QueryLinkedElement(bpy.types.Operator): @classmethod def poll(cls, context): + assert context.area return context.area.type == "VIEW_3D" - def execute(self, context): - import sqlite3 - - from bpy_extras.view3d_utils import ( - region_2d_to_origin_3d, - region_2d_to_vector_3d, - ) - + def execute(self, context) -> set["rna_enums.OperatorReturnItems"]: LinksData.linked_data = {} props = tool.Project.get_project_props() props.queried_obj = None - for area in bpy.context.screen.areas: + assert context.screen + for area in context.screen.areas: if area.type == "PROPERTIES": for region in area.regions: if region.type == "WINDOW": @@ -2286,128 +2336,89 @@ class QueryLinkedElement(bpy.types.Operator): elif area.type == "VIEW_3D": area.tag_redraw() + assert context.region and context.region_data region = context.region rv3d = context.region_data coord = (self.mouse_x, self.mouse_y) origin = region_2d_to_origin_3d(region, rv3d, coord) direction = region_2d_to_vector_3d(region, rv3d, coord) - hit, location, normal, face_index, obj, instance_matrix = self.ray_cast(context, origin, direction) + hit, location, normal, face_index, obj, instance_matrix = tool.Blender.ray_cast_scene( + context, origin, direction + ) if not hit: self.report({"INFO"}, "No object found.") return {"FINISHED"} - if "guids" not in obj: + if not tool.Project.Link.is_linked_element(obj): self.report({"INFO"}, "Object is not a linked IFC element.") return {"FINISHED"} - guid = None - guid_start_index = 0 - for i, guid_end_index in enumerate(obj["guid_ids"]): - if face_index < guid_end_index: - guid = obj["guids"][i] - props.queried_obj = obj - props.queried_obj_root = self.find_obj_root(obj, instance_matrix) - - selected_tris = [] - selected_edges = [] - vert_indices = set() - for polygon in obj.data.polygons[guid_start_index:guid_end_index]: - vert_indices.update(polygon.vertices) - vert_indices = list(vert_indices) - vert_map = {k: v for v, k in enumerate(vert_indices)} - selected_vertices = [tuple(obj.matrix_world @ obj.data.vertices[vi].co) for vi in vert_indices] - for polygon in obj.data.polygons[guid_start_index:guid_end_index]: - selected_tris.append(tuple(vert_map[v] for v in polygon.vertices)) - selected_edges.extend(tuple([vert_map[vi] for vi in e] for e in polygon.edge_keys)) - - obj["selected_vertices"] = selected_vertices - obj["selected_edges"] = selected_edges - obj["selected_tris"] = selected_tris - - break - guid_start_index = guid_end_index - - self.db = sqlite3.connect(obj["db"]) - self.c = self.db.cursor() - - self.c.execute(f"SELECT * FROM elements WHERE global_id = '{guid}' LIMIT 1") - element = self.c.fetchone() - - attributes = {} - for i, attr in enumerate(["GlobalId", "IFC Class", "Predefined Type", "Name", "Description"]): - if element[i + 1] is not None: - attributes[attr] = element[i + 1] - - self.c.execute("SELECT * FROM properties WHERE element_id = ?", (element[0],)) - rows = self.c.fetchall() - - properties = {} - for row in rows: - properties.setdefault(row[1], {})[row[2]] = row[3] - - self.c.execute("SELECT * FROM relationships WHERE from_id = ?", (element[0],)) - relationships = self.c.fetchall() - - relating_type_id = None - - for relationship in relationships: - if relationship[1] == "IfcRelDefinesByType": - relating_type_id = relationship[2] - - type_properties = {} - if relating_type_id is not None: - self.c.execute("SELECT * FROM properties WHERE element_id = ?", (relating_type_id,)) - rows = self.c.fetchall() - for row in rows: - type_properties.setdefault(row[1], {})[row[2]] = row[3] - - LinksData.linked_data = { - "attributes": attributes, - "properties": [(k, properties[k]) for k in sorted(properties.keys())], - "type_properties": [(k, type_properties[k]) for k in sorted(type_properties.keys())], - } - self.db.close() - - for area in bpy.context.screen.areas: - if area.type == "PROPERTIES": - for region in area.regions: - if region.type == "WINDOW": - region.tag_redraw() - elif area.type == "VIEW_3D": - area.tag_redraw() + guid = tool.Project.Link.get_guid_by_face_index(obj, face_index) + assert guid is not None + tool.Project.Link.select_linked_element(context, obj, guid) self.report({"INFO"}, f"Loaded data for {guid}") ProjectDecorator.install(bpy.context) return {"FINISHED"} - def ray_cast(self, context: bpy.types.Context, origin: Vector, direction: Vector): - depsgraph = context.evaluated_depsgraph_get() - result = context.scene.ray_cast(depsgraph, origin, direction) - return result - - def find_obj_root(self, obj: bpy.types.Object, matrix: Matrix) -> Union[bpy.types.Object, None]: - collections = set(obj.users_collection) - for o in bpy.data.objects: - if ( - o.type != "EMPTY" - or o.instance_type != "COLLECTION" - or o.instance_collection not in collections - or not np.allclose(matrix, o.matrix_world, atol=1e-4) - ): - continue - return o - def invoke(self, context, event): self.mouse_x = event.mouse_region_x self.mouse_y = event.mouse_region_y 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" bl_description = "Append inspected linked element" - bl_options = {"REGISTER"} + bl_options = {"REGISTER", "UNDO"} def _execute(self, context): from bonsai.bim.module.project.data import LinksData @@ -2423,6 +2434,7 @@ class AppendInspectedLinkedElement(AppendLibraryElement): return {"CANCELLED"} queried_obj = props.queried_obj + assert queried_obj ifc_file = tool.Ifc.get() linked_ifc_file: ifcopenshell.file @@ -2680,28 +2692,25 @@ class CreateClippingPlane(bpy.types.Operator): bl_options = {"REGISTER", "UNDO"} def execute(self, context): - from bpy_extras.view3d_utils import ( - region_2d_to_origin_3d, - region_2d_to_vector_3d, - ) - # Clean up deleted planes props = tool.Project.get_project_props() if len(props.clipping_planes) > 5: self.report({"INFO"}, "Maximum of six clipping planes allowed.") return {"FINISHED"} + assert context.screen for area in context.screen.areas: if area.type == "VIEW_3D": area.tag_redraw() + assert context.region and context.region_data region = context.region rv3d = context.region_data if rv3d: # Called from a 3D viewport coord = (self.mouse_x, self.mouse_y) origin = region_2d_to_origin_3d(region, rv3d, coord) direction = region_2d_to_vector_3d(region, rv3d, coord) - hit, location, normal, face_index, obj, matrix = self.ray_cast(context, origin, direction) + hit, location, normal, face_index, obj, matrix = tool.Blender.ray_cast_scene(context, origin, direction) if not hit: self.report({"INFO"}, "No object found.") return {"FINISHED"} @@ -2736,11 +2745,6 @@ class CreateClippingPlane(bpy.types.Operator): bpy.ops.bim.refresh_clipping_planes("INVOKE_DEFAULT") return {"FINISHED"} - def ray_cast(self, context, origin, direction): - depsgraph = context.evaluated_depsgraph_get() - result = context.scene.ray_cast(depsgraph, origin, direction) - return result - def invoke(self, context, event): self.mouse_x = event.mouse_region_x self.mouse_y = event.mouse_region_y @@ -2839,7 +2843,7 @@ class IFCFileHandlerOperator(bpy.types.Operator): def clean_up_path(path: str) -> str: # In Blender 4.5.6 there was a bug producing unncesseary double slash prefix - # breaking the paths. Issue is not present in 5.0+ and presumably will be solved in 4.5.7 too. + # breaking the paths. Issue is not present in 5.0+ and is fixed in 4.5.7. # https://projects.blender.org/blender/blender/issues/153822 if bpy.app.version == (4, 5, 6): blender_prefix = "//" 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/ui.py b/src/bonsai/bonsai/bim/module/project/ui.py index 7029eb7227..d86dd3cf5b 100644 --- a/src/bonsai/bonsai/bim/module/project/ui.py +++ b/src/bonsai/bonsai/bim/module/project/ui.py @@ -29,7 +29,6 @@ import bonsai.tool as tool from bonsai.bim.helper import draw_attributes, prop_with_search from bonsai.bim.ifc import IfcStore from bonsai.bim.module.project.data import LinksData, ProjectData -from typing import TYPE_CHECKING if TYPE_CHECKING: from bonsai.bim.module.project.prop import ( @@ -491,6 +490,7 @@ class BIM_PT_links(Panel): row.operator("bim.disable_editing_link", text="", icon="CANCEL") else: row.operator("bim.enable_editing_link", text="", icon="GREASEPENCIL") + row.operator("bim.select_linked_model_element", icon="VIEWZOOM", text="") row.operator("bim.select_link_handle", text="", icon="OBJECT_DATA").link_index = index row.operator("bim.unload_link", text="", icon="UNLINKED").link_index = index row.operator("bim.reload_link", text="", icon="FILE_REFRESH").link_index = index 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/bim/ui.py b/src/bonsai/bonsai/bim/ui.py index 7bc27a20e0..1d0e8742dd 100644 --- a/src/bonsai/bonsai/bim/ui.py +++ b/src/bonsai/bonsai/bim/ui.py @@ -774,6 +774,7 @@ class BIM_ADDON_preferences(bpy.types.AddonPreferences): bsdd_load_preview_dictionaries: bool bsdd_load_inactive_dictionaries: bool bsdd_load_test_dictionaries: bool + bsdd_baseurl: str should_disable_undo_on_save: bool should_stream: bool should_always_cache: bool @@ -789,6 +790,7 @@ class BIM_ADDON_preferences(bpy.types.AddonPreferences): mass_time_units_in_wizard: bool chain_filter_with_set_operations: bool save_metadata_blend_file: bool + metadata_blend_file_suffix: str decorator_font_scale: float def draw(self, context: bpy.types.Context) -> None: diff --git a/src/bonsai/bonsai/core/drawing.py b/src/bonsai/bonsai/core/drawing.py index 953f57e7f4..42b0f77fbb 100644 --- a/src/bonsai/bonsai/core/drawing.py +++ b/src/bonsai/bonsai/core/drawing.py @@ -19,7 +19,7 @@ from __future__ import annotations from pathlib import Path -from typing import TYPE_CHECKING, Optional, Union +from typing import TYPE_CHECKING, Literal, Optional, Union if TYPE_CHECKING: import bpy diff --git a/src/bonsai/bonsai/tool/blender.py b/src/bonsai/bonsai/tool/blender.py index 9c01ce8c20..205e592a35 100644 --- a/src/bonsai/bonsai/tool/blender.py +++ b/src/bonsai/bonsai/tool/blender.py @@ -2184,3 +2184,32 @@ class Blender(bonsai.core.tool.Blender): for f in files if (Path(directory) / f.name).is_file() ] + + @classmethod + def ray_cast_scene( + cls, + context: bpy.types.Context, + 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 + 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 6e1c9b2186..d92bf19cf5 100644 --- a/src/bonsai/bonsai/tool/project.py +++ b/src/bonsai/bonsai/tool/project.py @@ -18,21 +18,24 @@ from __future__ import annotations -import os import json +import os import shutil -import numpy as np from collections import defaultdict from math import radians from pathlib import Path -from typing import TYPE_CHECKING, NamedTuple, Optional +from typing import TYPE_CHECKING, Any, NamedTuple, Optional import bpy import ifcopenshell import ifcopenshell.api.document 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 import bonsai.bim.schema import bonsai.core.aggregate @@ -45,7 +48,11 @@ import bonsai.tool as tool from bonsai.bim.ifc import IfcStore if TYPE_CHECKING: - from bonsai.bim.module.project.prop import BIMProjectProperties, MeasureToolSettings + from bonsai.bim.module.project.prop import ( + BIMProjectProperties, + Link, + MeasureToolSettings, + ) HiearchyDict = dict[ifcopenshell.entity_instance, "HiearchyDict"] @@ -61,20 +68,20 @@ class Project(bonsai.core.tool.Project): return scene.MeasureToolSettings # pyright: ignore[reportAttributeAccessIssue] @classmethod - def get_link_empty_handle(cls, link) -> bpy.types.Object | None: + def get_link_empty_handle(cls, link: Link) -> bpy.types.Object | None: if tool.Ifc.get(): return tool.Ifc.get_object(tool.Ifc.get().by_id(link.ifc_definition_id)) return link.empty_handle @classmethod - def set_link_empty_handle(cls, link, empty: bpy.types.Object) -> None: + def set_link_empty_handle(cls, link: Link, empty: bpy.types.Object) -> None: if tool.Ifc.get(): tool.Ifc.link(tool.Ifc.get().by_id(link.ifc_definition_id), empty) else: link.empty_handle = empty @classmethod - def calculate_link_matrix(cls, link) -> None: + def calculate_link_matrix(cls, link: Link) -> Matrix: filepath = Path(tool.Ifc.resolve_uri(link.filepath)) with open(filepath.with_suffix(".ifc.cache.json"), "r") as f: metadata = json.load(f) @@ -99,7 +106,7 @@ class Project(bonsai.core.tool.Project): rot = ifcopenshell.util.shape_builder.np_rotation_matrix(radians(-float(gprops.model_project_north)), 4, "Z") local_matrix = rot @ np.eye(4) local_matrix[:, 3][:3] = [float(o) for o in gprops.model_origin_si.split(",")] - return np.linalg.inv(local_matrix) @ global_matrix + return Matrix(np.linalg.inv(local_matrix) @ global_matrix) @classmethod def append_all_types_from_template(cls, template: str) -> None: @@ -496,8 +503,8 @@ class Project(bonsai.core.tool.Project): ) @classmethod - def get_clipping_planes_normals(cls): - normals = [] + def get_clipping_planes_normals(cls) -> list[tuple[Vector, Vector]]: + normals: list[tuple[Vector, Vector]] = [] for clipping_plane in tool.Project.get_project_props().clipping_planes: plane = clipping_plane.obj if not plane or not plane.data: @@ -506,6 +513,7 @@ class Project(bonsai.core.tool.Project): if plane.mode == "EDIT": continue # A profile decorator or something else is used here. + assert isinstance(plane.data, bpy.types.Mesh) v1 = plane.matrix_world @ plane.data.vertices[0].co v2 = plane.matrix_world @ plane.data.vertices[1].co v3 = plane.matrix_world @ plane.data.vertices[2].co @@ -588,3 +596,281 @@ class Project(bonsai.core.tool.Project): ifc_file = tool.Ifc.get() ifcopenshell.api.document.remove_information(ifc_file, information=doc) + + class Link: + """Tools for working with linked models.""" + + @classmethod + def is_linked_element(cls, obj: bpy.types.Object) -> bool: + return "guids" in obj + + @classmethod + def get_obj_by_guid(cls, link: Link, guid: str) -> bpy.types.Object | None: + assert link.is_loaded + + handle = tool.Project.get_link_empty_handle(link) + assert handle + col = handle.instance_collection + assert col + + guid_obj = None + for obj in col.objects: + obj_guids: list[str] = obj["guids"] + if guid in obj_guids: + guid_obj = obj + break + + 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 = 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) + guid_polygons = mesh.polygons[slice_] + + selected_tris: list[tuple[int, ...]] = [] + selected_edges: list[tuple[int, ...]] = [] + + # Restart verts indices for our polygons. + guid_vertices_set: set[int] = set() + for polygon in guid_polygons: + guid_vertices_set.update(polygon.vertices) + vert_map = {k: v for v, k in enumerate(guid_vertices_set)} + + selected_vertices = [obj.matrix_world @ mesh.vertices[vi].co for vi in vert_map] + for polygon in guid_polygons: + selected_tris.append(tuple(vert_map[vi] for vi in polygon.vertices)) + selected_edges.extend(tuple([vert_map[vi] for vi in e]) for e in polygon.edge_keys) + + obj["selected_vertices"] = selected_vertices + 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, + context: bpy.types.Context, + obj: bpy.types.Object, + guid: str, + instance_matrix: Matrix | None = None, + ) -> None: + import sqlite3 + + from ifcpatch.recipes.ExtractPropertiesToSQLite import ( + ElementRow, + PropertyRow, + RelationshipRow, + ) + + from bonsai.bim.module.project.data import LinksData + from bonsai.bim.module.project.decorator import ProjectDecorator + + # Not sure if there's a difference between `instance_matrix` coming from `ray_cast` + # and usual `matrix_world`, maybe we can just get it from object always. + if instance_matrix is None: + instance_matrix = obj.matrix_world + + 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() + + c.execute(f"SELECT * FROM elements WHERE global_id = '{guid}' LIMIT 1") + element = ElementRow(*c.fetchone()) + + attributes: dict[str, Any] = {} + for i, attr in enumerate(["GlobalId", "IFC Class", "Predefined Type", "Name", "Description"]): + if element[i + 1] is not None: + attributes[attr] = element[i + 1] + + c.execute("SELECT * FROM properties WHERE element_id = ?", (element[0],)) + rows = [PropertyRow(*row) for row in c.fetchall()] + + properties: defaultdict[str, dict[str, str]] = defaultdict(dict) + for row in rows: + properties[row.pset_name][row.name] = row.value + + c.execute("SELECT * FROM relationships WHERE from_id = ?", (element[0],)) + relationships = [RelationshipRow(*row) for row in c.fetchall()] + + relating_type_id = None + + for relationship in relationships: + if relationship[1] == "IfcRelDefinesByType": + relating_type_id = relationship[2] + + type_properties: defaultdict[str, dict[str, str]] = defaultdict(dict) + if relating_type_id is not None: + c.execute("SELECT * FROM properties WHERE element_id = ?", (relating_type_id,)) + rows = [PropertyRow(*row) for row in c.fetchall()] + for row in rows: + type_properties[row.pset_name][row.name] = row.value + + LinksData.linked_data = { + "attributes": attributes, + "properties": [(k, properties[k]) for k in sorted(properties.keys())], + "type_properties": [(k, type_properties[k]) for k in sorted(type_properties.keys())], + } + db.close() + + assert context.screen + for area in context.screen.areas: + if area.type == "PROPERTIES": + for region in area.regions: + if region.type == "WINDOW": + region.tag_redraw() + elif area.type == "VIEW_3D": + area.tag_redraw() + + 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) + for o in bpy.data.objects: + if ( + o.type != "EMPTY" + or o.instance_type != "COLLECTION" + or o.instance_collection not in collections + or not np.allclose(matrix, o.matrix_world, atol=1e-4) + ): + 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/bonsai/pyproject.toml b/src/bonsai/pyproject.toml index 398687a715..4ee183cba0 100644 --- a/src/bonsai/pyproject.toml +++ b/src/bonsai/pyproject.toml @@ -38,6 +38,12 @@ exclude = ["test*"] [tool.ruff] extend = "../../pyproject.toml" -lint.select = [ +lint.extend-select = [ "F401", # unused imports ] + +[tool.ruff.lint.isort] +known-first-party = [ + "test", + "bonsai", +] diff --git a/src/bonsai/test/tool/test_blender.py b/src/bonsai/test/tool/test_blender.py index 97e68b9d08..cd155b5dee 100644 --- a/src/bonsai/test/tool/test_blender.py +++ b/src/bonsai/test/tool/test_blender.py @@ -16,6 +16,8 @@ # You should have received a copy of the GNU General Public License # along with Bonsai. If not, see . +import tempfile +from pathlib import Path from typing import TYPE_CHECKING import bpy @@ -24,10 +26,8 @@ import pytest import bonsai.core.tool import bonsai.tool as tool -import tempfile from bonsai.tool.blender import Blender as subject from test.bim.bootstrap import NewFile -from pathlib import Path if TYPE_CHECKING: import bpy.stub_internal.rna_enums as rna_enums diff --git a/src/bonsai/test/tool/test_drawing.py b/src/bonsai/test/tool/test_drawing.py index 3fa2e68cae..06091ff8c4 100644 --- a/src/bonsai/test/tool/test_drawing.py +++ b/src/bonsai/test/tool/test_drawing.py @@ -21,7 +21,6 @@ import xml.etree.ElementTree as ET from pathlib import Path import bpy -import pytest import ifcopenshell import ifcopenshell.api.drawing import ifcopenshell.api.group @@ -31,8 +30,9 @@ import ifcopenshell.guid import ifcopenshell.util.element import mathutils import numpy as np -from mathutils import Vector +import pytest from ifcopenshell.util.shape_builder import ShapeBuilder +from mathutils import Vector import bonsai.core.tool import bonsai.tool as tool diff --git a/src/bonsai/test/tool/test_project.py b/src/bonsai/test/tool/test_project.py index 43dc61be8e..c6d1c410de 100644 --- a/src/bonsai/test/tool/test_project.py +++ b/src/bonsai/test/tool/test_project.py @@ -16,12 +16,12 @@ # You should have received a copy of the GNU General Public License # along with Bonsai. If not, see . -import json import contextlib +import json import tempfile -import numpy as np from pathlib import Path from tempfile import NamedTemporaryFile +from typing import cast import bpy import ifcopenshell @@ -30,6 +30,7 @@ import ifcopenshell.api.document import ifcopenshell.api.root import ifcopenshell.api.unit import ifcpatch +import numpy as np from ifcpatch.recipes import Ifc2Sql import bonsai.core.tool @@ -399,3 +400,50 @@ class TestLoadingIfcSqlite(NewFile): for element_name in elements_without_meshes: assert element_name in bpy.data.objects assert not bpy.data.objects[element_name].data + + +class TestGettingLinkedElementGeomSlice: + def __init__(self): + self.test_get_first_element() + self.test_get_middle_element() + self.test_skip_hidden_first_element() + self.test_skip_hidden_middle_element() + self.test_handle_hidden_non_first_element() + + TEST_OBJ = { + "guids": ["aaa", "bbb", "ccc"], + "guid_ids": [5, 10, 15], + } + + def test_get_first_element(self): + obj = TestGettingLinkedElementGeomSlice.TEST_OBJ + obj = cast(bpy.types.Object, obj) + slice_ = subject.Link.get_linked_element_geom_slice(obj, "aaa") + assert range(15)[slice_] == range(5) + + def test_get_middle_element(self): + obj = TestGettingLinkedElementGeomSlice.TEST_OBJ + obj = cast(bpy.types.Object, obj) + slice_ = subject.Link.get_linked_element_geom_slice(obj, "bbb") + assert range(15)[slice_] == range(5, 10) + + def test_skip_hidden_first_element(self): + obj = TestGettingLinkedElementGeomSlice.TEST_OBJ + obj = obj | {"hidden_indices": [0]} + obj = cast(bpy.types.Object, obj) + slice_ = subject.Link.get_linked_element_geom_slice(obj, "bbb") + assert range(15)[slice_] == range(5) + + def test_skip_hidden_middle_element(self): + obj = TestGettingLinkedElementGeomSlice.TEST_OBJ + obj = obj | {"hidden_indices": [1]} + obj = cast(bpy.types.Object, obj) + slice_ = subject.Link.get_linked_element_geom_slice(obj, "ccc") + assert range(15)[slice_] == range(5, 10) + + def test_handle_hidden_non_first_element(self): + obj = TestGettingLinkedElementGeomSlice.TEST_OBJ + obj = obj | {"hidden_indices": [1]} + obj = cast(bpy.types.Object, obj) + slice_ = subject.Link.get_linked_element_geom_slice(obj, "aaa") + assert range(15)[slice_] == range(5) diff --git a/src/ifcgeom/mapping/IfcSectionedSolidHorizontal.cpp b/src/ifcgeom/mapping/IfcSectionedSolidHorizontal.cpp index ad1e2e0d4c..f2556092b4 100644 --- a/src/ifcgeom/mapping/IfcSectionedSolidHorizontal.cpp +++ b/src/ifcgeom/mapping/IfcSectionedSolidHorizontal.cpp @@ -61,9 +61,16 @@ taxonomy::ptr mapping::map_impl(const IfcSchema::IfcSectionedSolidHorizontal* in longitudes.push_back(*pbde->DistanceAlong()->as(true) * length_unit_); - auto linear_placement = taxonomy::cast(map(csp)); - profile_offsets.push_back(linear_placement->ccomponents().block<3, 1>(0, 3)); - boost::optional rot(linear_placement->ccomponents().block<3,3>(0,0)); + Eigen::Vector3d po( + pbde->OffsetLateral().get_value_or(0.), + // @todo I don't understand whether vertical is an offset relative to the tangent plane or to the global XY plane + pbde->OffsetVertical().get_value_or(0.), + 0.); + + profile_offsets.push_back(po); + + auto axis2_placement_linear = taxonomy::cast(map(csp)); + boost::optional rot(axis2_placement_linear->ccomponents().block<3, 3>(0, 0)); profile_rotations.push_back(rot); } if (faces.size() != profile_offsets.size()) { diff --git a/src/ifcgeom/mapping/IfcSectionedSurface.cpp b/src/ifcgeom/mapping/IfcSectionedSurface.cpp index b91b7b62c2..efdeda5757 100644 --- a/src/ifcgeom/mapping/IfcSectionedSurface.cpp +++ b/src/ifcgeom/mapping/IfcSectionedSurface.cpp @@ -63,11 +63,18 @@ taxonomy::ptr mapping::map_impl(const IfcSchema::IfcSectionedSurface* inst) { longitudes.push_back(*pbde->DistanceAlong()->as(true) * length_unit_); - auto linear_placement = taxonomy::cast(map(csp)); - profile_offsets.push_back(linear_placement->ccomponents().block<3, 1>(0, 3)); - boost::optional rot(linear_placement->ccomponents().block<3, 3>(0, 0)); - profile_rotations.push_back(rot); - } + Eigen::Vector3d po( + pbde->OffsetLateral().get_value_or(0.), + // @todo I don't understand whether vertical is an offset relative to the tangent plane or to the global XY plane + pbde->OffsetVertical().get_value_or(0.), + 0.); + + profile_offsets.push_back(po); + + auto axis2_placement_linear = taxonomy::cast(map(csp)); + boost::optional rot(axis2_placement_linear->ccomponents().block<3, 3>(0, 0)); + profile_rotations.push_back(rot); + } #else return nullptr; #endif diff --git a/src/ifcgeom/taxonomy.cpp b/src/ifcgeom/taxonomy.cpp index 7d509db8cb..2ce115dfd1 100644 --- a/src/ifcgeom/taxonomy.cpp +++ b/src/ifcgeom/taxonomy.cpp @@ -543,8 +543,8 @@ piecewise_function::const_ptr offset_function::get_offset() const { return offse ifcopenshell::geometry::taxonomy::collection::ptr ifcopenshell::geometry::flatten(const taxonomy::collection::ptr& deep) { auto flat = make(); ifcopenshell::geometry::visit(deep, [&flat](taxonomy::ptr i) { - flat->children.push_back(taxonomy::cast(clone(i))); - }); + flat->children.push_back(std::static_pointer_cast(clone(i))); + }); return flat; } diff --git a/src/ifcgeom/taxonomy.h b/src/ifcgeom/taxonomy.h index ab8a222aee..5d70d4490c 100644 --- a/src/ifcgeom/taxonomy.h +++ b/src/ifcgeom/taxonomy.h @@ -1622,25 +1622,19 @@ typedef item const* ptr; for (auto& i : deep->children) { // @todo Sad... now that we have templated collection members, // we can't generally use collection_base anymore as a cast target. - if (auto s = taxonomy::dcast(i)) { + if (auto s = std::dynamic_pointer_cast(i)) { visit(s, fn); - } - else if (auto s = taxonomy::dcast(i)) { + } else if (auto s = std::dynamic_pointer_cast(i)) { visit(s, fn); - } - else if (auto s = taxonomy::dcast(i)) { + } else if (auto s = std::dynamic_pointer_cast(i)) { visit(s, fn); - } - else if (auto s = taxonomy::dcast(i)) { + } else if (auto s = std::dynamic_pointer_cast(i)) { visit(s, fn); - } - else if (auto s = taxonomy::dcast(i)) { + } else if (auto s = std::dynamic_pointer_cast(i)) { visit(s, fn); - } - else if (auto s = taxonomy::dcast(i)) { + } else if (auto s = std::dynamic_pointer_cast(i)) { visit(s, fn); - } - else if (auto s = taxonomy::dcast(i)) { + } else if (auto s = std::dynamic_pointer_cast(i)) { visit(s, fn); } else { diff --git a/src/ifcopenshell-python/Makefile b/src/ifcopenshell-python/Makefile index b6cfe15b17..90c503d145 100644 --- a/src/ifcopenshell-python/Makefile +++ b/src/ifcopenshell-python/Makefile @@ -76,6 +76,7 @@ IFCCONVERT_URL:=https://s3.amazonaws.com/ifcopenshell-builds/IfcConvert-v$(BINAR .PHONY: build-urls build-urls: @echo "You can provide one of the 4 platforms (linux64, macos64, macosm164, win64) using 'PLATFORM=xxx'." + @echo "And Python version using 'PYNUMBER=xx' (e.g. 'PYNUMBER=311')." @echo ${IOS_URL} @echo ${IFCCONVERT_URL} 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") diff --git a/src/ifcopenshell-python/ifcopenshell/util/geolocation.py b/src/ifcopenshell-python/ifcopenshell/util/geolocation.py index 42aa28ca51..47c8886691 100644 --- a/src/ifcopenshell-python/ifcopenshell/util/geolocation.py +++ b/src/ifcopenshell-python/ifcopenshell/util/geolocation.py @@ -18,7 +18,7 @@ import math from decimal import ROUND_HALF_UP, Decimal -from typing import NamedTuple, Optional, Union, Any +from typing import Any, NamedTuple, Optional, Union import numpy as np diff --git a/src/ifcopenshell-python/scripts/dev_environment.py b/src/ifcopenshell-python/scripts/dev_environment.py index db65020048..8058a58b92 100644 --- a/src/ifcopenshell-python/scripts/dev_environment.py +++ b/src/ifcopenshell-python/scripts/dev_environment.py @@ -11,6 +11,7 @@ import site from pathlib import Path SITE = Path(site.getusersitepackages()) +SITE.mkdir(parents=True, exist_ok=True) REPO_PATH = Path(__file__).parent.parent.parent.parent REPO_PATH_SRC = REPO_PATH / "src" assert REPO_PATH_SRC.exists(), f"'{REPO_PATH_SRC}' doesn't exist." @@ -36,8 +37,8 @@ for package, repo_package_path in packages.items(): if package_path.exists(): # I guess it's a directory. shutil.rmtree(package_path) - package_path.symlink_to(repo_package_path, True) print(f"Symlinking {package_path} -> {repo_package_path}") + package_path.symlink_to(repo_package_path, True) PACKAGE_PATH = SITE / "ifcopenshell" diff --git a/src/ifcpatch/ifcpatch/recipes/ExtractPropertiesToSQLite.py b/src/ifcpatch/ifcpatch/recipes/ExtractPropertiesToSQLite.py index 0f9dc8544d..c91467036f 100644 --- a/src/ifcpatch/ifcpatch/recipes/ExtractPropertiesToSQLite.py +++ b/src/ifcpatch/ifcpatch/recipes/ExtractPropertiesToSQLite.py @@ -17,21 +17,47 @@ # along with IfcPatch. If not, see . +import logging import tempfile +from typing import NamedTuple import ifcopenshell.util.element +import ifcpatch + try: - import sqlite3 + import sqlite3 # noqa: F401 except: print("No SQLite support") -class Patcher: +class ElementRow(NamedTuple): + element_id: int + guid: str + class_: str + predefined_type: str | None + name: str | None + description: str | None + + +class PropertyRow(NamedTuple): + element_id: int + pset_name: str + name: str + value: str + + +class RelationshipRow(NamedTuple): + element_id: int + rel_ifc_class: str + to_id: int + + +class Patcher(ifcpatch.BasePatcher): def __init__( self, - file, - logger, + file: ifcopenshell.file, + logger: logging.Logger | None = None, ): """Extracts properties and relationships from a IFC-SPF model to SQLite. @@ -45,10 +71,11 @@ class Patcher: result = ifcpatch.execute({"input": fn, "file": model, "recipe": "ExtractPropertiesToSQLite"}) ifcpatch.write(result, "output.sqlite") """ - self.file = file - self.logger = logger + super().__init__(file, logger) def patch(self): + import sqlite3 + tmp = tempfile.NamedTemporaryFile(delete=False) db_file = tmp.name self.db = sqlite3.connect(db_file) @@ -90,20 +117,21 @@ class Patcher: elements = self.file.by_type("IfcObjectDefinition") - rows = [] - properties = [] - relationships = [] + rows: list[ElementRow] = [] + properties: list[PropertyRow] = [] + relationships: list[RelationshipRow] = [] id_map = {e.id(): i for i, e in enumerate(elements)} + for i, element in enumerate(elements): rows.append( - [ + ElementRow( i, element[0], # IfcRoot.GlobalId element.is_a(), ifcopenshell.util.element.get_predefined_type(element), element[2], # IfcRoot.Name element[3], # IfcRoot.Description - ] + ) ) psets = ifcopenshell.util.element.get_psets(element, should_inherit=False) for pset_name, pset_data in psets.items(): @@ -114,49 +142,55 @@ class Patcher: value = "True" if value else "False" elif not isinstance(value, str): value = str(value) - properties.append([i, pset_name, prop_name, value]) + properties.append(PropertyRow(i, pset_name, prop_name, value)) material = ifcopenshell.util.element.get_material(element, should_skip_usage=True) if material: name = getattr(material, "Name", getattr(material, "LayerSetName", None)) or "Unnamed" - properties.append([i, "IFC Material", "Name", name]) - properties.append([i, "IFC Material", "Class", material.is_a()]) + properties.append(PropertyRow(i, "IFC Material", "Name", name)) + properties.append(PropertyRow(i, "IFC Material", "Class", material.is_a())) if material.is_a("IfcMaterial"): materials = [] elif material.is_a("IfcMaterialLayerSet"): for idx, item in enumerate(material.MaterialLayers or []): material = item.Material - properties.append([i, "IFC Material", f"Layer {idx + 1} Name", getattr(item, "Name", None)]) - properties.append([i, "IFC Material", f"Layer {idx + 1} Material", material.Name]) + properties.append( + PropertyRow(i, "IFC Material", f"Layer {idx + 1} Name", getattr(item, "Name", None)) + ) + properties.append(PropertyRow(i, "IFC Material", f"Layer {idx + 1} Material", material.Name)) if category := getattr(material, "Category", None): - properties.append([i, "IFC Material", f"Layer {idx + 1} Category", category]) + properties.append(PropertyRow(i, "IFC Material", f"Layer {idx + 1} Category", category)) elif material.is_a("IfcMaterialProfileSet"): for idx, item in enumerate(material.MaterialProfiles or []): material = item.Material - properties.append([i, "IFC Material", f"Profile {idx + 1} Name", item.Name]) - properties.append([i, "IFC Material", f"Profile {idx + 1} Material", material.Name]) + properties.append(PropertyRow(i, "IFC Material", f"Profile {idx + 1} Name", item.Name)) + properties.append(PropertyRow(i, "IFC Material", f"Profile {idx + 1} Material", material.Name)) if category := getattr(material, "Category", None): - properties.append([i, "IFC Material", f"Profile {idx + 1} Category", category]) + properties.append(PropertyRow(i, "IFC Material", f"Profile {idx + 1} Category", category)) elif material.is_a("IfcMaterialConstituentSet"): for idx, item in enumerate(material.MaterialConstituents or []): material = item.Material - properties.append([i, "IFC Material", f"Constituent {idx + 1} Name", item.Name]) - properties.append([i, "IFC Material", f"Constituent {idx + 1} Material", material.Name]) + properties.append(PropertyRow(i, "IFC Material", f"Constituent {idx + 1} Name", item.Name)) + properties.append( + PropertyRow(i, "IFC Material", f"Constituent {idx + 1} Material", material.Name) + ) if category := getattr(material, "Category", None): - properties.append([i, "IFC Material", f"Constituent {idx + 1} Category", category]) + properties.append( + PropertyRow(i, "IFC Material", f"Constituent {idx + 1} Category", category) + ) elif material.is_a("IfcMaterialList"): for idx, material in enumerate(material.Materials): - properties.append([i, "IFC Material", f"Material {idx + 1} Name", material.Name]) + properties.append(PropertyRow(i, "IFC Material", f"Material {idx + 1} Name", material.Name)) if category := getattr(material, "Category", None): - properties.append([i, "IFC Material", f"Material {idx + 1} Category", category]) + properties.append(PropertyRow(i, "IFC Material", f"Material {idx + 1} Category", category)) layers = ifcopenshell.util.element.get_layers(self.file, element) for idx, layer in enumerate(layers): - properties.append([i, "IFC Presentation Layer Assignment", f"Layer {idx + 1}", layer.Name]) + properties.append(PropertyRow(i, "IFC Presentation Layer Assignment", f"Layer {idx + 1}", layer.Name)) relating_type = ifcopenshell.util.element.get_type(element) if relating_type and relating_type != element: - relationships.append([i, "IfcRelDefinesByType", id_map[relating_type.id()]]) + relationships.append(RelationshipRow(i, "IfcRelDefinesByType", id_map[relating_type.id()])) self.c.executemany("INSERT INTO elements VALUES (?, ?, ?, ?, ?, ?);", rows) self.c.executemany("INSERT INTO properties VALUES (?, ?, ?, ?);", properties) diff --git a/src/ifcpatch/ifcpatch/recipes/Ifc2Sql.py b/src/ifcpatch/ifcpatch/recipes/Ifc2Sql.py index 256ffa99ce..d1405e9a33 100644 --- a/src/ifcpatch/ifcpatch/recipes/Ifc2Sql.py +++ b/src/ifcpatch/ifcpatch/recipes/Ifc2Sql.py @@ -125,7 +125,6 @@ class Patcher(ifcpatch.BasePatcher): ) """ super().__init__(file, logger) - self.logger = logger self.sql_type: Literal["sqlite", "mysql"] = sql_type.lower() self.host = host self.username = username diff --git a/src/ifcpatch/ifcpatch/recipes/MergeProjects.py b/src/ifcpatch/ifcpatch/recipes/MergeProjects.py index ffb8e2e65b..9d20164424 100644 --- a/src/ifcpatch/ifcpatch/recipes/MergeProjects.py +++ b/src/ifcpatch/ifcpatch/recipes/MergeProjects.py @@ -16,20 +16,28 @@ # You should have received a copy of the GNU Lesser General Public License # along with IfcPatch. If not, see . +from collections.abc import Sequence from logging import Logger from typing import Union +import ifcpatch import ifcopenshell import ifcopenshell.util.element import ifcopenshell.util.geolocation import ifcopenshell.util.unit import numpy as np +import ifcpatch from ifcpatch.recipes.SetFalseOrigin import Patcher as SetFalseOrigin -class Patcher: - def __init__(self, file: ifcopenshell.file, logger: Logger, filepaths: list[Union[str, ifcopenshell.file]]): +class Patcher(ifcpatch.BasePatcher): + def __init__( + self, + file: ifcopenshell.file, + logger: Logger | None = None, + filepaths: Sequence[Union[str, ifcopenshell.file]] = (), + ): """Merge two or more IFC models into one Note that other than combining the two (or more) IfcProject elements into @@ -50,8 +58,7 @@ class Patcher: ifcpatch.execute({"input": "input.ifc", "file": model, "recipe": "MergeProjects", "arguments": ["/path/to/model2.ifc"]}) """ - self.file = file - self.logger = logger + super().__init__(file, logger) self.filepaths = filepaths def patch(self): @@ -61,6 +68,8 @@ class Patcher: "replace it with a list of file/filepaths." ) self.filepaths = [self.filepaths] + if len(self.filepaths) == 0: + raise ValueError("At least one file/filepath must be provided to merge with the main model.") for filepath in self.filepaths: if isinstance(filepath, ifcopenshell.file): other = filepath