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