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 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
@@ -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,
+1 -1
View File
@@ -170,6 +170,6 @@ class ProjectLibraryData:
class LinksData:
linked_data = {}
linked_data: dict[str, Any] = {}
enable_culling = False
is_loaded = False
@@ -16,8 +16,6 @@
# You should have received a copy of the GNU General Public License
# along with Bonsai. If not, see <http://www.gnu.org/licenses/>.
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:
+137 -133
View File
@@ -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 = "//"
@@ -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]
+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.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
@@ -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)
+2
View File
@@ -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:
+1 -1
View File
@@ -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
+29
View File
@@ -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
+296 -10
View File
@@ -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"],
)
+7 -1
View File
@@ -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",
]
+2 -2
View File
@@ -16,6 +16,8 @@
# You should have received a copy of the GNU General Public License
# along with Bonsai. If not, see <http://www.gnu.org/licenses/>.
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
+2 -2
View File
@@ -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
+50 -2
View File
@@ -16,12 +16,12 @@
# You should have received a copy of the GNU General Public License
# along with Bonsai. If not, see <http://www.gnu.org/licenses/>.
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)
@@ -61,9 +61,16 @@ taxonomy::ptr mapping::map_impl(const IfcSchema::IfcSectionedSolidHorizontal* in
longitudes.push_back(*pbde->DistanceAlong()->as<IfcSchema::IfcLengthMeasure>(true) * length_unit_);
auto linear_placement = taxonomy::cast<taxonomy::matrix4>(map(csp));
profile_offsets.push_back(linear_placement->ccomponents().block<3, 1>(0, 3));
boost::optional<Eigen::Matrix3d> 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<taxonomy::matrix4>(map(csp));
boost::optional<Eigen::Matrix3d> rot(axis2_placement_linear->ccomponents().block<3, 3>(0, 0));
profile_rotations.push_back(rot);
}
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_);
auto linear_placement = taxonomy::cast<taxonomy::matrix4>(map(csp));
profile_offsets.push_back(linear_placement->ccomponents().block<3, 1>(0, 3));
boost::optional<Eigen::Matrix3d> 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<taxonomy::matrix4>(map(csp));
boost::optional<Eigen::Matrix3d> rot(axis2_placement_linear->ccomponents().block<3, 3>(0, 0));
profile_rotations.push_back(rot);
}
#else
return nullptr;
#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) {
auto flat = make<taxonomy::collection>();
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;
}
+7 -13
View File
@@ -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<taxonomy::collection>(i)) {
if (auto s = std::dynamic_pointer_cast<taxonomy::collection>(i)) {
visit<taxonomy::collection>(s, fn);
}
else if (auto s = taxonomy::dcast<taxonomy::loop>(i)) {
} else if (auto s = std::dynamic_pointer_cast<taxonomy::loop>(i)) {
visit<taxonomy::loop>(s, fn);
}
else if (auto s = taxonomy::dcast<taxonomy::face>(i)) {
} else if (auto s = std::dynamic_pointer_cast<taxonomy::face>(i)) {
visit<taxonomy::face>(s, fn);
}
else if (auto s = taxonomy::dcast<taxonomy::shell>(i)) {
} else if (auto s = std::dynamic_pointer_cast<taxonomy::shell>(i)) {
visit<taxonomy::shell>(s, fn);
}
else if (auto s = taxonomy::dcast<taxonomy::solid>(i)) {
} else if (auto s = std::dynamic_pointer_cast<taxonomy::solid>(i)) {
visit<taxonomy::solid>(s, fn);
}
else if (auto s = taxonomy::dcast<taxonomy::loft>(i)) {
} else if (auto s = std::dynamic_pointer_cast<taxonomy::loft>(i)) {
visit<taxonomy::loft>(s, fn);
}
else if (auto s = taxonomy::dcast<taxonomy::boolean_result>(i)) {
} else if (auto s = std::dynamic_pointer_cast<taxonomy::boolean_result>(i)) {
visit<taxonomy::boolean_result>(s, fn);
}
else {
+1
View File
@@ -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}
@@ -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")
@@ -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
@@ -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"
@@ -17,21 +17,47 @@
# along with IfcPatch. If not, see <http://www.gnu.org/licenses/>.
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)
-1
View File
@@ -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
+13 -4
View File
@@ -16,20 +16,28 @@
# You should have received a copy of the GNU Lesser General Public License
# along with IfcPatch. If not, see <http://www.gnu.org/licenses/>.
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