Merge branch 'v0.8.0' into ifcmax/initial-refresh

This commit is contained in:
Josef Wienerroither
2026-03-07 08:21:43 +01:00
27 changed files with 666 additions and 231 deletions
@@ -18,6 +18,7 @@
import hashlib import hashlib
import json import json
import logging
import multiprocessing import multiprocessing
import os import os
import shutil import shutil
@@ -39,7 +40,6 @@ from typing import (
import bmesh import bmesh
import bpy import bpy
import logging
import ifcopenshell import ifcopenshell
import ifcopenshell.api.document import ifcopenshell.api.document
import ifcopenshell.api.pset import ifcopenshell.api.pset
@@ -58,9 +58,9 @@ from bpy_extras.io_utils import ImportHelper
from lxml import etree from lxml import etree
from mathutils import Color, Vector from mathutils import Color, Vector
import bonsai.bim.import_ifc
import bonsai.bim.export_ifc import bonsai.bim.export_ifc
import bonsai.bim.handler import bonsai.bim.handler
import bonsai.bim.import_ifc
import bonsai.bim.module.drawing.sheeter as sheeter import bonsai.bim.module.drawing.sheeter as sheeter
import bonsai.bim.module.drawing.svgwriter as svgwriter import bonsai.bim.module.drawing.svgwriter as svgwriter
import bonsai.core.drawing as core import bonsai.core.drawing as core
@@ -46,6 +46,7 @@ classes = (
operator.EnableEditingLink, operator.EnableEditingLink,
operator.ExportIFC, operator.ExportIFC,
operator.FlipClippingPlane, operator.FlipClippingPlane,
operator.HideQueriedLinkedElement,
operator.IFCFileHandlerOperator, operator.IFCFileHandlerOperator,
operator.ImageScalingTool, operator.ImageScalingTool,
operator.LinkIfc, operator.LinkIfc,
@@ -66,6 +67,7 @@ classes = (
operator.RewindLibrary, operator.RewindLibrary,
operator.SaveLibraryFile, operator.SaveLibraryFile,
operator.SelectLibraryFile, operator.SelectLibraryFile,
operator.SelectLinkedModelElement,
operator.SelectLinkHandle, operator.SelectLinkHandle,
operator.ToggleFilterCategories, operator.ToggleFilterCategories,
operator.ToggleLinkSelectability, operator.ToggleLinkSelectability,
+1 -1
View File
@@ -170,6 +170,6 @@ class ProjectLibraryData:
class LinksData: class LinksData:
linked_data = {} linked_data: dict[str, Any] = {}
enable_culling = False enable_culling = False
is_loaded = False is_loaded = False
@@ -16,8 +16,6 @@
# You should have received a copy of the GNU General Public License # You should have received a copy of the GNU General Public License
# along with Bonsai. If not, see <http://www.gnu.org/licenses/>. # along with Bonsai. If not, see <http://www.gnu.org/licenses/>.
from typing import Union
import bmesh import bmesh
import bpy import bpy
import gpu import gpu
@@ -54,7 +52,7 @@ class ProjectDecorator:
installed = None installed = None
@classmethod @classmethod
def install(cls, context): def install(cls, context: bpy.types.Context) -> None:
if cls.installed: if cls.installed:
cls.uninstall() cls.uninstall()
handler = cls() handler = cls()
@@ -99,26 +97,20 @@ class ProjectDecorator:
# general shader # general shader
self.shader = gpu.shader.from_builtin("UNIFORM_COLOR") self.shader = gpu.shader.from_builtin("UNIFORM_COLOR")
selected_vertices = []
selected_edges = []
selected_tris = []
props = tool.Project.get_project_props() props = tool.Project.get_project_props()
try: obj = props.queried_obj
obj = props.queried_obj if obj is None:
selected_vertices = obj["selected_vertices"]
selected_edges = obj["selected_edges"]
selected_tris = obj["selected_tris"]
except:
return 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: if root_obj and not (m := root_obj.matrix_world).is_identity:
selected_vertices = [m @ Vector(v) for v in selected_vertices] selected_vertices = [m @ Vector(v) for v in selected_vertices]
if selected_edges: if geom.selected_edges:
self.draw_batch("LINES", selected_vertices, selected_elements_color, 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), selected_tris) self.draw_batch("TRIS", selected_vertices, transparent_color(selected_elements_color), geom.selected_tris)
class ClippingPlaneDecorator: class ClippingPlaneDecorator:
+137 -133
View File
@@ -32,6 +32,7 @@ from typing import TYPE_CHECKING, Literal, Union, get_args
import bpy import bpy
import ifcopenshell import ifcopenshell
import ifcopenshell.api.attribute import ifcopenshell.api.attribute
import ifcopenshell.api.document
import ifcopenshell.api.nest import ifcopenshell.api.nest
import ifcopenshell.api.project import ifcopenshell.api.project
import ifcopenshell.api.root import ifcopenshell.api.root
@@ -48,7 +49,11 @@ import ifcopenshell.util.unit
import numpy as np import numpy as np
from bpy.app.handlers import persistent from bpy.app.handlers import persistent
from bpy_extras.io_utils import ExportHelper, ImportHelper 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.handler
import bonsai.bim.helper import bonsai.bim.helper
@@ -56,14 +61,6 @@ import bonsai.core.project as core
import bonsai.tool as tool import bonsai.tool as tool
from bonsai.bim import export_ifc, import_ifc from bonsai.bim import export_ifc, import_ifc
from bonsai.bim.ifc import IfcStore 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.decorator import FaceAreaDecorator, PolylineDecorator
from bonsai.bim.module.model.polyline import PolylineOperator from bonsai.bim.module.model.polyline import PolylineOperator
from bonsai.bim.module.project.data import LinksData, ProjectLibraryData 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_label = "Load Link"
bl_options = {"REGISTER", "UNDO"} bl_options = {"REGISTER", "UNDO"}
bl_description = "Load the selected file" 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): def _execute(self, context):
self.link = tool.Project.get_project_props().links[self.link_index] 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 = bpy.data.objects.new(empty_name, None)
empty.instance_type = "COLLECTION" empty.instance_type = "COLLECTION"
empty.instance_collection = 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) tool.Project.set_link_empty_handle(self.link, empty)
assert bpy.context.scene
bpy.context.scene.collection.objects.link(empty) bpy.context.scene.collection.objects.link(empty)
self.link.is_loaded = True self.link.is_loaded = True
if tool.Ifc.get(): # For non-IFC projects, locking has no meaning 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_label = "Toggle Link Visibility"
bl_options = {"REGISTER", "UNDO"} bl_options = {"REGISTER", "UNDO"}
bl_description = "Toggle visibility between SOLID and WIREFRAME" 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): def execute(self, context):
props = tool.Project.get_project_props() props = tool.Project.get_project_props()
@@ -1696,8 +1707,11 @@ class EnableEditingLink(bpy.types.Operator):
def execute(self, context): def execute(self, context):
link = tool.Project.get_project_props().active_link link = tool.Project.get_project_props().active_link
assert link
link.is_editing = True 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"} return {"FINISHED"}
@@ -1709,9 +1723,11 @@ class DisableEditingLink(bpy.types.Operator):
def execute(self, context): def execute(self, context):
link = tool.Project.get_project_props().active_link link = tool.Project.get_project_props().active_link
assert link
link.is_editing = False link.is_editing = False
obj = tool.Project.get_link_empty_handle(link) 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) tool.Geometry.lock_object(obj)
return {"FINISHED"} return {"FINISHED"}
@@ -1724,8 +1740,10 @@ class EditLink(bpy.types.Operator, tool.Ifc.Operator):
def _execute(self, context): def _execute(self, context):
link = tool.Project.get_project_props().active_link link = tool.Project.get_project_props().active_link
assert link
link.is_editing = False link.is_editing = False
obj = tool.Project.get_link_empty_handle(link) obj = tool.Project.get_link_empty_handle(link)
assert obj
new_obj_matrix = obj.matrix_world new_obj_matrix = obj.matrix_world
filepath = Path(tool.Ifc.resolve_uri(link.filepath)) filepath = Path(tool.Ifc.resolve_uri(link.filepath))
@@ -1761,7 +1779,7 @@ class EditLink(bpy.types.Operator, tool.Ifc.Operator):
else: else:
link.transformation = transformation 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) tool.Geometry.lock_object(obj)
@@ -1783,6 +1801,43 @@ class SelectLinkHandle(bpy.types.Operator):
return {"FINISHED"} 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): class ExportIFC(bpy.types.Operator, ExportHelper):
bl_idname = "bim.save_project" bl_idname = "bim.save_project"
bl_label = "Save IFC" bl_label = "Save IFC"
@@ -2264,21 +2319,16 @@ class QueryLinkedElement(bpy.types.Operator):
@classmethod @classmethod
def poll(cls, context): def poll(cls, context):
assert context.area
return context.area.type == "VIEW_3D" return context.area.type == "VIEW_3D"
def execute(self, context): def execute(self, context) -> set["rna_enums.OperatorReturnItems"]:
import sqlite3
from bpy_extras.view3d_utils import (
region_2d_to_origin_3d,
region_2d_to_vector_3d,
)
LinksData.linked_data = {} LinksData.linked_data = {}
props = tool.Project.get_project_props() props = tool.Project.get_project_props()
props.queried_obj = None props.queried_obj = None
for area in bpy.context.screen.areas: assert context.screen
for area in context.screen.areas:
if area.type == "PROPERTIES": if area.type == "PROPERTIES":
for region in area.regions: for region in area.regions:
if region.type == "WINDOW": if region.type == "WINDOW":
@@ -2286,128 +2336,89 @@ class QueryLinkedElement(bpy.types.Operator):
elif area.type == "VIEW_3D": elif area.type == "VIEW_3D":
area.tag_redraw() area.tag_redraw()
assert context.region and context.region_data
region = context.region region = context.region
rv3d = context.region_data rv3d = context.region_data
coord = (self.mouse_x, self.mouse_y) coord = (self.mouse_x, self.mouse_y)
origin = region_2d_to_origin_3d(region, rv3d, coord) origin = region_2d_to_origin_3d(region, rv3d, coord)
direction = region_2d_to_vector_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: if not hit:
self.report({"INFO"}, "No object found.") self.report({"INFO"}, "No object found.")
return {"FINISHED"} 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.") self.report({"INFO"}, "Object is not a linked IFC element.")
return {"FINISHED"} return {"FINISHED"}
guid = None guid = tool.Project.Link.get_guid_by_face_index(obj, face_index)
guid_start_index = 0 assert guid is not None
for i, guid_end_index in enumerate(obj["guid_ids"]): tool.Project.Link.select_linked_element(context, obj, guid)
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()
self.report({"INFO"}, f"Loaded data for {guid}") self.report({"INFO"}, f"Loaded data for {guid}")
ProjectDecorator.install(bpy.context) ProjectDecorator.install(bpy.context)
return {"FINISHED"} 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): def invoke(self, context, event):
self.mouse_x = event.mouse_region_x self.mouse_x = event.mouse_region_x
self.mouse_y = event.mouse_region_y self.mouse_y = event.mouse_region_y
return self.execute(context) 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): class AppendInspectedLinkedElement(AppendLibraryElement):
bl_idname = "bim.append_inspected_linked_element" bl_idname = "bim.append_inspected_linked_element"
bl_label = "Append Inspected Linked Element" bl_label = "Append Inspected Linked Element"
bl_description = "Append inspected linked element" bl_description = "Append inspected linked element"
bl_options = {"REGISTER"} bl_options = {"REGISTER", "UNDO"}
def _execute(self, context): def _execute(self, context):
from bonsai.bim.module.project.data import LinksData from bonsai.bim.module.project.data import LinksData
@@ -2423,6 +2434,7 @@ class AppendInspectedLinkedElement(AppendLibraryElement):
return {"CANCELLED"} return {"CANCELLED"}
queried_obj = props.queried_obj queried_obj = props.queried_obj
assert queried_obj
ifc_file = tool.Ifc.get() ifc_file = tool.Ifc.get()
linked_ifc_file: ifcopenshell.file linked_ifc_file: ifcopenshell.file
@@ -2680,28 +2692,25 @@ class CreateClippingPlane(bpy.types.Operator):
bl_options = {"REGISTER", "UNDO"} bl_options = {"REGISTER", "UNDO"}
def execute(self, context): def execute(self, context):
from bpy_extras.view3d_utils import (
region_2d_to_origin_3d,
region_2d_to_vector_3d,
)
# Clean up deleted planes # Clean up deleted planes
props = tool.Project.get_project_props() props = tool.Project.get_project_props()
if len(props.clipping_planes) > 5: if len(props.clipping_planes) > 5:
self.report({"INFO"}, "Maximum of six clipping planes allowed.") self.report({"INFO"}, "Maximum of six clipping planes allowed.")
return {"FINISHED"} return {"FINISHED"}
assert context.screen
for area in context.screen.areas: for area in context.screen.areas:
if area.type == "VIEW_3D": if area.type == "VIEW_3D":
area.tag_redraw() area.tag_redraw()
assert context.region and context.region_data
region = context.region region = context.region
rv3d = context.region_data rv3d = context.region_data
if rv3d: # Called from a 3D viewport if rv3d: # Called from a 3D viewport
coord = (self.mouse_x, self.mouse_y) coord = (self.mouse_x, self.mouse_y)
origin = region_2d_to_origin_3d(region, rv3d, coord) origin = region_2d_to_origin_3d(region, rv3d, coord)
direction = region_2d_to_vector_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: if not hit:
self.report({"INFO"}, "No object found.") self.report({"INFO"}, "No object found.")
return {"FINISHED"} return {"FINISHED"}
@@ -2736,11 +2745,6 @@ class CreateClippingPlane(bpy.types.Operator):
bpy.ops.bim.refresh_clipping_planes("INVOKE_DEFAULT") bpy.ops.bim.refresh_clipping_planes("INVOKE_DEFAULT")
return {"FINISHED"} 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): def invoke(self, context, event):
self.mouse_x = event.mouse_region_x self.mouse_x = event.mouse_region_x
self.mouse_y = event.mouse_region_y self.mouse_y = event.mouse_region_y
@@ -2839,7 +2843,7 @@ class IFCFileHandlerOperator(bpy.types.Operator):
def clean_up_path(path: str) -> str: def clean_up_path(path: str) -> str:
# In Blender 4.5.6 there was a bug producing unncesseary double slash prefix # 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 # https://projects.blender.org/blender/blender/issues/153822
if bpy.app.version == (4, 5, 6): if bpy.app.version == (4, 5, 6):
blender_prefix = "//" blender_prefix = "//"
@@ -452,6 +452,7 @@ class BIMProjectProperties(PropertyGroup):
) )
queried_obj: bpy.props.PointerProperty(type=bpy.types.Object) queried_obj: bpy.props.PointerProperty(type=bpy.types.Object)
queried_obj_root: 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: bpy.props.CollectionProperty(type=ObjProperty)
clipping_planes_active_index: bpy.props.IntProperty(min=0, default=0, max=5) clipping_planes_active_index: bpy.props.IntProperty(min=0, default=0, max=5)
edited_objs: bpy.props.CollectionProperty(type=EditedObj) edited_objs: bpy.props.CollectionProperty(type=EditedObj)
@@ -550,6 +551,7 @@ class BIMProjectProperties(PropertyGroup):
should_save_metadata_for_this_file: bool should_save_metadata_for_this_file: bool
queried_obj: Union[bpy.types.Object, None] queried_obj: Union[bpy.types.Object, None]
queried_obj_root: 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: bpy.types.bpy_prop_collection_idprop[ObjProperty]
clipping_planes_active_index: int clipping_planes_active_index: int
edited_objs: bpy.types.bpy_prop_collection_idprop[EditedObj] edited_objs: bpy.types.bpy_prop_collection_idprop[EditedObj]
+1 -1
View File
@@ -29,7 +29,6 @@ import bonsai.tool as tool
from bonsai.bim.helper import draw_attributes, prop_with_search from bonsai.bim.helper import draw_attributes, prop_with_search
from bonsai.bim.ifc import IfcStore from bonsai.bim.ifc import IfcStore
from bonsai.bim.module.project.data import LinksData, ProjectData from bonsai.bim.module.project.data import LinksData, ProjectData
from typing import TYPE_CHECKING
if TYPE_CHECKING: if TYPE_CHECKING:
from bonsai.bim.module.project.prop import ( 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") row.operator("bim.disable_editing_link", text="", icon="CANCEL")
else: else:
row.operator("bim.enable_editing_link", text="", icon="GREASEPENCIL") 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.select_link_handle", text="", icon="OBJECT_DATA").link_index = index
row.operator("bim.unload_link", text="", icon="UNLINKED").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 row.operator("bim.reload_link", text="", icon="FILE_REFRESH").link_index = index
@@ -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": "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": "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": "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 = layout.row(align=True)
row.label(text="Query Object", icon="MOUSE_RMB") row.label(text="Query Object", icon="MOUSE_RMB")
row = layout.row(align=True) row = layout.row(align=True)
@@ -61,6 +63,9 @@ class ExploreTool(bpy.types.WorkSpaceTool):
row.label(text="", icon="EVENT_ALT") row.label(text="", icon="EVENT_ALT")
row.label(text="Disable Culling" if LinksData.enable_culling else "Enable Culling", icon="EVENT_C") 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() prop = tool.Project.get_measure_tool_settings()
row = layout.row(align=True) row = layout.row(align=True)
row.label(text="", icon="EVENT_SHIFT") row.label(text="", icon="EVENT_SHIFT")
@@ -86,6 +91,7 @@ class ExploreHotkey(bpy.types.Operator):
bl_idname = "bim.explore_hotkey" bl_idname = "bim.explore_hotkey"
bl_label = "" bl_label = ""
bl_options = {"REGISTER", "UNDO", "INTERNAL"} bl_options = {"REGISTER", "UNDO", "INTERNAL"}
hotkey: bpy.props.StringProperty() hotkey: bpy.props.StringProperty()
description: bpy.props.StringProperty() description: bpy.props.StringProperty()
@@ -137,3 +143,9 @@ class ExploreHotkey(bpy.types.Operator):
return return
bpy.ops.bim.image_scaling_tool("INVOKE_DEFAULT") 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)
+2
View File
@@ -774,6 +774,7 @@ class BIM_ADDON_preferences(bpy.types.AddonPreferences):
bsdd_load_preview_dictionaries: bool bsdd_load_preview_dictionaries: bool
bsdd_load_inactive_dictionaries: bool bsdd_load_inactive_dictionaries: bool
bsdd_load_test_dictionaries: bool bsdd_load_test_dictionaries: bool
bsdd_baseurl: str
should_disable_undo_on_save: bool should_disable_undo_on_save: bool
should_stream: bool should_stream: bool
should_always_cache: bool should_always_cache: bool
@@ -789,6 +790,7 @@ class BIM_ADDON_preferences(bpy.types.AddonPreferences):
mass_time_units_in_wizard: bool mass_time_units_in_wizard: bool
chain_filter_with_set_operations: bool chain_filter_with_set_operations: bool
save_metadata_blend_file: bool save_metadata_blend_file: bool
metadata_blend_file_suffix: str
decorator_font_scale: float decorator_font_scale: float
def draw(self, context: bpy.types.Context) -> None: def draw(self, context: bpy.types.Context) -> None:
+1 -1
View File
@@ -19,7 +19,7 @@
from __future__ import annotations from __future__ import annotations
from pathlib import Path from pathlib import Path
from typing import TYPE_CHECKING, Optional, Union from typing import TYPE_CHECKING, Literal, Optional, Union
if TYPE_CHECKING: if TYPE_CHECKING:
import bpy import bpy
+29
View File
@@ -2184,3 +2184,32 @@ class Blender(bonsai.core.tool.Blender):
for f in files for f in files
if (Path(directory) / f.name).is_file() 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
+296 -10
View File
@@ -18,21 +18,24 @@
from __future__ import annotations from __future__ import annotations
import os
import json import json
import os
import shutil import shutil
import numpy as np
from collections import defaultdict from collections import defaultdict
from math import radians from math import radians
from pathlib import Path from pathlib import Path
from typing import TYPE_CHECKING, NamedTuple, Optional from typing import TYPE_CHECKING, Any, NamedTuple, Optional
import bpy import bpy
import ifcopenshell import ifcopenshell
import ifcopenshell.api.document import ifcopenshell.api.document
import ifcopenshell.util.element import ifcopenshell.util.element
import ifcopenshell.util.representation 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 ifcopenshell.api.project.append_asset import APPENDABLE_ASSET_TYPES
from mathutils import Matrix
import bonsai.bim.schema import bonsai.bim.schema
import bonsai.core.aggregate import bonsai.core.aggregate
@@ -45,7 +48,11 @@ import bonsai.tool as tool
from bonsai.bim.ifc import IfcStore from bonsai.bim.ifc import IfcStore
if TYPE_CHECKING: 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"] HiearchyDict = dict[ifcopenshell.entity_instance, "HiearchyDict"]
@@ -61,20 +68,20 @@ class Project(bonsai.core.tool.Project):
return scene.MeasureToolSettings # pyright: ignore[reportAttributeAccessIssue] return scene.MeasureToolSettings # pyright: ignore[reportAttributeAccessIssue]
@classmethod @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(): if tool.Ifc.get():
return tool.Ifc.get_object(tool.Ifc.get().by_id(link.ifc_definition_id)) return tool.Ifc.get_object(tool.Ifc.get().by_id(link.ifc_definition_id))
return link.empty_handle return link.empty_handle
@classmethod @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(): if tool.Ifc.get():
tool.Ifc.link(tool.Ifc.get().by_id(link.ifc_definition_id), empty) tool.Ifc.link(tool.Ifc.get().by_id(link.ifc_definition_id), empty)
else: else:
link.empty_handle = empty link.empty_handle = empty
@classmethod @classmethod
def calculate_link_matrix(cls, link) -> None: def calculate_link_matrix(cls, link: Link) -> Matrix:
filepath = Path(tool.Ifc.resolve_uri(link.filepath)) filepath = Path(tool.Ifc.resolve_uri(link.filepath))
with open(filepath.with_suffix(".ifc.cache.json"), "r") as f: with open(filepath.with_suffix(".ifc.cache.json"), "r") as f:
metadata = json.load(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") rot = ifcopenshell.util.shape_builder.np_rotation_matrix(radians(-float(gprops.model_project_north)), 4, "Z")
local_matrix = rot @ np.eye(4) local_matrix = rot @ np.eye(4)
local_matrix[:, 3][:3] = [float(o) for o in gprops.model_origin_si.split(",")] 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 @classmethod
def append_all_types_from_template(cls, template: str) -> None: def append_all_types_from_template(cls, template: str) -> None:
@@ -496,8 +503,8 @@ class Project(bonsai.core.tool.Project):
) )
@classmethod @classmethod
def get_clipping_planes_normals(cls): def get_clipping_planes_normals(cls) -> list[tuple[Vector, Vector]]:
normals = [] normals: list[tuple[Vector, Vector]] = []
for clipping_plane in tool.Project.get_project_props().clipping_planes: for clipping_plane in tool.Project.get_project_props().clipping_planes:
plane = clipping_plane.obj plane = clipping_plane.obj
if not plane or not plane.data: if not plane or not plane.data:
@@ -506,6 +513,7 @@ class Project(bonsai.core.tool.Project):
if plane.mode == "EDIT": if plane.mode == "EDIT":
continue # A profile decorator or something else is used here. 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 v1 = plane.matrix_world @ plane.data.vertices[0].co
v2 = plane.matrix_world @ plane.data.vertices[1].co v2 = plane.matrix_world @ plane.data.vertices[1].co
v3 = plane.matrix_world @ plane.data.vertices[2].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() ifc_file = tool.Ifc.get()
ifcopenshell.api.document.remove_information(ifc_file, information=doc) 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"],
)
+7 -1
View File
@@ -38,6 +38,12 @@ exclude = ["test*"]
[tool.ruff] [tool.ruff]
extend = "../../pyproject.toml" extend = "../../pyproject.toml"
lint.select = [ lint.extend-select = [
"F401", # unused imports "F401", # unused imports
] ]
[tool.ruff.lint.isort]
known-first-party = [
"test",
"bonsai",
]
+2 -2
View File
@@ -16,6 +16,8 @@
# You should have received a copy of the GNU General Public License # You should have received a copy of the GNU General Public License
# along with Bonsai. If not, see <http://www.gnu.org/licenses/>. # along with Bonsai. If not, see <http://www.gnu.org/licenses/>.
import tempfile
from pathlib import Path
from typing import TYPE_CHECKING from typing import TYPE_CHECKING
import bpy import bpy
@@ -24,10 +26,8 @@ import pytest
import bonsai.core.tool import bonsai.core.tool
import bonsai.tool as tool import bonsai.tool as tool
import tempfile
from bonsai.tool.blender import Blender as subject from bonsai.tool.blender import Blender as subject
from test.bim.bootstrap import NewFile from test.bim.bootstrap import NewFile
from pathlib import Path
if TYPE_CHECKING: if TYPE_CHECKING:
import bpy.stub_internal.rna_enums as rna_enums import bpy.stub_internal.rna_enums as rna_enums
+2 -2
View File
@@ -21,7 +21,6 @@ import xml.etree.ElementTree as ET
from pathlib import Path from pathlib import Path
import bpy import bpy
import pytest
import ifcopenshell import ifcopenshell
import ifcopenshell.api.drawing import ifcopenshell.api.drawing
import ifcopenshell.api.group import ifcopenshell.api.group
@@ -31,8 +30,9 @@ import ifcopenshell.guid
import ifcopenshell.util.element import ifcopenshell.util.element
import mathutils import mathutils
import numpy as np import numpy as np
from mathutils import Vector import pytest
from ifcopenshell.util.shape_builder import ShapeBuilder from ifcopenshell.util.shape_builder import ShapeBuilder
from mathutils import Vector
import bonsai.core.tool import bonsai.core.tool
import bonsai.tool as tool import bonsai.tool as tool
+50 -2
View File
@@ -16,12 +16,12 @@
# You should have received a copy of the GNU General Public License # You should have received a copy of the GNU General Public License
# along with Bonsai. If not, see <http://www.gnu.org/licenses/>. # along with Bonsai. If not, see <http://www.gnu.org/licenses/>.
import json
import contextlib import contextlib
import json
import tempfile import tempfile
import numpy as np
from pathlib import Path from pathlib import Path
from tempfile import NamedTemporaryFile from tempfile import NamedTemporaryFile
from typing import cast
import bpy import bpy
import ifcopenshell import ifcopenshell
@@ -30,6 +30,7 @@ import ifcopenshell.api.document
import ifcopenshell.api.root import ifcopenshell.api.root
import ifcopenshell.api.unit import ifcopenshell.api.unit
import ifcpatch import ifcpatch
import numpy as np
from ifcpatch.recipes import Ifc2Sql from ifcpatch.recipes import Ifc2Sql
import bonsai.core.tool import bonsai.core.tool
@@ -399,3 +400,50 @@ class TestLoadingIfcSqlite(NewFile):
for element_name in elements_without_meshes: for element_name in elements_without_meshes:
assert element_name in bpy.data.objects assert element_name in bpy.data.objects
assert not bpy.data.objects[element_name].data 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)
@@ -61,9 +61,16 @@ taxonomy::ptr mapping::map_impl(const IfcSchema::IfcSectionedSolidHorizontal* in
longitudes.push_back(*pbde->DistanceAlong()->as<IfcSchema::IfcLengthMeasure>(true) * length_unit_); longitudes.push_back(*pbde->DistanceAlong()->as<IfcSchema::IfcLengthMeasure>(true) * length_unit_);
auto linear_placement = taxonomy::cast<taxonomy::matrix4>(map(csp)); Eigen::Vector3d po(
profile_offsets.push_back(linear_placement->ccomponents().block<3, 1>(0, 3)); pbde->OffsetLateral().get_value_or(0.),
boost::optional<Eigen::Matrix3d> rot(linear_placement->ccomponents().block<3,3>(0,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<taxonomy::matrix4>(map(csp));
boost::optional<Eigen::Matrix3d> rot(axis2_placement_linear->ccomponents().block<3, 3>(0, 0));
profile_rotations.push_back(rot); profile_rotations.push_back(rot);
} }
if (faces.size() != profile_offsets.size()) { if (faces.size() != profile_offsets.size()) {
+12 -5
View File
@@ -63,11 +63,18 @@ taxonomy::ptr mapping::map_impl(const IfcSchema::IfcSectionedSurface* inst) {
longitudes.push_back(*pbde->DistanceAlong()->as<IfcSchema::IfcLengthMeasure>(true) * length_unit_); longitudes.push_back(*pbde->DistanceAlong()->as<IfcSchema::IfcLengthMeasure>(true) * length_unit_);
auto linear_placement = taxonomy::cast<taxonomy::matrix4>(map(csp)); Eigen::Vector3d po(
profile_offsets.push_back(linear_placement->ccomponents().block<3, 1>(0, 3)); pbde->OffsetLateral().get_value_or(0.),
boost::optional<Eigen::Matrix3d> rot(linear_placement->ccomponents().block<3, 3>(0, 0)); // @todo I don't understand whether vertical is an offset relative to the tangent plane or to the global XY plane
profile_rotations.push_back(rot); pbde->OffsetVertical().get_value_or(0.),
} 0.);
profile_offsets.push_back(po);
auto axis2_placement_linear = taxonomy::cast<taxonomy::matrix4>(map(csp));
boost::optional<Eigen::Matrix3d> rot(axis2_placement_linear->ccomponents().block<3, 3>(0, 0));
profile_rotations.push_back(rot);
}
#else #else
return nullptr; return nullptr;
#endif #endif
+2 -2
View File
@@ -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) { ifcopenshell::geometry::taxonomy::collection::ptr ifcopenshell::geometry::flatten(const taxonomy::collection::ptr& deep) {
auto flat = make<taxonomy::collection>(); auto flat = make<taxonomy::collection>();
ifcopenshell::geometry::visit<taxonomy::collection>(deep, [&flat](taxonomy::ptr i) { ifcopenshell::geometry::visit<taxonomy::collection>(deep, [&flat](taxonomy::ptr i) {
flat->children.push_back(taxonomy::cast<taxonomy::geom_item>(clone(i))); flat->children.push_back(std::static_pointer_cast<taxonomy::geom_item>(clone(i)));
}); });
return flat; return flat;
} }
+7 -13
View File
@@ -1622,25 +1622,19 @@ typedef item const* ptr;
for (auto& i : deep->children) { for (auto& i : deep->children) {
// @todo Sad... now that we have templated collection members, // @todo Sad... now that we have templated collection members,
// we can't generally use collection_base anymore as a cast target. // we can't generally use collection_base anymore as a cast target.
if (auto s = taxonomy::dcast<taxonomy::collection>(i)) { if (auto s = std::dynamic_pointer_cast<taxonomy::collection>(i)) {
visit<taxonomy::collection>(s, fn); visit<taxonomy::collection>(s, fn);
} } else if (auto s = std::dynamic_pointer_cast<taxonomy::loop>(i)) {
else if (auto s = taxonomy::dcast<taxonomy::loop>(i)) {
visit<taxonomy::loop>(s, fn); visit<taxonomy::loop>(s, fn);
} } else if (auto s = std::dynamic_pointer_cast<taxonomy::face>(i)) {
else if (auto s = taxonomy::dcast<taxonomy::face>(i)) {
visit<taxonomy::face>(s, fn); visit<taxonomy::face>(s, fn);
} } else if (auto s = std::dynamic_pointer_cast<taxonomy::shell>(i)) {
else if (auto s = taxonomy::dcast<taxonomy::shell>(i)) {
visit<taxonomy::shell>(s, fn); visit<taxonomy::shell>(s, fn);
} } else if (auto s = std::dynamic_pointer_cast<taxonomy::solid>(i)) {
else if (auto s = taxonomy::dcast<taxonomy::solid>(i)) {
visit<taxonomy::solid>(s, fn); visit<taxonomy::solid>(s, fn);
} } else if (auto s = std::dynamic_pointer_cast<taxonomy::loft>(i)) {
else if (auto s = taxonomy::dcast<taxonomy::loft>(i)) {
visit<taxonomy::loft>(s, fn); visit<taxonomy::loft>(s, fn);
} } else if (auto s = std::dynamic_pointer_cast<taxonomy::boolean_result>(i)) {
else if (auto s = taxonomy::dcast<taxonomy::boolean_result>(i)) {
visit<taxonomy::boolean_result>(s, fn); visit<taxonomy::boolean_result>(s, fn);
} }
else { else {
+1
View File
@@ -76,6 +76,7 @@ IFCCONVERT_URL:=https://s3.amazonaws.com/ifcopenshell-builds/IfcConvert-v$(BINAR
.PHONY: build-urls .PHONY: build-urls
build-urls: build-urls:
@echo "You can provide one of the 4 platforms (linux64, macos64, macosm164, win64) using 'PLATFORM=xxx'." @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 ${IOS_URL}
@echo ${IFCCONVERT_URL} @echo ${IFCCONVERT_URL}
@@ -40,7 +40,7 @@ APPENDABLE_ASSET = Literal[
"IfcProfileDef", "IfcProfileDef",
"IfcPresentationStyle", "IfcPresentationStyle",
] ]
APPENDABLE_ASSET_TYPES = get_args(APPENDABLE_ASSET) APPENDABLE_ASSET_TYPES: tuple[APPENDABLE_ASSET, ...] = get_args(APPENDABLE_ASSET)
MATERIAL_SETS = ("IfcMaterialLayerSet", "IfcMaterialConstituentSet", "IfcMaterialProfileSet") MATERIAL_SETS = ("IfcMaterialLayerSet", "IfcMaterialConstituentSet", "IfcMaterialProfileSet")
@@ -18,7 +18,7 @@
import math import math
from decimal import ROUND_HALF_UP, Decimal 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 import numpy as np
@@ -11,6 +11,7 @@ import site
from pathlib import Path from pathlib import Path
SITE = Path(site.getusersitepackages()) SITE = Path(site.getusersitepackages())
SITE.mkdir(parents=True, exist_ok=True)
REPO_PATH = Path(__file__).parent.parent.parent.parent REPO_PATH = Path(__file__).parent.parent.parent.parent
REPO_PATH_SRC = REPO_PATH / "src" REPO_PATH_SRC = REPO_PATH / "src"
assert REPO_PATH_SRC.exists(), f"'{REPO_PATH_SRC}' doesn't exist." 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(): if package_path.exists():
# I guess it's a directory. # I guess it's a directory.
shutil.rmtree(package_path) shutil.rmtree(package_path)
package_path.symlink_to(repo_package_path, True)
print(f"Symlinking {package_path} -> {repo_package_path}") print(f"Symlinking {package_path} -> {repo_package_path}")
package_path.symlink_to(repo_package_path, True)
PACKAGE_PATH = SITE / "ifcopenshell" PACKAGE_PATH = SITE / "ifcopenshell"
@@ -17,21 +17,47 @@
# along with IfcPatch. If not, see <http://www.gnu.org/licenses/>. # along with IfcPatch. If not, see <http://www.gnu.org/licenses/>.
import logging
import tempfile import tempfile
from typing import NamedTuple
import ifcopenshell.util.element import ifcopenshell.util.element
import ifcpatch
try: try:
import sqlite3 import sqlite3 # noqa: F401
except: except:
print("No SQLite support") 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__( def __init__(
self, self,
file, file: ifcopenshell.file,
logger, logger: logging.Logger | None = None,
): ):
"""Extracts properties and relationships from a IFC-SPF model to SQLite. """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"}) result = ifcpatch.execute({"input": fn, "file": model, "recipe": "ExtractPropertiesToSQLite"})
ifcpatch.write(result, "output.sqlite") ifcpatch.write(result, "output.sqlite")
""" """
self.file = file super().__init__(file, logger)
self.logger = logger
def patch(self): def patch(self):
import sqlite3
tmp = tempfile.NamedTemporaryFile(delete=False) tmp = tempfile.NamedTemporaryFile(delete=False)
db_file = tmp.name db_file = tmp.name
self.db = sqlite3.connect(db_file) self.db = sqlite3.connect(db_file)
@@ -90,20 +117,21 @@ class Patcher:
elements = self.file.by_type("IfcObjectDefinition") elements = self.file.by_type("IfcObjectDefinition")
rows = [] rows: list[ElementRow] = []
properties = [] properties: list[PropertyRow] = []
relationships = [] relationships: list[RelationshipRow] = []
id_map = {e.id(): i for i, e in enumerate(elements)} id_map = {e.id(): i for i, e in enumerate(elements)}
for i, element in enumerate(elements): for i, element in enumerate(elements):
rows.append( rows.append(
[ ElementRow(
i, i,
element[0], # IfcRoot.GlobalId element[0], # IfcRoot.GlobalId
element.is_a(), element.is_a(),
ifcopenshell.util.element.get_predefined_type(element), ifcopenshell.util.element.get_predefined_type(element),
element[2], # IfcRoot.Name element[2], # IfcRoot.Name
element[3], # IfcRoot.Description element[3], # IfcRoot.Description
] )
) )
psets = ifcopenshell.util.element.get_psets(element, should_inherit=False) psets = ifcopenshell.util.element.get_psets(element, should_inherit=False)
for pset_name, pset_data in psets.items(): for pset_name, pset_data in psets.items():
@@ -114,49 +142,55 @@ class Patcher:
value = "True" if value else "False" value = "True" if value else "False"
elif not isinstance(value, str): elif not isinstance(value, str):
value = str(value) 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) material = ifcopenshell.util.element.get_material(element, should_skip_usage=True)
if material: if material:
name = getattr(material, "Name", getattr(material, "LayerSetName", None)) or "Unnamed" name = getattr(material, "Name", getattr(material, "LayerSetName", None)) or "Unnamed"
properties.append([i, "IFC Material", "Name", name]) properties.append(PropertyRow(i, "IFC Material", "Name", name))
properties.append([i, "IFC Material", "Class", material.is_a()]) properties.append(PropertyRow(i, "IFC Material", "Class", material.is_a()))
if material.is_a("IfcMaterial"): if material.is_a("IfcMaterial"):
materials = [] materials = []
elif material.is_a("IfcMaterialLayerSet"): elif material.is_a("IfcMaterialLayerSet"):
for idx, item in enumerate(material.MaterialLayers or []): for idx, item in enumerate(material.MaterialLayers or []):
material = item.Material material = item.Material
properties.append([i, "IFC Material", f"Layer {idx + 1} Name", getattr(item, "Name", None)]) properties.append(
properties.append([i, "IFC Material", f"Layer {idx + 1} Material", material.Name]) 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): 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"): elif material.is_a("IfcMaterialProfileSet"):
for idx, item in enumerate(material.MaterialProfiles or []): for idx, item in enumerate(material.MaterialProfiles or []):
material = item.Material material = item.Material
properties.append([i, "IFC Material", f"Profile {idx + 1} Name", item.Name]) properties.append(PropertyRow(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} Material", material.Name))
if category := getattr(material, "Category", None): 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"): elif material.is_a("IfcMaterialConstituentSet"):
for idx, item in enumerate(material.MaterialConstituents or []): for idx, item in enumerate(material.MaterialConstituents or []):
material = item.Material material = item.Material
properties.append([i, "IFC Material", f"Constituent {idx + 1} Name", item.Name]) properties.append(PropertyRow(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} Material", material.Name)
)
if category := getattr(material, "Category", None): 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"): elif material.is_a("IfcMaterialList"):
for idx, material in enumerate(material.Materials): 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): 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) layers = ifcopenshell.util.element.get_layers(self.file, element)
for idx, layer in enumerate(layers): 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) relating_type = ifcopenshell.util.element.get_type(element)
if relating_type and relating_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 elements VALUES (?, ?, ?, ?, ?, ?);", rows)
self.c.executemany("INSERT INTO properties VALUES (?, ?, ?, ?);", properties) self.c.executemany("INSERT INTO properties VALUES (?, ?, ?, ?);", properties)
-1
View File
@@ -125,7 +125,6 @@ class Patcher(ifcpatch.BasePatcher):
) )
""" """
super().__init__(file, logger) super().__init__(file, logger)
self.logger = logger
self.sql_type: Literal["sqlite", "mysql"] = sql_type.lower() self.sql_type: Literal["sqlite", "mysql"] = sql_type.lower()
self.host = host self.host = host
self.username = username self.username = username
+13 -4
View File
@@ -16,20 +16,28 @@
# You should have received a copy of the GNU Lesser General Public License # You should have received a copy of the GNU Lesser General Public License
# along with IfcPatch. If not, see <http://www.gnu.org/licenses/>. # along with IfcPatch. If not, see <http://www.gnu.org/licenses/>.
from collections.abc import Sequence
from logging import Logger from logging import Logger
from typing import Union from typing import Union
import ifcpatch
import ifcopenshell import ifcopenshell
import ifcopenshell.util.element import ifcopenshell.util.element
import ifcopenshell.util.geolocation import ifcopenshell.util.geolocation
import ifcopenshell.util.unit import ifcopenshell.util.unit
import numpy as np import numpy as np
import ifcpatch
from ifcpatch.recipes.SetFalseOrigin import Patcher as SetFalseOrigin from ifcpatch.recipes.SetFalseOrigin import Patcher as SetFalseOrigin
class Patcher: class Patcher(ifcpatch.BasePatcher):
def __init__(self, file: ifcopenshell.file, logger: Logger, filepaths: list[Union[str, ifcopenshell.file]]): def __init__(
self,
file: ifcopenshell.file,
logger: Logger | None = None,
filepaths: Sequence[Union[str, ifcopenshell.file]] = (),
):
"""Merge two or more IFC models into one """Merge two or more IFC models into one
Note that other than combining the two (or more) IfcProject elements into 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"]}) ifcpatch.execute({"input": "input.ifc", "file": model, "recipe": "MergeProjects", "arguments": ["/path/to/model2.ifc"]})
""" """
self.file = file super().__init__(file, logger)
self.logger = logger
self.filepaths = filepaths self.filepaths = filepaths
def patch(self): def patch(self):
@@ -61,6 +68,8 @@ class Patcher:
"replace it with a list of file/filepaths." "replace it with a list of file/filepaths."
) )
self.filepaths = [self.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: for filepath in self.filepaths:
if isinstance(filepath, ifcopenshell.file): if isinstance(filepath, ifcopenshell.file):
other = filepath other = filepath