diff --git a/.gitignore b/.gitignore
index bee04658cd..8a7482ab8e 100644
--- a/.gitignore
+++ b/.gitignore
@@ -82,6 +82,8 @@ src/bonsai/bonsai/translations.py
# bonsai test temp files
src/bonsai/test/files/temp
+src/bonsai/test/files/basic.ifc.cache.blend
+src/bonsai/test/files/basic.ifc.cache.sqlite
# bonsai data
src/bonsai/bonsai/bim/data/build/
diff --git a/src/bonsai/bonsai/bim/export_ifc.py b/src/bonsai/bonsai/bim/export_ifc.py
index 7295c72e34..cfab523942 100644
--- a/src/bonsai/bonsai/bim/export_ifc.py
+++ b/src/bonsai/bonsai/bim/export_ifc.py
@@ -52,7 +52,6 @@ class IfcExporter:
self.set_header()
IfcStore.update_cache()
self.sync_all_objects()
- tool.Project.save_linked_models_to_ifc()
extension = self.ifc_export_settings.output_file.split(".")[-1].lower()
if extension == "ifczip":
with tempfile.TemporaryDirectory() as unzipped_path:
diff --git a/src/bonsai/bonsai/bim/module/drawing/operator.py b/src/bonsai/bonsai/bim/module/drawing/operator.py
index d02e4c6c76..db389427e6 100644
--- a/src/bonsai/bonsai/bim/module/drawing/operator.py
+++ b/src/bonsai/bonsai/bim/module/drawing/operator.py
@@ -928,7 +928,7 @@ class CreateDrawing(bpy.types.Operator):
props = tool.Project.get_project_props()
for link in props.get_loaded_links_for_drawings():
- files[link.name] = self.get_linked_file(link)
+ files[link.filepath] = self.get_linked_file(link)
target_view = ifcopenshell.util.element.get_psets(self.camera_element)["EPset_Drawing"]["TargetView"]
self.setup_serialiser(target_view)
@@ -1374,7 +1374,7 @@ class CreateDrawing(bpy.types.Operator):
return True
def get_linked_file(self, link: "Link") -> ifcopenshell.file:
- link_path = link.name
+ link_path = link.filepath
ifc_file = IfcStore.session_files.get(link_path, None)
if ifc_file is not None:
return ifc_file
diff --git a/src/bonsai/bonsai/bim/module/drawing/ui.py b/src/bonsai/bonsai/bim/module/drawing/ui.py
index 556459b8b6..dc98715686 100644
--- a/src/bonsai/bonsai/bim/module/drawing/ui.py
+++ b/src/bonsai/bonsai/bim/module/drawing/ui.py
@@ -89,7 +89,7 @@ class BIM_PT_camera(Panel):
for link in links:
row = panel.row(align=True)
split = row.split(factor=0.9)
- split.label(text=link.name, icon="FILE")
+ split.label(text=link.filepath, icon="FILE")
split.prop(link, "include_in_drawings", text="")
else:
panel.label(text="No IFC projects linked and loaded.")
diff --git a/src/bonsai/bonsai/bim/module/geometry/operator.py b/src/bonsai/bonsai/bim/module/geometry/operator.py
index fbd694d219..7937acca30 100644
--- a/src/bonsai/bonsai/bim/module/geometry/operator.py
+++ b/src/bonsai/bonsai/bim/module/geometry/operator.py
@@ -902,6 +902,7 @@ class OverrideDelete(bpy.types.Operator):
if not is_valid_data_block:
continue
+
element = tool.Ifc.get_entity(obj)
if element:
if tool.Geometry.is_locked(element):
@@ -912,6 +913,9 @@ class OverrideDelete(bpy.types.Operator):
if ifcopenshell.util.element.get_pset(element, "BBIM_Array"):
self.report({"INFO"}, "Elements that are part of an array cannot be deleted.")
continue
+ if element.is_a("IfcDocumentReference"):
+ self.report({"INFO"}, "Linked models cannot be deleted.")
+ continue
if element.is_a("IfcGridAxis"):
# Deleting the last W axis is OK
if ((grid := element.PartOfU) and len(grid[0].UAxes) == 1) or (
@@ -1208,6 +1212,11 @@ class OverrideDuplicateMove(bpy.types.Operator):
operator.report({"ERROR"}, f"Drawing '{obj.name}' not duplicated.")
continue
+ if element.is_a("IfcDocumentReference"):
+ objects_to_remove.add(obj)
+ operator.report({"ERROR"}, f"Linked model '{obj.name}' not duplicated.")
+ continue
+
if tool.Geometry.is_locked(element):
objects_to_remove.add(obj)
operator.report({"ERROR"}, lock_error_message(obj.name))
diff --git a/src/bonsai/bonsai/bim/module/georeference/operator.py b/src/bonsai/bonsai/bim/module/georeference/operator.py
index 6c82e6684c..c2af3d9225 100644
--- a/src/bonsai/bonsai/bim/module/georeference/operator.py
+++ b/src/bonsai/bonsai/bim/module/georeference/operator.py
@@ -51,7 +51,7 @@ class RemoveGeoreferencing(bpy.types.Operator, tool.Ifc.Operator):
bl_description = "Remove the georeferencing"
def _execute(self, context):
- core.remove_georeferencing(tool.Ifc)
+ core.remove_georeferencing(tool.Ifc, tool.Georeference)
class EditGeoreferencing(bpy.types.Operator, tool.Ifc.Operator):
diff --git a/src/bonsai/bonsai/bim/module/georeference/prop.py b/src/bonsai/bonsai/bim/module/georeference/prop.py
index 5a1a37f06f..1f4ca321ac 100644
--- a/src/bonsai/bonsai/bim/module/georeference/prop.py
+++ b/src/bonsai/bonsai/bim/module/georeference/prop.py
@@ -224,16 +224,12 @@ class BIMGeoreferenceProperties(PropertyGroup):
x_axis_ordinate: StringProperty(name="X Axis Ordinate", update=update_grid_north_vector)
x_axis_is_null: BoolProperty(name="X Axis Is Null")
- # These are only for reference to capture data about a host model from a linked model
- # If you relink a model from a new host origin, we can autodetect it in theory with this
- host_model_origin: StringProperty(name="Host Model Origin")
- host_model_origin_si: StringProperty(name="Host Model Origin SI")
- host_model_project_north: StringProperty(name="Host Model Angle to Grid North")
-
# This is the ENH in project units and SI units of the Blender session's 0,0,0.
# These are only for reference, using tool.Georeference.set_model_origin on
# project load, project create, and when linking for the first time from an
# empty Blender session.
+ model_is_georeferenced: BoolProperty(name="Model Is Georeferenced")
+ model_crs: StringProperty(name="Model CRS")
model_origin: StringProperty(name="Model Origin")
model_origin_si: StringProperty(name="Model Origin SI")
model_project_north: StringProperty(name="Model Angle to Grid North")
@@ -275,10 +271,6 @@ class BIMGeoreferenceProperties(PropertyGroup):
x_axis_ordinate: str
x_axis_is_null: bool
- host_model_origin: str
- host_model_origin_si: str
- host_model_project_north: str
-
model_origin: str
model_origin_si: str
model_project_north: str
diff --git a/src/bonsai/bonsai/bim/module/project/__init__.py b/src/bonsai/bonsai/bim/module/project/__init__.py
index 53ebaff14e..f4f4261eb5 100644
--- a/src/bonsai/bonsai/bim/module/project/__init__.py
+++ b/src/bonsai/bonsai/bim/module/project/__init__.py
@@ -31,27 +31,31 @@ classes = (
operator.BIM_OT_load_clipping_planes,
operator.BIM_OT_save_clipping_planes,
operator.ChangeLibraryElement,
+ operator.ClearMeasurement,
operator.ClearRecentIFCProjects,
operator.CreateClippingPlane,
operator.CreateProject,
operator.DisableCulling,
operator.DisableEditingHeader,
+ operator.DisableEditingLink,
operator.EditHeader,
+ operator.EditLink,
operator.EditProjectLibrary,
operator.EnableCulling,
operator.EnableEditingHeader,
+ operator.EnableEditingLink,
operator.ExportIFC,
operator.FlipClippingPlane,
operator.IFCFileHandlerOperator,
operator.ImageScalingTool,
operator.LinkIfc,
+ operator.LoadBlendMetadataAndIFC,
operator.LoadLink,
operator.LoadLinkedProject,
operator.LoadProject,
operator.LoadProjectElements,
- operator.MeasureTool,
operator.MeasureFaceAreaTool,
- operator.ClearMeasurement,
+ operator.MeasureTool,
operator.NewProject,
operator.QueryLinkedElement,
operator.RefreshClippingPlanes,
@@ -69,7 +73,6 @@ classes = (
operator.UnassignLibraryDeclaration,
operator.UnlinkIfc,
operator.UnloadLink,
- operator.LoadBlendMetadataAndIFC,
workspace.ExploreHotkey,
prop.LibraryBreadcrumb,
prop.LibraryElement,
diff --git a/src/bonsai/bonsai/bim/module/project/operator.py b/src/bonsai/bonsai/bim/module/project/operator.py
index b6f63f13ca..e4e57275ba 100644
--- a/src/bonsai/bonsai/bim/module/project/operator.py
+++ b/src/bonsai/bonsai/bim/module/project/operator.py
@@ -18,6 +18,7 @@
import datetime
import json
+import math
import logging
import os
import subprocess
@@ -59,6 +60,15 @@ 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, degrees
+from pathlib import Path
+from collections import defaultdict
+from mathutils import Vector, Matrix
+from bpy.app.handlers import persistent
+from ifcopenshell.geom import ShapeElementType
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
@@ -1321,7 +1331,7 @@ class ToggleFilterCategories(bpy.types.Operator):
return {"FINISHED"}
-class LinkIfc(bpy.types.Operator, ImportHelper):
+class LinkIfc(bpy.types.Operator, ImportHelper, tool.Ifc.Operator):
bl_idname = "bim.link_ifc"
bl_label = "Link IFC"
bl_options = {"REGISTER", "UNDO"}
@@ -1360,134 +1370,148 @@ class LinkIfc(bpy.types.Operator, ImportHelper):
row = self.layout.row()
row.prop(pprops, "project_north")
- def execute(self, context):
+ def invoke(self, context, event):
+ pprops = tool.Project.get_project_props()
+
+ # Populate false origin and project north from 3D cursor for user convenience
+ cursor = context.scene.cursor
+ cursor_loc = cursor.location
+ cursor_rot = cursor.rotation_euler
+ angle = -cursor_rot.z
+
+ # Calculate false origin based on the provided formulas
+ # x = -3Dcursor.x * cos(angle) - 3Dcursor.y * sin(angle)
+ # y = 3Dcursor.x * sin(angle) - 3Dcursor.y * cos(angle)
+ # z = -3Dcursor.z
+ false_origin_x = -cursor_loc.x * math.cos(angle) - cursor_loc.y * math.sin(angle)
+ false_origin_y = cursor_loc.x * math.sin(angle) - cursor_loc.y * math.cos(angle)
+ false_origin_z = -cursor_loc.z
+
+ # Set the false_origin value
+ pprops.false_origin = f"{false_origin_x:.3f},{false_origin_y:.3f},{false_origin_z:.3f}"
+ pprops.project_north = str(round(math.degrees(angle), 1))
+
+ return super().invoke(context, event)
+
+ def _execute(self, context):
start = time.time()
files = [f.name for f in self.files] if self.files else [self.filepath]
+
+ if not files or all(not f or not f.strip() for f in files):
+ self.report({"ERROR"}, "No file selected")
+ return {"CANCELLED"}
+
+ existing_links = tool.Project.get_linked_models_documents() if tool.Ifc.get() else {}
for filename in files:
+ if not filename or not filename.strip():
+ continue
filepath = Path(self.directory) / filename
if bpy.data.filepath and filepath.samefile(bpy.data.filepath):
self.report({"INFO"}, "Can't link the current .blend file")
continue
props = tool.Project.get_project_props()
- new = props.links.add()
filepath = tool.Ifc.get_uri(filepath, use_relative_path=self.use_relative_path)
+
+ new = props.links.add()
+ if tool.Ifc.get():
+ if not (document := existing_links.get(filepath)):
+ document = ifcopenshell.api.document.add_information(tool.Ifc.get())
+ document.Name = Path(filepath).name
+ document.Scope = "LINKED_MODEL"
+ reference = ifcopenshell.api.document.add_reference(tool.Ifc.get(), information=document)
+ reference[1] = ",".join([str(o) for o in np.eye(4).flatten().tolist()])
+ reference.Location = filepath.replace("\\", "/")
+ new.ifc_definition_id = reference.id()
new.name = filepath
- status = bpy.ops.bim.load_link(filepath=filepath, use_cache=self.use_cache)
- if status == {"CANCELLED"}:
- error_msg = (
- f'Error processing IFC file "{filepath}" '
- "was critical and blend file either wasn't saved or wasn't updated. "
- "See logs above in system console for details."
- )
- print(error_msg)
- self.report({"ERROR"}, error_msg)
- return {"FINISHED"}
- print(f"Finished linking {len(files)} IFCs", time.time() - start)
- return {"FINISHED"}
+ new.filepath = filepath
+ bpy.ops.bim.load_link(link_index=-1, use_cache=self.use_cache)
-class UnlinkIfc(bpy.types.Operator):
+class UnlinkIfc(bpy.types.Operator, tool.Ifc.Operator):
bl_idname = "bim.unlink_ifc"
bl_label = "Unlink IFC"
bl_options = {"REGISTER", "UNDO"}
bl_description = "Remove the selected file from the link list"
- filepath: bpy.props.StringProperty()
+ link_index: bpy.props.IntProperty(name="Link Index")
- def execute(self, context):
- filepath = Path(self.filepath).as_posix()
- bpy.ops.bim.unload_link(filepath=filepath)
+ def _execute(self, context):
props = tool.Project.get_project_props()
- index = props.links.find(filepath)
- if index != -1:
- props.links.remove(index)
- return {"FINISHED"}
+ link = props.links[self.link_index]
+ bpy.ops.bim.unload_link(link_index=self.link_index)
+ if tool.Ifc.get():
+ reference = tool.Ifc.get().by_id(link.ifc_definition_id)
+ document = tool.Document.get_reference_document(reference)
+ ifcopenshell.api.document.remove_reference(tool.Ifc.get(), reference)
+ if document and not tool.Document.get_document_references(document):
+ ifcopenshell.api.document.remove_information(tool.Ifc.get(), document)
+ props.links.remove(self.link_index)
-class UnloadLink(bpy.types.Operator):
+class UnloadLink(bpy.types.Operator, tool.Ifc.Operator):
bl_idname = "bim.unload_link"
bl_label = "Unload Link"
bl_options = {"REGISTER", "UNDO"}
bl_description = "Unload the selected linked file"
- filepath: bpy.props.StringProperty()
+ link_index: bpy.props.IntProperty(name="Link Index")
- def execute(self, context):
- filepath = tool.Blender.ensure_blender_path_is_abs(Path(self.filepath))
- if filepath.suffix.lower() == ".ifc":
- filepath = filepath.with_suffix(".ifc.cache.blend")
-
- for library in list(bpy.data.libraries):
- if tool.Blender.ensure_blender_path_is_abs(Path(library.filepath)) == filepath:
+ def _execute(self, context):
+ link = tool.Project.get_project_props().links[self.link_index]
+ if obj := tool.Project.get_link_empty_handle(link):
+ collection = obj.instance_collection
+ library = collection.library
+ tool.Ifc.unlink(obj=obj)
+ bpy.data.objects.remove(obj)
+ if collection.users == 0:
+ bpy.data.collections.remove(collection)
+ if not len([c for c in bpy.data.collections if c.library == library]):
bpy.data.libraries.remove(library)
-
- props = tool.Project.get_project_props()
- links = props.links
- link = links[self.filepath]
- # Let's assume that user might delete it.
- if empty_handle := link.empty_handle:
- bpy.data.objects.remove(empty_handle)
-
- # following lines removes the library also when use_relative_path=True, otherwise it doesn't
- libraries = bpy.data.libraries
- for library in libraries:
- if library.name == self.filepath + ".cache.blend":
- bpy.data.libraries.remove(library)
-
link.is_loaded = False
-
- if not any([l.is_loaded for l in links]):
- ProjectDecorator.uninstall()
- # we make sure we don't draw queried object from the file that was just unlinked
- elif queried_obj := props.queried_obj:
- queried_filepath = Path(queried_obj["ifc_filepath"])
- if queried_filepath == filepath:
- ProjectDecorator.uninstall()
-
- return {"FINISHED"}
+ ProjectDecorator.uninstall()
-class LoadLink(bpy.types.Operator):
+class LoadLink(bpy.types.Operator, tool.Ifc.Operator):
bl_idname = "bim.load_link"
bl_label = "Load Link"
bl_options = {"REGISTER", "UNDO"}
bl_description = "Load the selected file"
- filepath: bpy.props.StringProperty(name="Link Filepath")
+ link_index: bpy.props.IntProperty(name="Link Index")
use_cache: bpy.props.BoolProperty(name="Use Cache", default=True)
- filepath_: Path
-
- def execute(self, context):
- filepath = Path(tool.Ifc.resolve_uri(self.filepath))
+ def _execute(self, context):
+ self.link = tool.Project.get_project_props().links[self.link_index]
+ filepath = Path(tool.Ifc.resolve_uri(self.link.filepath))
if not filepath.exists():
self.report({"ERROR"}, f"File does not exist: '{filepath}'")
return {"CANCELLED"}
self.filepath_ = filepath
- if filepath.suffix.lower().endswith(".blend"):
- self.link_blend(filepath)
- elif filepath.suffix.lower().endswith(".ifc"):
- status = self.link_ifc()
- if status:
- return status
- return {"FINISHED"}
+ if filepath.suffix.lower().endswith(".ifc"):
+ return self.link_ifc()
def link_blend(self, filepath: Path) -> None:
with bpy.data.libraries.load(str(filepath), link=True) as (data_from, data_to):
- data_to.scenes = data_from.scenes
- link = tool.Project.get_project_props().links[self.filepath]
- for scene in bpy.data.scenes:
- if not scene.library or Path(scene.library.filepath) != filepath:
+ data_to.collections = [c for c in data_from.collections if "IfcProject" in c]
+
+ # Find the linked collection
+ for collection in bpy.data.collections:
+ if not collection.library or Path(collection.library.filepath) != filepath:
continue
- for child in scene.collection.children:
- if "IfcProject" not in child.name:
- continue
- empty = bpy.data.objects.new(child.name, None)
- empty.instance_type = "COLLECTION"
- empty.instance_collection = child
- link.empty_handle = empty
- bpy.context.scene.collection.objects.link(empty)
- break
+ # Create unique empty instance for this link
+ empty_name = collection.name
+ 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))
+
+ tool.Project.set_link_empty_handle(self.link, empty)
+ bpy.context.scene.collection.objects.link(empty)
+ self.link.is_loaded = True
+ if tool.Ifc.get(): # For non-IFC projects, locking has no meaning
+ tool.Geometry.lock_object(empty)
+ tool.Blender.select_and_activate_single_object(bpy.context, empty)
break
- link.is_loaded = True
- tool.Blender.select_and_activate_single_object(bpy.context, empty)
+ else:
+ print(f"WARNING: No IfcProject collection found in {filepath}")
+ self.link.is_loaded = False
def link_ifc(self) -> Union[set[str], None]:
blend_filepath = self.filepath_.with_suffix(".ifc.cache.blend")
@@ -1502,14 +1526,12 @@ class LoadLink(bpy.types.Operator):
code = f"""
import bpy
+import sys
def run():
import bonsai.tool as tool
gprops = tool.Georeference.get_georeference_props()
# Our model origin becomes their host model origin
- gprops.host_model_origin = "{gprops.model_origin}"
- gprops.host_model_origin_si = "{gprops.model_origin_si}"
- gprops.host_model_project_north = "{gprops.model_project_north}"
gprops.has_blender_offset = {gprops.has_blender_offset}
gprops.blender_offset_x = "{gprops.blender_offset_x}"
gprops.blender_offset_y = "{gprops.blender_offset_y}"
@@ -1522,7 +1544,12 @@ def run():
pprops.false_origin = "{pprops.false_origin}"
pprops.project_north = "{pprops.project_north}"
# Use absolute path to be safe from cwd changes.
- bpy.ops.bim.load_linked_project(filepath=r"{str(self.filepath_)}")
+ try:
+ bpy.ops.bim.load_linked_project(filepath=r"{str(self.filepath_)}")
+ except RuntimeError as e:
+ # Operator failed (returned CANCELLED with error report)
+ print(f"Failed to load linked project: {{e}}")
+ sys.exit(1)
# Use str instead of as_posix to avoid issues with Windows shared paths.
bpy.ops.wm.save_as_mainfile(filepath=r"{str(blend_filepath)}")
@@ -1556,14 +1583,17 @@ except Exception as e:
if not blend_filepath.exists() or blend_filepath.stat().st_mtime < t:
return {"CANCELLED"}
- self.set_model_origin_from_link()
-
+ self.set_model_origin_from_link()
+ self.set_georeferencing_indicator()
self.link_blend(blend_filepath)
def set_model_origin_from_link(self) -> None:
if tool.Ifc.get():
return # The current model's coordinates always take priority.
+ if len(tool.Project.get_project_props().links) > 1:
+ return # Only the first link sets the origin
+
json_filepath = self.filepath_.with_suffix(".ifc.cache.json")
if not json_filepath.exists():
return
@@ -1576,21 +1606,36 @@ except Exception as e:
if (value := data.get(prop, None)) is not None:
setattr(gprops, prop, value)
+ def set_georeferencing_indicator(self) -> None:
+ if not tool.Ifc.get():
+ self.link.georeferenced = "NONE"
+ return
+ if not (crs_name := (ifcopenshell.util.geolocation.get_crs(tool.Ifc.get()) or {}).get("Name", "")):
+ self.link.georeferenced = "NONE"
+ return
+ reference = tool.Ifc.get().by_id(self.link.ifc_definition_id)
+ json_filepath = Path(reference.Location).with_suffix(".ifc.cache.json")
+ if not json_filepath.exists():
+ self.link.georeferenced = "NONE"
+ return
+ with open(json_filepath, "r") as f:
+ data = json.load(f)
+ if not data["model_is_georeferenced"]:
+ self.link.georeferenced = "NONE"
+ else:
+ self.link.georeferenced = "FULL_COMPATIBLE" if crs_name == data["model_crs"] else "NOT_COMPATIBLE"
+
class ReloadLink(bpy.types.Operator):
bl_idname = "bim.reload_link"
bl_label = "Reload Link"
bl_options = {"REGISTER", "UNDO"}
bl_description = "Reload the selected file"
- filepath: bpy.props.StringProperty()
+ link_index: bpy.props.IntProperty(name="Link Index")
def execute(self, context):
- is_abs = os.path.isabs(Path(self.filepath))
- use_relative_path = not is_abs
- bpy.ops.bim.unlink_ifc(filepath=self.filepath)
- filepath = tool.Ifc.resolve_uri(self.filepath)
- status = bpy.ops.bim.link_ifc(filepath=filepath, use_cache=False, use_relative_path=use_relative_path)
- return {"FINISHED"}
+ bpy.ops.bim.unload_link(link_index=self.link_index)
+ return bpy.ops.bim.load_link(link_index=self.link_index, use_cache=False) or {"FINISHED"}
class ToggleLinkSelectability(bpy.types.Operator):
@@ -1598,16 +1643,18 @@ class ToggleLinkSelectability(bpy.types.Operator):
bl_label = "Toggle Link Selectability"
bl_options = {"REGISTER", "UNDO"}
bl_description = "Toggle selectability"
- link: bpy.props.StringProperty(name="Linked IFC Filepath")
+ link_index: bpy.props.IntProperty(name="Link Index")
def execute(self, context):
props = tool.Project.get_project_props()
- link = props.links[self.link]
- self.library_filepath = tool.Blender.ensure_blender_path_is_abs(Path(self.link).with_suffix(".ifc.cache.blend"))
+ link = props.links[self.link_index]
+ self.library_filepath = tool.Blender.ensure_blender_path_is_abs(
+ Path(link.filepath).with_suffix(".ifc.cache.blend")
+ )
link.is_selectable = (is_selectable := not link.is_selectable)
for collection in self.get_linked_collections():
collection.hide_select = not is_selectable
- if handle := link.empty_handle:
+ if handle := tool.Project.get_link_empty_handle(link):
handle.hide_select = not is_selectable
return {"FINISHED"}
@@ -1624,13 +1671,15 @@ class ToggleLinkVisibility(bpy.types.Operator):
bl_label = "Toggle Link Visibility"
bl_options = {"REGISTER", "UNDO"}
bl_description = "Toggle visibility between SOLID and WIREFRAME"
- link: bpy.props.StringProperty(name="Linked IFC Filepath")
+ link_index: bpy.props.IntProperty(name="Link Index")
mode: bpy.props.EnumProperty(name="Visibility Mode", items=((i, i, "") for i in ("WIREFRAME", "VISIBLE")))
def execute(self, context):
props = tool.Project.get_project_props()
- link = props.links[self.link]
- self.library_filepath = tool.Blender.ensure_blender_path_is_abs(Path(self.link).with_suffix(".ifc.cache.blend"))
+ link = props.links[self.link_index]
+ self.library_filepath = tool.Blender.ensure_blender_path_is_abs(
+ Path(link.filepath).with_suffix(".ifc.cache.blend")
+ )
if self.mode == "WIREFRAME":
self.toggle_wireframe(link)
elif self.mode == "VISIBLE":
@@ -1652,7 +1701,7 @@ class ToggleLinkVisibility(bpy.types.Operator):
layer_collections = tool.Blender.get_layer_collections_mapping(linked_collections)
for layer_collection in layer_collections.values():
layer_collection.exclude = is_hidden
- if handle := link.empty_handle:
+ if handle := tool.Project.get_link_empty_handle(link):
handle.hide_set(is_hidden)
def get_linked_collections(self) -> list[bpy.types.Collection]:
@@ -1663,17 +1712,95 @@ class ToggleLinkVisibility(bpy.types.Operator):
]
+class EnableEditingLink(bpy.types.Operator):
+ bl_idname = "bim.enable_editing_link"
+ bl_label = "Enable Editing Link"
+ bl_options = {"REGISTER", "UNDO"}
+ bl_description = "Enable editing link location"
+
+ def execute(self, context):
+ link = tool.Project.get_project_props().active_link
+ link.is_editing = True
+ tool.Geometry.unlock_object(tool.Project.get_link_empty_handle(link))
+ return {"FINISHED"}
+
+
+class DisableEditingLink(bpy.types.Operator):
+ bl_idname = "bim.disable_editing_link"
+ bl_label = "Disable Editing Link"
+ bl_options = {"REGISTER", "UNDO"}
+ bl_description = "Disable editing link and restore to previously saved location"
+
+ def execute(self, context):
+ link = tool.Project.get_project_props().active_link
+ link.is_editing = False
+ obj = tool.Project.get_link_empty_handle(link)
+ obj.matrix_world = Matrix(tool.Project.calculate_link_matrix(link))
+ tool.Geometry.lock_object(obj)
+ return {"FINISHED"}
+
+
+class EditLink(bpy.types.Operator, tool.Ifc.Operator):
+ bl_idname = "bim.edit_link"
+ bl_label = "Edit Link"
+ bl_options = {"REGISTER", "UNDO"}
+ bl_description = "Disable editing link and restore to previously saved location"
+
+ def _execute(self, context):
+ link = tool.Project.get_project_props().active_link
+ link.is_editing = False
+ obj = tool.Project.get_link_empty_handle(link)
+ new_obj_matrix = obj.matrix_world
+
+ filepath = Path(tool.Ifc.resolve_uri(link.filepath))
+ with open(filepath.with_suffix(".ifc.cache.json"), "r") as f:
+ metadata = json.load(f)
+
+ rot = ifcopenshell.util.shape_builder.np_rotation_matrix(
+ radians(-float(metadata["model_project_north"])), 4, "Z"
+ )
+ global_matrix = rot @ np.eye(4)
+ global_matrix[:, 3][:3] = [float(o) for o in metadata["model_origin_si"].split(",")]
+
+ gprops = tool.Georeference.get_georeference_props()
+ 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(",")]
+
+ # obj_matrix is typically calculated as:
+ # obj_matrix = np.linalg.inv(local_matrix) @ transformation @ global_matrix
+ # So let's calculate the transformation
+
+ transformed_global_matrix = local_matrix @ np.array(new_obj_matrix)
+ transformation = transformed_global_matrix @ np.linalg.inv(global_matrix)
+ if np.allclose(transformation, np.eye(4)):
+ link.has_transformation = True
+ transformation = ",".join(map(str, np.eye(4).reshape(-1)))
+ else:
+ link.has_transformation = False
+ transformation = ",".join(map(str, transformation.reshape(-1)))
+
+ if tool.Ifc.get():
+ reference = tool.Ifc.get().by_id(link.ifc_definition_id)
+ reference[1] = transformation
+ else:
+ link.transformation = transformation
+
+ obj.matrix_world = Matrix(tool.Project.calculate_link_matrix(link))
+ tool.Geometry.lock_object(obj)
+
+
class SelectLinkHandle(bpy.types.Operator):
bl_idname = "bim.select_link_handle"
bl_label = "Select Link Handle"
bl_options = {"REGISTER", "UNDO"}
bl_description = "Select link empty object handle"
- index: bpy.props.IntProperty(name="Link Index")
+ link_index: bpy.props.IntProperty(name="Link Index")
def execute(self, context):
props = tool.Project.get_project_props()
- link = props.links[self.index]
- handle = link.empty_handle
+ link = props.links[self.link_index]
+ handle = tool.Project.get_link_empty_handle(link)
if not handle:
self.report({"ERROR"}, "Link has no empty handle (probably it was deleted).")
return {"CANCELLED"}
@@ -1866,7 +1993,14 @@ class LoadLinkedProject(bpy.types.Operator, ImportHelper):
print("Processing", self.filepath)
self.collection = bpy.data.collections.new("IfcProject/" + os.path.basename(self.filepath))
- self.file = ifcopenshell.open(self.filepath)
+
+ try:
+ self.file = ifcopenshell.open(self.filepath)
+ except Exception as e:
+ self.report({"ERROR"}, f"Failed to open IFC file: {str(e)}")
+ bpy.data.collections.remove(self.collection)
+ return {"CANCELLED"}
+
tool.Ifc.set(self.file)
print("Finished opening")
@@ -1897,20 +2031,13 @@ class LoadLinkedProject(bpy.types.Operator, ImportHelper):
if tool.Loader.settings.false_origin_mode == "MANUAL" and tool.Loader.settings.false_origin:
tool.Loader.set_manual_blender_offset(self.file)
elif tool.Loader.settings.false_origin_mode == "AUTOMATIC":
- if host_model_origin_si := gprops.host_model_origin_si:
- host_model_origin_si = [float(o) / self.unit_scale for o in host_model_origin_si.split(",")]
- tool.Loader.settings.false_origin = host_model_origin_si
- tool.Loader.settings.project_north = float(gprops.host_model_project_north)
- tool.Loader.set_manual_blender_offset(self.file)
- else:
- tool.Loader.guess_false_origin(self.file)
+ tool.Loader.guess_false_origin(self.file)
tool.Georeference.set_model_origin()
self.json_filepath = self.filepath + ".cache.json"
data = {
- "host_model_origin": gprops.host_model_origin,
- "host_model_origin_si": gprops.host_model_origin_si,
- "host_model_project_north": gprops.host_model_project_north,
+ "model_is_georeferenced": gprops.model_is_georeferenced,
+ "model_crs": gprops.model_crs,
"model_origin": gprops.model_origin,
"model_origin_si": gprops.model_origin_si,
"model_project_north": gprops.model_project_north,
diff --git a/src/bonsai/bonsai/bim/module/project/prop.py b/src/bonsai/bonsai/bim/module/project/prop.py
index 08859af1f9..a34b5726f7 100644
--- a/src/bonsai/bonsai/bim/module/project/prop.py
+++ b/src/bonsai/bonsai/bim/module/project/prop.py
@@ -16,8 +16,9 @@
# You should have received a copy of the GNU General Public License
# along with Bonsai. If not, see .
-import os
+import math
from collections.abc import Generator
+from pathlib import Path
from typing import TYPE_CHECKING, Literal, Union, assert_never, get_args
import bpy
@@ -219,29 +220,62 @@ class FilterCategory(PropertyGroup):
class Link(PropertyGroup):
- name: StringProperty(
- name="Name",
+ name: StringProperty(name="Name")
+ filepath: StringProperty(
+ name="Filepath",
description="Filepath to linked .ifc file, stored in posix format (could be relative to .ifc file, not to .blend)",
)
+ transformation: StringProperty(
+ name="Transformation",
+ description="4x4 matrix transformation as a flattened comma separated list for the linked model",
+ default="",
+ )
+ georeferenced: EnumProperty(
+ name="Georeferenced",
+ description="Georeferencing status: compatibility between host and linked model",
+ items=[
+ ("NONE", "No Georef", "Linked model has no georeferencing"),
+ ("NOT_COMPATIBLE", "Not Compatible", "Has geo data but CRS differ from host"),
+ ("FULL_COMPATIBLE", "Full Compatible", "Both CRS name and vertical datum match host"),
+ ],
+ default="NONE",
+ )
+ has_transformation: BoolProperty(
+ name="Has Transformation",
+ description="Whether there is a transformation from its global coordinates",
+ default=False,
+ )
is_loaded: BoolProperty(name="Is Loaded", default=False)
+ is_editing: BoolProperty(name="Is Editing", description="Whether the link is being transformed", default=False)
is_selectable: BoolProperty(name="Is Selectable", default=True)
is_wireframe: BoolProperty(name="Is Wireframe", default=False)
is_hidden: BoolProperty(name="Is Hidden", default=False)
include_in_drawings: BoolProperty(name="Include in Drawings", default=True, options=set())
empty_handle: PointerProperty(
name="Empty Object Handle",
- description="We use empty object handle to allow simple manipulations with a linked model (moving, scaling, rotating)",
+ description="Storage for empty handle. Used in non-IFC scenarios or temporarily during link creation",
type=bpy.types.Object,
)
+ ifc_definition_id: IntProperty(
+ name="IFC Definition ID",
+ description="STEP ID of the IfcDocumentReference when linked to a parent IFC project. Zero when no parent IFC exists",
+ default=0,
+ )
if TYPE_CHECKING:
name: str
+ filepath: str
+ transformation: str
+ georeferenced: Literal["NONE", "NOT_COMPATIBLE", "FULL_COMPATIBLE"]
+ has_transformation: bool
is_loaded: bool
+ is_editing: bool
is_selectable: bool
is_wireframe: bool
is_hidden: bool
include_in_drawings: bool
empty_handle: Union[bpy.types.Object, None]
+ ifc_definition_id: int
class EditedObj(PropertyGroup):
@@ -424,6 +458,10 @@ class BIMProjectProperties(PropertyGroup):
clipping_planes_active_index: bpy.props.IntProperty(min=0, default=0, max=5)
edited_objs: bpy.props.CollectionProperty(type=EditedObj)
+ @property
+ def active_link(self) -> Union[Link, None]:
+ return tool.Blender.get_active_uilist_element(self.links, self.active_link_index)
+
@property
def active_clipping_plane(self) -> ObjProperty | None:
return tool.Blender.get_active_uilist_element(self.clipping_planes, self.clipping_planes_active_index)
diff --git a/src/bonsai/bonsai/bim/module/project/ui.py b/src/bonsai/bonsai/bim/module/project/ui.py
index 79ed2c7d7a..3d02e6ca05 100644
--- a/src/bonsai/bonsai/bim/module/project/ui.py
+++ b/src/bonsai/bonsai/bim/module/project/ui.py
@@ -22,6 +22,7 @@ import os
from typing import TYPE_CHECKING
import bpy
+import math
import ifcopenshell
from bpy.types import Menu, Panel, UIList
@@ -30,6 +31,7 @@ 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 (
@@ -477,17 +479,27 @@ class BIM_PT_links(Panel):
def draw(self, context):
self.props = tool.Project.get_project_props()
+
row = self.layout.row(align=True)
row.operator("bim.link_ifc")
if self.props.links:
- self.layout.template_list(
- "BIM_UL_links",
- "",
- self.props,
- "links",
- self.props,
- "active_link_index",
- )
+ if self.props.active_link:
+ row = self.layout.row(align=True)
+ row.alignment = "RIGHT"
+ index = self.props.active_link_index
+ if self.props.active_link.is_editing:
+ row.operator("bim.edit_link", text="", icon="CHECKMARK")
+ row.operator("bim.disable_editing_link", text="", icon="CANCEL")
+ else:
+ row.operator("bim.enable_editing_link", text="", icon="GREASEPENCIL")
+ if self.props.active_link.is_loaded:
+ 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
+ else:
+ row.operator("bim.load_link", text="", icon="LINKED").link_index = index
+ row.operator("bim.unlink_ifc", text="", icon="X").link_index = index
+ self.layout.template_list("BIM_UL_links", "", self.props, "links", self.props, "active_link_index")
if LinksData.enable_culling:
row = self.layout.row(align=True)
@@ -607,47 +619,30 @@ class BIM_UL_links(UIList):
active_propname,
index,
):
- if item:
- row = layout.row(align=True)
- if item.is_loaded:
- row.label(text=item.name)
- op = row.operator(
- "bim.toggle_link_selectability",
- text="",
- icon="RESTRICT_SELECT_OFF" if item.is_selectable else "RESTRICT_SELECT_ON",
- emboss=False,
- )
- op.link = item.name
- op = row.operator(
- "bim.toggle_link_visibility",
- text="",
- icon="CUBE" if item.is_wireframe else "MESH_CUBE",
- emboss=False,
- )
- op.link = item.name
- op.mode = "WIREFRAME"
- op = row.operator(
- "bim.toggle_link_visibility",
- text="",
- icon="HIDE_ON" if item.is_hidden else "HIDE_OFF",
- emboss=False,
- )
- op.link = item.name
- op.mode = "VISIBLE"
- op = row.operator("bim.select_link_handle", text="", icon="OBJECT_DATA")
- op.index = index
- op = row.operator("bim.unload_link", text="", icon="UNLINKED")
- op.filepath = item.name
- op = row.operator("bim.reload_link", text="", icon="FILE_REFRESH")
- op.filepath = item.name
- else:
- row.prop(item, "name", text="")
- op = row.operator("bim.select_uri_attribute", text="", icon="FILE_FOLDER")
- op.attribute_data_path = tool.Blender.get_full_data_path(item, "name")
- op = row.operator("bim.load_link", text="", icon="LINKED")
- op.filepath = item.name
- op = row.operator("bim.unlink_ifc", text="", icon="X")
- op.filepath = item.name
+ row = layout.row(align=True)
+ if item.is_loaded:
+ if item.georeferenced == "NONE":
+ row.label(text="", icon="QUESTION")
+ elif item.georeferenced == "NOT_COMPATIBLE":
+ row.label(text="", icon="ERROR")
+ elif item.georeferenced == "FULL_COMPATIBLE":
+ row.label(text="", icon="WORLD")
+ if item.has_transformation:
+ row.label(text="", icon="OBJECT_ORIGIN")
+
+ row.label(text=item.filepath)
+ icon = "RESTRICT_SELECT_OFF" if item.is_selectable else "RESTRICT_SELECT_ON"
+ row.operator("bim.toggle_link_selectability", text="", icon=icon, emboss=False).link_index = index
+ icon = "CUBE" if item.is_wireframe else "MESH_CUBE"
+ op = row.operator("bim.toggle_link_visibility", text="", icon=icon, emboss=False)
+ op.link_index = index
+ op.mode = "WIREFRAME"
+ icon = "HIDE_ON" if item.is_hidden else "HIDE_OFF"
+ op = row.operator("bim.toggle_link_visibility", text="", icon=icon, emboss=False)
+ op.link_index = index
+ op.mode = "VISIBLE"
+ else:
+ row.label(text=item.filepath)
class BIM_PT_purge(Panel):
diff --git a/src/bonsai/bonsai/bim/operator.py b/src/bonsai/bonsai/bim/operator.py
index 456d5ed5a0..f332c91401 100644
--- a/src/bonsai/bonsai/bim/operator.py
+++ b/src/bonsai/bonsai/bim/operator.py
@@ -268,63 +268,45 @@ class SaveBlendMetadataFile(bpy.types.Operator):
import bpy
# Ensure all styles are loaded before attempting to remove them
-try:
- bpy.ops.bim.load_styles()
-except Exception:
- pass
+bpy.ops.bim.load_styles()
# 1. Collect all IfcStyle material names
ifcstyle_material_names = []
-try:
- styles_props = getattr(bpy.context.scene, "BIMStylesProperties", None)
- if styles_props is None and bpy.data.scenes:
- styles_props = getattr(bpy.data.scenes[0], "BIMStylesProperties", None)
- if styles_props:
- for style in list(styles_props.styles):
- material = getattr(style, "blender_material", None)
- if material and material.name:
- ifcstyle_material_names.append(material.name)
-except Exception:
- pass
+styles_props = getattr(bpy.context.scene, "BIMStylesProperties", None)
+if styles_props is None and bpy.data.scenes:
+ styles_props = getattr(bpy.data.scenes[0], "BIMStylesProperties", None)
+if styles_props:
+ for style in list(styles_props.styles):
+ material = getattr(style, "blender_material", None)
+ if material and material.name:
+ ifcstyle_material_names.append(material.name)
# 2. Purge IfcStore
-try:
- from bonsai.bim.ifc import IfcStore
-except ImportError:
- IfcStore = None
-
-if IfcStore:
- try:
- IfcStore.purge()
- except Exception:
- pass
+from bonsai.bim.ifc import IfcStore
+IfcStore.purge()
# 3. Remove all collections named IfcProject*
for collection in list(bpy.data.collections):
if collection.name.startswith('IfcProject'):
- try:
- bpy.data.collections.remove(collection, do_unlink=True)
- except Exception:
- pass
+ bpy.data.collections.remove(collection, do_unlink=True)
-# 4. Purge orphaned data blocks after removing IfcProject collections
-try:
- bpy.ops.outliner.orphans_purge(do_local_ids=True, do_linked_ids=True, do_recursive=True)
-except Exception:
- pass
+# 4.1 Remove all collections from linked libraries (they will be recreated by bonsai)
+for collection in list(bpy.data.collections):
+ if collection.library:
+ bpy.data.collections.remove(collection, do_unlink=True)
-# 5. Remove all materials corresponding to the IfcStyles we collected
-materials_removed = 0
-try:
- for mat_name in ifcstyle_material_names:
- if mat_name in bpy.data.materials:
- try:
- bpy.data.materials.remove(bpy.data.materials[mat_name], do_unlink=True)
- materials_removed += 1
- except Exception:
- pass
-except Exception:
- pass
+# 4.2. Remove all empty objects that are collection instances for linked models
+for obj in list(bpy.data.objects):
+ if obj.type == 'EMPTY' and obj.instance_type == 'COLLECTION' and obj.name.startswith('IfcProject/'):
+ bpy.data.objects.remove(obj, do_unlink=True)
+
+# 5. Purge orphaned data blocks after removing IfcProject collections
+bpy.ops.outliner.orphans_purge(do_local_ids=True, do_linked_ids=True, do_recursive=True)
+
+# 6. Remove all materials corresponding to the IfcStyles we collected
+for mat_name in ifcstyle_material_names:
+ if mat_name in bpy.data.materials:
+ bpy.data.materials.remove(bpy.data.materials[mat_name], do_unlink=True)
bpy.ops.wm.save_as_mainfile(filepath=r'{blendmetadata_path}')
"""
diff --git a/src/bonsai/bonsai/core/georeference.py b/src/bonsai/bonsai/core/georeference.py
index e74b5f577d..442c928b4e 100644
--- a/src/bonsai/bonsai/core/georeference.py
+++ b/src/bonsai/bonsai/core/georeference.py
@@ -29,6 +29,7 @@ if TYPE_CHECKING:
def add_georeferencing(georeference: type[tool.Georeference]) -> None:
georeference.add_georeferencing()
+ georeference.set_model_origin()
def enable_editing_georeferencing(georeference: type[tool.Georeference]) -> None:
@@ -37,8 +38,9 @@ def enable_editing_georeferencing(georeference: type[tool.Georeference]) -> None
georeference.enable_editing()
-def remove_georeferencing(ifc: type[tool.Ifc]) -> None:
+def remove_georeferencing(ifc: type[tool.Ifc], georeference: type[tool.Georeference]) -> None:
ifc.run("georeference.remove_georeferencing")
+ georeference.set_model_origin()
def disable_editing_georeferencing(georeference: type[tool.Georeference]) -> None:
diff --git a/src/bonsai/bonsai/tool/document.py b/src/bonsai/bonsai/tool/document.py
index 5d0aaf1a8e..910ca92cf4 100644
--- a/src/bonsai/bonsai/tool/document.py
+++ b/src/bonsai/bonsai/tool/document.py
@@ -251,11 +251,19 @@ class Document(bonsai.core.tool.Document):
def get_document_references(
cls, document: ifcopenshell.entity_instance
) -> tuple[ifcopenshell.entity_instance, ...]:
+ # TODO: migrate to util.document and replace all instances
"""Get IfcDocumentReference.ReferencedDocuments, compatible with IFC2X3."""
if document.file.schema == "IFC2X3":
return document.DocumentReferences or ()
return document.HasDocumentReferences
+ @classmethod
+ def get_reference_document(cls, reference: ifcopenshell.entity_instance) -> ifcopenshell.entity_instance | None:
+ # TODO: migrate to util.document and replace all instances
+ if reference.file.schema == "IFC2X3":
+ return (reference.ReferenceToDocument or (None))[0]
+ return reference.ReferencedDocument
+
@classmethod
def clear_active_document(cls) -> None:
props = cls.get_document_props()
diff --git a/src/bonsai/bonsai/tool/georeference.py b/src/bonsai/bonsai/tool/georeference.py
index eefdd12cd9..3fa3e51f62 100644
--- a/src/bonsai/bonsai/tool/georeference.py
+++ b/src/bonsai/bonsai/tool/georeference.py
@@ -315,6 +315,21 @@ class Georeference(bonsai.core.tool.Georeference):
)
return coordinates
+ @classmethod
+ def global2local(cls, matrix, is_specified_in_map_units: bool) -> tuple[float, float, float]:
+ matrix = ifcopenshell.util.geolocation.auto_global2local(tool.Ifc.get(), matrix, is_specified_in_map_units=is_specified_in_map_units)
+ props = cls.get_georeference_props()
+ if props.has_blender_offset:
+ matrix = ifcopenshell.util.geolocation.global2local(
+ matrix,
+ float(props.blender_offset_x),
+ float(props.blender_offset_y),
+ float(props.blender_offset_z),
+ float(props.blender_x_axis_abscissa),
+ float(props.blender_x_axis_ordinate),
+ )
+ return matrix
+
@classmethod
def import_plot(cls, filepath: str) -> None:
import bmesh
@@ -385,6 +400,9 @@ class Georeference(bonsai.core.tool.Georeference):
unit_scale = ifcopenshell.util.unit.calculate_unit_scale(tool.Ifc.get())
gprops = tool.Georeference.get_georeference_props()
e, n, h = cls.xyz2enh((0, 0, 0), should_return_in_map_units=False)
+ crs = ifcopenshell.util.geolocation.get_crs(tool.Ifc.get()) or {}
+ gprops.model_is_georeferenced = bool(crs)
+ gprops.model_crs = crs.get("Name", "") or ""
gprops.model_origin = f"{e},{n},{h}"
gprops.model_origin_si = f"{e * unit_scale},{n * unit_scale},{h * unit_scale}"
angle = ifcopenshell.util.geolocation.get_grid_north(tool.Ifc.get())
diff --git a/src/bonsai/bonsai/tool/project.py b/src/bonsai/bonsai/tool/project.py
index d4df4c80de..da613c3fa9 100644
--- a/src/bonsai/bonsai/tool/project.py
+++ b/src/bonsai/bonsai/tool/project.py
@@ -19,10 +19,14 @@
from __future__ import annotations
import os
+import json
+import math
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, Union
+from typing import TYPE_CHECKING, NamedTuple, Optional
import bpy
import ifcopenshell
@@ -58,6 +62,47 @@ class Project(bonsai.core.tool.Project):
assert (scene := bpy.context.scene)
return scene.MeasureToolSettings # pyright: ignore[reportAttributeAccessIssue]
+ @classmethod
+ def get_link_empty_handle(cls, 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:
+ 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:
+ filepath = Path(tool.Ifc.resolve_uri(link.filepath))
+ with open(filepath.with_suffix(".ifc.cache.json"), "r") as f:
+ metadata = json.load(f)
+
+ rot = ifcopenshell.util.shape_builder.np_rotation_matrix(
+ radians(-float(metadata["model_project_north"])), 4, "Z"
+ )
+ global_matrix = rot @ np.eye(4)
+ global_matrix[:, 3][:3] = [float(o) for o in metadata["model_origin_si"].split(",")]
+
+ if tool.Ifc.get():
+ transformation = tool.Ifc.get().by_id(link.ifc_definition_id)[1] # Identification
+ else:
+ transformation = link.transformation
+
+ if transformation:
+ transformation = np.fromstring(transformation, sep=",", dtype=np.float64).reshape(4, 4)
+ if not np.allclose(transformation, np.eye(4)):
+ global_matrix = transformation @ global_matrix
+
+ gprops = tool.Georeference.get_georeference_props()
+ 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
+
@classmethod
def append_all_types_from_template(cls, template: str) -> None:
# TODO refactor
@@ -249,68 +294,32 @@ class Project(bonsai.core.tool.Project):
tool.Root.reload_grid_decorator()
@classmethod
- def get_linked_models_document(cls) -> Union[ifcopenshell.entity_instance, None]:
- for document in tool.Ifc.get().by_type("IfcDocumentInformation"):
- if document.Name == "BBIM_Linked_Models":
- return document
+ def get_linked_models_documents(cls) -> dict[str, ifcopenshell.entity_instance]:
+ linked_docs = {}
+ for doc in tool.Ifc.get().by_type("IfcDocumentInformation"):
+ if doc.Scope == "LINKED_MODEL":
+ for reference in tool.Drawing.get_document_references(doc):
+ linked_docs[Path(reference.Location).as_posix()] = doc
+ break
+ return linked_docs
@classmethod
def load_linked_models_from_ifc(cls) -> None:
links = tool.Project.get_project_props().links
links.clear()
- links_document = cls.get_linked_models_document()
- if not links_document:
- return
-
- references = tool.Document.get_document_references(links_document)
- if not references:
- return
-
- for reference in references:
- link = links.add()
- link.name = reference.Location
-
- @classmethod
- def save_linked_models_to_ifc(cls) -> None:
- ifc_file = tool.Ifc.get()
- links = tool.Project.get_project_props().links
- filepaths: set[Path] = set()
- for link in links:
- filepaths.add(Path(link.name))
-
- links_document = cls.get_linked_models_document()
-
- if not filepaths and links_document is None:
- return
-
- paths_to_add = filepaths.copy()
- references_to_remove: list[ifcopenshell.entity_instance] = []
- if links_document:
- references = tool.Document.get_document_references(links_document)
- for reference in references:
- # I guess got corrupted by the user.
- if not (location := reference.Location):
- references_to_remove.remove(reference)
- continue
- path = Path(location)
- if path in paths_to_add:
- paths_to_add.remove(path)
- else:
- references_to_remove.append(reference)
-
- if paths_to_add:
- if links_document is None:
- links_document = ifcopenshell.api.document.add_information(ifc_file)
- links_document.Name = "BBIM_Linked_Models"
- links_document.Description = "Bonsai internal document containing references to currently linked models"
-
- for path in paths_to_add:
- reference = ifcopenshell.api.document.add_reference(ifc_file, links_document)
- reference.Location = path.as_posix()
-
- if references_to_remove:
- for reference in references_to_remove:
- ifcopenshell.api.document.remove_reference(ifc_file, reference)
+ for doc in tool.Ifc.get().by_type("IfcDocumentInformation"):
+ if doc.Scope != "LINKED_MODEL":
+ continue
+ for reference in tool.Drawing.get_document_references(doc):
+ filepath = reference.Location
+ link = links.add()
+ link.name = filepath
+ link.filepath = filepath
+ link.ifc_definition_id = reference.id()
+ link.has_transformation = False
+ if reference[1]:
+ m = np.fromstring(reference[1], sep=",", dtype=np.float64).reshape(4, 4)
+ link.has_transformation = not np.allclose(m, np.eye(4))
@classmethod
def get_project_library_elements(
diff --git a/src/bonsai/test/bim/feature/project.feature b/src/bonsai/test/bim/feature/project.feature
index cb9aeec21e..f536a17220 100644
--- a/src/bonsai/test/bim/feature/project.feature
+++ b/src/bonsai/test/bim/feature/project.feature
@@ -677,31 +677,43 @@ Scenario: Load project elements - all georeferencing coordinate situations with
And the object "IfcActuator/J" has a vertex at "10.366,3.813,-1"
And the object "IfcActuator/J" has a vertex at "12.298,4.331,-1"
-Scenario: Link IFC
+Scenario: Link IFC - from an empty IFC project
Given an empty IFC project
- When I link IFC project from "{cwd}/test/files/basic.ifc"
+ When I press "bim.link_ifc(filepath='{cwd}/test/files/basic.ifc')"
Then "scene.BIMProjectProperties.links['{cwd}/test/files/basic.ifc'].is_loaded" is "True"
And the collection "IfcProject/basic.ifc" exists
And the object "Chunk" exists
And the object "Chunk" is placed in the collection "IfcProject/basic.ifc"
-Scenario: Link IFC - disabled false origin mode
- Given an empty IFC project
- # Not currently possible via UI
- And I set "scene.BIMProjectProperties.distance_limit" to "5"
- And I set "scene.BIMProjectProperties.false_origin_mode" to "DISABLED"
- When I link IFC project from "{cwd}/test/files/geolocation.ifc"
- Then the object "Chunk" exists
- And the object "Chunk" has a vertex at "2,2,-1"
- And the object "Chunk" has a vertex at "9,-1,-1"
- And the object "Chunk" has a vertex at "17,4,-1"
-
Scenario: Link IFC - from an empty IFC project - automatic false origin mode (0,0,0 will be the false origin)
Given an empty IFC project
# Not currently possible via UI
And I set "scene.BIMProjectProperties.distance_limit" to "5"
And I set "scene.BIMProjectProperties.false_origin_mode" to "AUTOMATIC"
- When I link IFC project from "{cwd}/test/files/geolocation.ifc"
+ When I press "bim.link_ifc(filepath='{cwd}/test/files/geolocation.ifc', use_cache=False)"
+ Then the object "Chunk" exists
+ And the object "Chunk" has a vertex at "2,2,-1"
+ And the object "Chunk" has a vertex at "9,-1,-1"
+ And the object "Chunk" has a vertex at "17,4,-1"
+
+Scenario: Link IFC - from an empty IFC project - manual false origin mode
+ Given an empty IFC project
+ # Not currently possible via UI
+ And I set "scene.BIMProjectProperties.distance_limit" to "5"
+ And I set "scene.BIMProjectProperties.false_origin_mode" to "MANUAL"
+ And I set "scene.BIMProjectProperties.false_origin" to "10000,0,0"
+ When I press "bim.link_ifc(filepath='{cwd}/test/files/geolocation.ifc', use_cache=False)"
+ Then the object "Chunk" exists
+ And the object "Chunk" has a vertex at "2,2,-1"
+ And the object "Chunk" has a vertex at "9,-1,-1"
+ And the object "Chunk" has a vertex at "17,4,-1"
+
+Scenario: Link IFC - from an empty IFC project - disabled false origin mode
+ Given an empty IFC project
+ # Not currently possible via UI
+ And I set "scene.BIMProjectProperties.distance_limit" to "5"
+ And I set "scene.BIMProjectProperties.false_origin_mode" to "DISABLED"
+ When I press "bim.link_ifc(filepath='{cwd}/test/files/geolocation.ifc', use_cache=False)"
Then the object "Chunk" exists
And the object "Chunk" has a vertex at "2,2,-1"
And the object "Chunk" has a vertex at "9,-1,-1"
@@ -712,31 +724,42 @@ Scenario: Link IFC - from an empty Blender session - automatic false origin mode
# Not currently possible via UI
And I set "scene.BIMProjectProperties.distance_limit" to "5"
And I set "scene.BIMProjectProperties.false_origin_mode" to "AUTOMATIC"
- When I link IFC project from "{cwd}/test/files/geolocation.ifc"
+ When I press "bim.link_ifc(filepath='{cwd}/test/files/geolocation.ifc', use_cache=False)"
Then the object "Chunk" exists
And the object "Chunk" has a vertex at "-11,-2,0"
And the object "Chunk" has a vertex at "-4,-5,0"
And the object "Chunk" has a vertex at "4,0,0"
-Scenario: Link IFC - manual false origin mode
+Scenario: Link IFC - from an empty Blender session - manual false origin mode
Given an empty Blender session
# Not currently possible via UI
And I set "scene.BIMProjectProperties.distance_limit" to "5"
And I set "scene.BIMProjectProperties.false_origin_mode" to "MANUAL"
And I set "scene.BIMProjectProperties.false_origin" to "10000,0,0"
- When I link IFC project from "{cwd}/test/files/geolocation.ifc"
+ When I press "bim.link_ifc(filepath='{cwd}/test/files/geolocation.ifc', use_cache=False)"
Then the object "Chunk" exists
And the object "Chunk" has a vertex at "-8,2,-1"
And the object "Chunk" has a vertex at "-1,-1,-1"
And the object "Chunk" has a vertex at "7,4,-1"
-Scenario: Link IFC - automatic false origin mode - two different false origins and project norths - grid north is up because we start with geolocation.ifc
+Scenario: Link IFC - from an empty Blender session - disabled false origin mode
+ Given an empty Blender session
+ # Not currently possible via UI
+ And I set "scene.BIMProjectProperties.distance_limit" to "5"
+ And I set "scene.BIMProjectProperties.false_origin_mode" to "DISABLED"
+ When I press "bim.link_ifc(filepath='{cwd}/test/files/geolocation.ifc', use_cache=False)"
+ Then the object "Chunk" exists
+ And the object "Chunk" has a vertex at "2,2,-1"
+ And the object "Chunk" has a vertex at "9,-1,-1"
+ And the object "Chunk" has a vertex at "17,4,-1"
+
+Scenario: Link IFC - from an empty Blender session - automatic false origin mode - two different false origins and project norths - grid north is up because we start with geolocation.ifc
Given an empty Blender session
# Not currently possible via UI
And I set "scene.BIMProjectProperties.distance_limit" to "5"
And I set "scene.BIMProjectProperties.false_origin_mode" to "AUTOMATIC"
- When I link IFC project from "{cwd}/test/files/geolocation.ifc"
- And I link IFC project from "{cwd}/test/files/geolocation-mapconversion-angle.ifc"
+ When I press "bim.link_ifc(filepath='{cwd}/test/files/geolocation.ifc', use_cache=False)"
+ And I press "bim.link_ifc(filepath='{cwd}/test/files/geolocation-mapconversion-angle.ifc', use_cache=False)"
Then the object "Col:IfcProject/geolocation.ifc:Chunk" exists
And the object "Col:IfcProject/geolocation-mapconversion-angle.ifc:Chunk" exists
And the object "Col:IfcProject/geolocation.ifc:Chunk" has a vertex at "-11,-2,0"
@@ -746,13 +769,13 @@ Scenario: Link IFC - automatic false origin mode - two different false origins a
And the object "Col:IfcProject/geolocation-mapconversion-angle.ifc:Chunk" has a vertex at "9.294,-9.366,0"
And the object "Col:IfcProject/geolocation-mapconversion-angle.ifc:Chunk" has a vertex at "18.722,-9.036,0"
-Scenario: Link IFC - automatic false origin mode - two different false origins and project norths - project north is up because we start with geolocation-mapconversion-angle.ifc
+Scenario: Link IFC - from an empty Blender session - automatic false origin mode - two different false origins and project norths - project north is up because we start with geolocation-mapconversion-angle.ifc
Given an empty Blender session
# Not currently possible via UI
And I set "scene.BIMProjectProperties.distance_limit" to "5"
And I set "scene.BIMProjectProperties.false_origin_mode" to "AUTOMATIC"
- When I link IFC project from "{cwd}/test/files/geolocation-mapconversion-angle.ifc"
- And I link IFC project from "{cwd}/test/files/geolocation.ifc"
+ When I press "bim.link_ifc(filepath='{cwd}/test/files/geolocation-mapconversion-angle.ifc', use_cache=False)"
+ And I press "bim.link_ifc(filepath='{cwd}/test/files/geolocation.ifc', use_cache=False)"
Then the object "Col:IfcProject/geolocation.ifc:Chunk" exists
And the object "Col:IfcProject/geolocation-mapconversion-angle.ifc:Chunk" exists
And the object "Col:IfcProject/geolocation-mapconversion-angle.ifc:Chunk" has a vertex at "-11,-2,0"
@@ -762,14 +785,14 @@ Scenario: Link IFC - automatic false origin mode - two different false origins a
And the object "Col:IfcProject/geolocation.ifc:Chunk" has a vertex at "-17.696,-7.866,0"
And the object "Col:IfcProject/geolocation.ifc:Chunk" has a vertex at "-13.268,0.464,0"
-Scenario: Link IFC - automatic false origin mode - three identical false origins but different project and map units
+Scenario: Link IFC - from an empty Blender session - automatic false origin mode - three identical false origins but different project and map units
Given an empty Blender session
# Not currently possible via UI
And I set "scene.BIMProjectProperties.distance_limit" to "5"
And I set "scene.BIMProjectProperties.false_origin_mode" to "AUTOMATIC"
- When I link IFC project from "{cwd}/test/files/geolocation-unit1.ifc"
- And I link IFC project from "{cwd}/test/files/geolocation-unit2.ifc"
- And I link IFC project from "{cwd}/test/files/geolocation-unit3.ifc"
+ When I press "bim.link_ifc(filepath='{cwd}/test/files/geolocation-unit1.ifc', use_cache=False)"
+ And I press "bim.link_ifc(filepath='{cwd}/test/files/geolocation-unit2.ifc', use_cache=False)"
+ And I press "bim.link_ifc(filepath='{cwd}/test/files/geolocation-unit3.ifc', use_cache=False)"
Then the object "Col:IfcProject/geolocation-unit1.ifc:Chunk" exists
And the object "Col:IfcProject/geolocation-unit2.ifc:Chunk" exists
And the object "Col:IfcProject/geolocation-unit3.ifc:Chunk" exists
@@ -779,54 +802,54 @@ Scenario: Link IFC - automatic false origin mode - three identical false origins
Scenario: Toggle link visibility - wireframe mode
Given an empty IFC project
- And I link IFC project from "{cwd}/test/files/basic.ifc"
- When I press "bim.toggle_link_visibility(link='{cwd}/test/files/basic.ifc', mode='WIREFRAME')"
+ And I press "bim.link_ifc(filepath='{cwd}/test/files/basic.ifc')"
+ When I press "bim.toggle_link_visibility(link_index=0, mode='WIREFRAME')"
Then "scene.BIMProjectProperties.links['{cwd}/test/files/basic.ifc'].is_wireframe" is "True"
And the object "Chunk" should display as "WIRE"
- When I press "bim.toggle_link_visibility(link='{cwd}/test/files/basic.ifc', mode='WIREFRAME')"
+ When I press "bim.toggle_link_visibility(link_index=0, mode='WIREFRAME')"
Then "scene.BIMProjectProperties.links['{cwd}/test/files/basic.ifc'].is_wireframe" is "False"
And the object "Chunk" should display as "TEXTURED"
Scenario: Toggle link selectability
Given an empty IFC project
- And I link IFC project from "{cwd}/test/files/basic.ifc"
- When I press "bim.toggle_link_selectability(link='{cwd}/test/files/basic.ifc')"
+ And I press "bim.link_ifc(filepath='{cwd}/test/files/basic.ifc')"
+ When I press "bim.toggle_link_selectability(link_index=0)"
Then "scene.BIMProjectProperties.links['{cwd}/test/files/basic.ifc'].is_selectable" is "False"
And the collection "IfcProject/basic.ifc" is unselectable
- When I press "bim.toggle_link_selectability(link='{cwd}/test/files/basic.ifc')"
+ When I press "bim.toggle_link_selectability(link_index=0)"
Then "scene.BIMProjectProperties.links['{cwd}/test/files/basic.ifc'].is_selectable" is "True"
And the collection "IfcProject/basic.ifc" is selectable
Scenario: Toggle link visibility - visible mode
Given an empty IFC project
- And I link IFC project from "{cwd}/test/files/basic.ifc"
- When I press "bim.toggle_link_visibility(link='{cwd}/test/files/basic.ifc', mode='VISIBLE')"
+ And I press "bim.link_ifc(filepath='{cwd}/test/files/basic.ifc')"
+ When I press "bim.toggle_link_visibility(link_index=0, mode='VISIBLE')"
Then "scene.BIMProjectProperties.links['{cwd}/test/files/basic.ifc'].is_hidden" is "True"
And the object "IfcProject/basic.ifc" is not visible
- When I press "bim.toggle_link_visibility(link='{cwd}/test/files/basic.ifc', mode='VISIBLE')"
+ When I press "bim.toggle_link_visibility(link_index=0, mode='VISIBLE')"
Then "scene.BIMProjectProperties.links['{cwd}/test/files/basic.ifc'].is_hidden" is "False"
And the object "IfcProject/basic.ifc" is visible
Scenario: Unload link
Given an empty Blender session
- And I link IFC project from "{cwd}/test/files/basic.ifc"
- When I press "bim.unload_link(filepath='{cwd}/test/files/basic.ifc')"
+ And I press "bim.link_ifc(filepath='{cwd}/test/files/basic.ifc')"
+ When I press "bim.unload_link(link_index=0)"
Then "scene.BIMProjectProperties.links['{cwd}/test/files/basic.ifc'].is_loaded" is "False"
And the collection "IfcProject/basic.ifc" does not exist
Scenario: Load link
Given an empty Blender session
- And I link IFC project from "{cwd}/test/files/basic.ifc"
- And I press "bim.unload_link(filepath='{cwd}/test/files/basic.ifc')"
- When I press "bim.load_link(filepath='{cwd}/test/files/basic.ifc')"
+ And I press "bim.link_ifc(filepath='{cwd}/test/files/basic.ifc')"
+ And I press "bim.unload_link(link_index=0)"
+ When I press "bim.load_link(link_index=0)"
Then "scene.BIMProjectProperties.links['{cwd}/test/files/basic.ifc'].is_loaded" is "True"
And the object "IfcProject/basic.ifc" exists
Scenario: Unlink IFC
Given an empty Blender session
- And I link IFC project from "{cwd}/test/files/basic.ifc"
- And I press "bim.unload_link(filepath='{cwd}/test/files/basic.ifc')"
- When I press "bim.unlink_ifc(filepath='{cwd}/test/files/basic.ifc')"
+ And I press "bim.link_ifc(filepath='{cwd}/test/files/basic.ifc')"
+ And I press "bim.unload_link(link_index=0)"
+ When I press "bim.unlink_ifc(link_index=0)"
Then "scene.BIMProjectProperties.links.get('{cwd}/test/files/basic.ifc')" is "None"
And "scene.collection.children.get('IfcProject/basic.ifc')" is "None"
And the object "Chunk" does not exist
diff --git a/src/bonsai/test/bim/test_feature.py b/src/bonsai/test/bim/test_feature.py
index 9df6bbf627..a6d93c0006 100644
--- a/src/bonsai/test/bim/test_feature.py
+++ b/src/bonsai/test/bim/test_feature.py
@@ -70,10 +70,7 @@ Can be useful for debugging, but has caveats - can't use ``wm.read_homefile``
as resets the ``bpy.context`` and some it's members become `None`.
"""
-TMP = Path.cwd() / "test/files/temp"
-TEST_FILES_DIR = Path.cwd() / "test/files"
-
-CLEAN_LINKED_FILES_CACHE = False
+TMP = Path(f"{variables['cwd']}/test/files/temp")
EPSET_DRAWING = Path.cwd() / "bonsai/bim/data/pset/EPset_Drawing.ifc"
EPSET_DRAWING_BYTES = EPSET_DRAWING.read_bytes()
@@ -311,25 +308,6 @@ def vectors_are_equal(v1, v2):
return all(is_x(v1[i], v2[i]) for i in range(len(v1)))
-@pytest.fixture(scope="function", autouse=True)
-def run_for_each_test() -> Generator[None]:
- # Code before this runs before each test
- yield
- # Code after this runs after each test
-
- global CLEAN_LINKED_FILES_CACHE
- if CLEAN_LINKED_FILES_CACHE:
- for filepath in TEST_FILES_DIR.glob("*.ifc.cache.*"):
- filepath.unlink()
- CLEAN_LINKED_FILES_CACHE = False
-
- # pset_template tests are editing EPset_Drawing.ifc, so we need to restore it.
- global RELOAD_EPSET_DRAWING
- if RELOAD_EPSET_DRAWING:
- EPSET_DRAWING.write_bytes(EPSET_DRAWING_BYTES)
- RELOAD_EPSET_DRAWING = False
-
-
@given("an untestable scenario")
def an_untestable_scenario():
pass
@@ -384,16 +362,6 @@ def saving_ifc_project() -> None:
tool.Project.save_test_project()
-@given(parsers.parse('I link IFC project from "{filepath}"'))
-@when(parsers.parse('I link IFC project from "{filepath}"'))
-@then(parsers.parse('I link IFC project from "{filepath}"'))
-def i_link_ifc_project_from_filepath(filepath: str) -> None:
- global CLEAN_LINKED_FILES_CACHE
- filepath = replace_variables(filepath)
- CLEAN_LINKED_FILES_CACHE = True
- bpy.ops.bim.link_ifc(filepath=filepath, use_cache=False)
-
-
@given("the Brickschema is stubbed")
def the_brickschema_is_stubbed():
# This makes things run faster since we don't need to load the entire brick schema
@@ -1587,10 +1555,19 @@ def the_object_name_has_a_vertex_at_location(name, location):
is_pass = False
target = Vector([float(co) for co in location.split(",")])
verts = []
- for v in obj.data.vertices:
- verts.append(obj.matrix_world @ v.co)
- if (verts[-1] - target).length < 0.001:
- is_pass = True
+ depsgraph = bpy.context.evaluated_depsgraph_get()
+ obj_eval = obj.evaluated_get(depsgraph)
+ mesh = obj_eval.to_mesh(preserve_all_data_layers=False, depsgraph=depsgraph)
+ try:
+ for inst in depsgraph.object_instances:
+ if inst.object.original is obj:
+ mw = inst.matrix_world
+ for i, v in enumerate(mesh.vertices):
+ verts.append(mw @ v.co)
+ if (verts[-1] - target).length < 0.001:
+ is_pass = True
+ finally:
+ obj_eval.to_mesh_clear()
assert is_pass, f"No verts found at {location}: {verts}"
diff --git a/src/bonsai/test/tool/test_project.py b/src/bonsai/test/tool/test_project.py
index 06c3f8ec25..43dc61be8e 100644
--- a/src/bonsai/test/tool/test_project.py
+++ b/src/bonsai/test/tool/test_project.py
@@ -16,9 +16,12 @@
# You should have received a copy of the GNU General Public License
# along with Bonsai. If not, see .
+import json
import contextlib
import tempfile
+import numpy as np
from pathlib import Path
+from tempfile import NamedTemporaryFile
import bpy
import ifcopenshell
@@ -263,7 +266,7 @@ class TestLoadLinkedModels(NewFile):
props = tool.Project.get_project_props()
ifcopenshell.api.root.create_entity(ifc, "IfcProject")
document = ifcopenshell.api.document.add_information(ifc)
- document.Name = "BBIM_Linked_Models"
+ document.Name = "X"
tool.Ifc.set(ifc)
subject.load_linked_models_from_ifc()
assert len(props.links) == 0
@@ -273,86 +276,81 @@ class TestLoadLinkedModels(NewFile):
props = tool.Project.get_project_props()
ifcopenshell.api.root.create_entity(ifc, "IfcProject")
document = ifcopenshell.api.document.add_information(ifc)
- document.Name = "BBIM_Linked_Models"
+ document.Scope = "LINKED_MODEL"
reference = ifcopenshell.api.document.add_reference(ifc, document)
- linked_model_path = "test.ifc"
- reference.Location = linked_model_path
+ reference.Location = "test.ifc"
+ reference.Identification = ""
+ reference2 = ifcopenshell.api.document.add_reference(ifc, document)
+ reference2.Location = "test2.ifc"
+ reference2.Identification = "1,2,3,4,5,6,7,8,9,10,11,12,13,14,15,16"
tool.Ifc.set(ifc)
subject.load_linked_models_from_ifc()
- assert len(props.links) == 1
- assert props.links[0].name == linked_model_path
+ assert len(props.links) == 2
+ assert props.links[0].name == "test.ifc"
+ assert props.links[0].ifc_definition_id == reference.id()
+ assert props.links[0].has_transformation is False
+ assert props.links[1].name == "test2.ifc"
+ assert props.links[1].ifc_definition_id == reference2.id()
+ assert props.links[1].has_transformation is True
-class TestSaveLinkedModelsToIfc(NewFile):
- def test_save_linked_models_to_ifc_no_links(self):
- ifc = ifcopenshell.file()
- tool.Ifc.set(ifc)
- subject.save_linked_models_to_ifc()
- assert len(ifc.by_type("IfcDocumentInformation")) == 0
- assert len(ifc.by_type("IfcDocumentReference")) == 0
-
- def test_save_linked_models_to_ifc_paths_to_add(self):
- ifc = ifcopenshell.file()
- ifcopenshell.api.root.create_entity(ifc, "IfcProject")
+class TestCalculateLinkMatrix(NewFile):
+ def test_linking_a_model_without_an_offset_to_our_session_with_no_offset(self):
props = tool.Project.get_project_props()
- link = props.links.add()
- linked_model_path = "test.ifc"
- link.name = linked_model_path
- tool.Ifc.set(ifc)
- subject.save_linked_models_to_ifc()
- assert len(documents := ifc.by_type("IfcDocumentInformation")) == 1
- assert documents[0].Name == "BBIM_Linked_Models"
- assert len(references := ifc.by_type("IfcDocumentReference")) == 1
- assert references[0].Location == linked_model_path
+ gprops = tool.Georeference.get_georeference_props()
+ with NamedTemporaryFile(suffix=".ifc.cache.json", mode="w", delete=True) as tmp:
+ link = props.links.add()
+ link.filepath = tmp.name.replace(".ifc.cache.json", ".ifc")
+ json.dump({"model_project_north": "0", "model_origin_si": "0,0,0"}, tmp)
+ tmp.flush()
+ gprops.model_project_north = "0"
+ gprops.model_origin_si = "0,0,0"
+ assert np.allclose(subject.calculate_link_matrix(link), np.eye(4))
- def test_save_linked_models_to_ifc_already_created_references(self):
- ifc = ifcopenshell.file()
- links = tool.Project.get_project_props().links
- ifcopenshell.api.root.create_entity(ifc, "IfcProject")
+ def test_linking_an_offset_model_to_our_session_with_no_offset(self):
+ props = tool.Project.get_project_props()
+ gprops = tool.Georeference.get_georeference_props()
+ with NamedTemporaryFile(suffix=".ifc.cache.json", mode="w", delete=True) as tmp:
+ link = props.links.add()
+ link.filepath = tmp.name.replace(".ifc.cache.json", ".ifc")
+ json.dump({"model_project_north": "0", "model_origin_si": "5,0,0"}, tmp)
+ tmp.flush()
+ gprops.model_project_north = "0"
+ gprops.model_origin_si = "0,0,0"
+ m = np.eye(4)
+ m[0][3] = 5
+ assert np.allclose(subject.calculate_link_matrix(link), m)
- document = ifcopenshell.api.document.add_information(ifc)
- document.Name = "BBIM_Linked_Models"
- document_id = document.id()
- reference = ifcopenshell.api.document.add_reference(ifc, document)
- linked_model_path = "test.ifc"
- reference.Location = linked_model_path
- reference_id = reference.id()
+ def test_linking_an_offset_model_to_our_session_with_offset(self):
+ props = tool.Project.get_project_props()
+ gprops = tool.Georeference.get_georeference_props()
+ with NamedTemporaryFile(suffix=".ifc.cache.json", mode="w", delete=True) as tmp:
+ link = props.links.add()
+ link.filepath = tmp.name.replace(".ifc.cache.json", ".ifc")
+ json.dump({"model_project_north": "0", "model_origin_si": "5,0,0"}, tmp)
+ tmp.flush()
+ gprops.model_project_north = "0"
+ gprops.model_origin_si = "2,0,0"
+ m = np.eye(4)
+ m[0][3] = 3
+ assert np.allclose(subject.calculate_link_matrix(link), m)
- link = links.add()
- linked_model_path = "test.ifc"
- link.name = linked_model_path
- tool.Ifc.set(ifc)
- subject.save_linked_models_to_ifc()
-
- # Information and references to stay intact.
- assert len(documents := ifc.by_type("IfcDocumentInformation")) == 1
- assert documents[0].id() == document_id
- assert documents[0].Name == "BBIM_Linked_Models"
- assert len(references := ifc.by_type("IfcDocumentReference")) == 1
- assert references[0].id() == reference_id
- assert references[0].Location == linked_model_path
-
- def test_save_linked_models_to_ifc_references_to_remove(self):
- ifc = ifcopenshell.file()
- links = tool.Project.get_project_props().links
- ifcopenshell.api.root.create_entity(ifc, "IfcProject")
-
- document = ifcopenshell.api.document.add_information(ifc)
- document.Name = "BBIM_Linked_Models"
- document_id = document.id()
- reference = ifcopenshell.api.document.add_reference(ifc, document)
- linked_model_path = "test.ifc"
- reference.Location = linked_model_path
-
- tool.Ifc.set(ifc)
- subject.save_linked_models_to_ifc()
- links.clear()
-
- # Remove reference for removed link.
- assert len(documents := ifc.by_type("IfcDocumentInformation")) == 1
- assert documents[0].id() == document_id
- assert documents[0].Name == "BBIM_Linked_Models"
- assert len(ifc.by_type("IfcDocumentReference")) == 0
+ def test_linking_an_offset_model_to_our_session_with_offset_and_transformation(self):
+ props = tool.Project.get_project_props()
+ gprops = tool.Georeference.get_georeference_props()
+ with NamedTemporaryFile(suffix=".ifc.cache.json", mode="w", delete=True) as tmp:
+ link = props.links.add()
+ link.filepath = tmp.name.replace(".ifc.cache.json", ".ifc")
+ transformation = np.eye(4)
+ transformation[0][3] = 4
+ link.transformation = ",".join(map(str, transformation.reshape(-1)))
+ json.dump({"model_project_north": "0", "model_origin_si": "5,0,0"}, tmp)
+ tmp.flush()
+ gprops.model_project_north = "0"
+ gprops.model_origin_si = "2,0,0"
+ m = np.eye(4)
+ m[0][3] = 7
+ assert np.allclose(subject.calculate_link_matrix(link), m)
class TestLoadingIfcSqlite(NewFile):
diff --git a/src/ifcopenshell-python/ifcopenshell/util/geolocation.py b/src/ifcopenshell-python/ifcopenshell/util/geolocation.py
index fba8dfffaf..42aa28ca51 100644
--- a/src/ifcopenshell-python/ifcopenshell/util/geolocation.py
+++ b/src/ifcopenshell-python/ifcopenshell/util/geolocation.py
@@ -18,7 +18,7 @@
import math
from decimal import ROUND_HALF_UP, Decimal
-from typing import NamedTuple, Optional, Union
+from typing import NamedTuple, Optional, Union, Any
import numpy as np
@@ -268,6 +268,15 @@ def get_helmert_transformation_parameters(ifc_file: ifcopenshell.file) -> Option
return HelmertTransformation(e, n, h, xaa, xao, scale, factor_x, factor_y, factor_z)
+def get_crs(ifc_file: ifcopenshell.file) -> dict[str, Any]:
+ """Get CRS information from an IFC file."""
+ if ifc_file.schema == "IFC2X3":
+ return ifcopenshell.util.element.get_pset(ifc_file.by_type("IfcProject")[0], "ePSet_ProjectedCRS")
+ for context in ifc_file.by_type("IfcGeometricRepresentationContext", include_subtypes=False):
+ if operation := context.HasCoordinateOperation:
+ return operation[0].TargetCRS.get_info()
+
+
def auto_z2e(ifc_file: ifcopenshell.file, z: float, should_return_in_map_units: bool = True) -> float:
"""Convert a Z coordinate to an elevation using model georeferencing data