Linked IFC projects enhancement (multiple links to same project file) (#7607)

* Linked IFC projects enhancement (multiple links to same project file)

- Implement link management system using UUIDs as identifiers to support multiple links to the same IFC file
- Add georeferencing compatibility detection and UI display (NONE, NOT_COMPATIBLE, PARTIAL_COMPATIBLE, FULL_COMPATIBLE)
- Support for duplicate link creation with Shift+D shortcut and automatic position offset
- Add false origin and project north calculation from 3D cursor for MANUAL mode
- Only store one cache per file, regardless of the amount of links
- Prevent duplicate links based on filepath and position comparison
- Improve error handling for missing files and loading failures
- Update tests

* Remove duplicate georef UI

I try to avoid duplicate UI (especially for one that can be as
sophisticated as georef - e.g. missing is WCS) as it means double the
code, double the tests, potential user confusion. BTW the note about
vertical datum isn't quite accurate as it may be included in the CRS
definition so vertical datum is optional.

* Remove depsgraph_update_post handler for update_link_ui_on_transform as per core developer feedback

* Move get_projected_crs to geolocation module

* Refactor get_projected_crs to simplify as per core developer feedback

* Remove unused import of bonsai.tool from project module

* Use IfcDocumentInformation per linked file and IfcDocumentReference for locaiton information

* Refactor SaveBlendMetadataFile operator to remove  try-except blocks and remove linked projects collections since they are recreated by bonsai

* Cleanup removing empty collection instances for linked models in metadata.blend file and call determine_georeferencing_compatibility on link reload

* Add locking mechanism for linked models and update UI to reflect lock status

* Update logic that track IFC to execute_ifc_duplicate_operator instead of having it in execute() which does not track IFC undo/redo

* Refactor link handling to use get_link_empty_handle and set_link_empty_handle methods which in turn use the standard blender-ifc integrations patters (tool.Ifc.get_object(doc_reference) and tool.Ifc.link(doc_reference, empty_handle)

* remove operator.DuplicateLink and move it to tool.Project.duplicate_link()

* Refactor link handling to use sequential identifiers (no need for STEP ID DocRef)

* Refactor IFC linking logic to handle cases without a parent IFC file loaded. Firts link flase origin becomes parent origin

* Lock should not affect selection.

This makes it consistent with grid / spatial lock, and also toggle
selectability is already implemented.

* Remove unnecessary check for loaded library as Blender seems to do this internally already

* Rename util to get_crs because in IFC4X3 you can also have geographic CRS not just projected

* Remove unnecessary call to determine_georeferencing_compatibility

This function is already always called prior to calculate_link_position
so shouldn't be called here. It's also a very expensive function: as it
currently stands, just to link a single IFC, ifcopenshell.open() is
called 3 times. This reduces it to 2.

* Store CRS as metadata for linked models, and compare metadata when indicating georeferencing compatibility

Previously, to check georeferencing compatibility, ifcopenshell.open()
was used. When linking large models, this adds considerable time and
memory usage. This instead captures the georef as standard metadata in
our .cache.json. This now reduces the ifcopenshell.open() calls back
down to only 1 as necessary (see previous commit).

* Use link index instead of link name to fetch link collection item

Link name runs into issues with name uniqueness. This is why you created
a function for "get next link ID". After this refactoring, we can no
longer worry about uniqueness and that function may be removed.

* Simplify reloadlink into just unload and reload (with cache disabled)

This function should not be responsible for editing any data.

* Remove unnecessary get_next_link_id as names no longer need uniqueness

This now frees up the name variable to track a more meaningful, human
name like IfcDocumentInformation's Name attribute.

* Rewrite get / set link_empty_handle to just use the link directly

This prevents needless logic to fetch the link and also removes issues
related to duplicate names.

* Temporarily remove logic in prop callback

Right now, pretty much all the logic is done in a prop callback. In
general logic in prop callbacks should be minimised, since it's hard to
test and easily triggered as a domino effect of another change, and may
also impact undo/redo.

* Remove code that unnecessarily removes cache

This code removes cache, which means any project unlinking an IFC auto
clears the cache for any other project which doesn't make sense, and
also breaks the ability to readd it quickly.

* Rewrite link, unlink, load, and unload IFC

There were a few issues tackled here:

 - Operators that change any IFC data must use tool.Ifc.Operator and
_execute, otherwise undo/redo will break. That's one of the risks of
using prop callbacks, as it is not explicit when an IFC edit happens.
 - The usage of IfcDocumentReference was not correct. The Location
should store the URL, _not_ the position. The position should be in the
Identification attribute.
 - The URL was stored in IfcDocumentInformation location, which does not
work in IFC2X3. There are a few changes here to make it IFC2X3
compatible.
 - Generally move logic in operators, not prop callback.

* Remove restriction around manual mode.

Users should be able to use manual mode if they want.

* Restore AUTOMATIC mode to identical behaviour to file open

This is the first step to reusing cache files agnostic of the host.

* Revert tests for a fresh start for updating tests

* Revert "test_feature - clean up .ifc.cache. files after test was executed"

This reverts commit 99ae768ddf.

* Update tests and reimplement calculations for matrix of empty handle

Previously, the empty would always be placed at the origin, unless a
"position" offset was present. This is a problem, because the "position"
is simply a local offset relative to the Blender cache! If the cache was
regenerated, the offsets would be outdated. Also, the cache appeared in
different locations depending on the false origin mode, so the offset
would mean different things to different people.

Instead, a more robust method is:

 1. When you link a file, a Blender cache is generated. The Blender
origin of this cache is arbitrary! It depends on the user's false origin
mode and is purely a Blender session specific thing.
 2. When you load a link, a link is _always_ loaded into the correct
location with regards to IFC global coordinates. All math is done from
the perspective of IFC.
 3. If you choose to transform (move / rotate / scale!?) this link from
its correct location, that gets recorded as a 4x4 transformation matrix.
Note: I haven't implemented this properly yet.

Tests all pass, with a minor modification to the new behaviour that
false origin mode now won't affect the location it ends up in, only the
generation of the cache.

* Remove arbitrary convention around display name

Not needed anymore now that A/M/D is a detail and not significant on
actual coordinates, and also that the UUID is no longer needed.

* Simplify implementation of loading linked models when opening an IFC

* Move link matrix calculation from operator to tool for reuse

* Implement editing link location and calculation of transformation matrix

I changed my mind on the is_locked thing, since it isn't clear to the
user that locking need to be done to save changes.

* Remove old is_locked, prop update callback no longer needed (dedicated operator instead), remove old calculation code

* Simplify code related to placed_as_per_georef

* For now, simple skip for duplicate / delete

IMO duplicate / delete / move a link are very rare and explicit
operations.

* Update tests

* Remove host_model coordinate data as cache is no longer host model dependent

* Move icons outside list because there are too many

* Minor tweaks

---------

Co-authored-by: Dion Moult <dionmoult@gmail.com>
Co-authored-by: Dion Moult <dion@thinkmoult.com>
This commit is contained in:
falken10vdl
2026-02-15 09:28:43 +01:00
committed by GitHub
parent a88c5938dc
commit b246998f68
20 changed files with 641 additions and 450 deletions
+247 -120
View File
@@ -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,