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
+2
View File
@@ -82,6 +82,8 @@ src/bonsai/bonsai/translations.py
# bonsai test temp files # bonsai test temp files
src/bonsai/test/files/temp src/bonsai/test/files/temp
src/bonsai/test/files/basic.ifc.cache.blend
src/bonsai/test/files/basic.ifc.cache.sqlite
# bonsai data # bonsai data
src/bonsai/bonsai/bim/data/build/ src/bonsai/bonsai/bim/data/build/
-1
View File
@@ -52,7 +52,6 @@ class IfcExporter:
self.set_header() self.set_header()
IfcStore.update_cache() IfcStore.update_cache()
self.sync_all_objects() self.sync_all_objects()
tool.Project.save_linked_models_to_ifc()
extension = self.ifc_export_settings.output_file.split(".")[-1].lower() extension = self.ifc_export_settings.output_file.split(".")[-1].lower()
if extension == "ifczip": if extension == "ifczip":
with tempfile.TemporaryDirectory() as unzipped_path: with tempfile.TemporaryDirectory() as unzipped_path:
@@ -928,7 +928,7 @@ class CreateDrawing(bpy.types.Operator):
props = tool.Project.get_project_props() props = tool.Project.get_project_props()
for link in props.get_loaded_links_for_drawings(): 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"] target_view = ifcopenshell.util.element.get_psets(self.camera_element)["EPset_Drawing"]["TargetView"]
self.setup_serialiser(target_view) self.setup_serialiser(target_view)
@@ -1374,7 +1374,7 @@ class CreateDrawing(bpy.types.Operator):
return True return True
def get_linked_file(self, link: "Link") -> ifcopenshell.file: 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) ifc_file = IfcStore.session_files.get(link_path, None)
if ifc_file is not None: if ifc_file is not None:
return ifc_file return ifc_file
+1 -1
View File
@@ -89,7 +89,7 @@ class BIM_PT_camera(Panel):
for link in links: for link in links:
row = panel.row(align=True) row = panel.row(align=True)
split = row.split(factor=0.9) 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="") split.prop(link, "include_in_drawings", text="")
else: else:
panel.label(text="No IFC projects linked and loaded.") panel.label(text="No IFC projects linked and loaded.")
@@ -902,6 +902,7 @@ class OverrideDelete(bpy.types.Operator):
if not is_valid_data_block: if not is_valid_data_block:
continue continue
element = tool.Ifc.get_entity(obj) element = tool.Ifc.get_entity(obj)
if element: if element:
if tool.Geometry.is_locked(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"): if ifcopenshell.util.element.get_pset(element, "BBIM_Array"):
self.report({"INFO"}, "Elements that are part of an array cannot be deleted.") self.report({"INFO"}, "Elements that are part of an array cannot be deleted.")
continue continue
if element.is_a("IfcDocumentReference"):
self.report({"INFO"}, "Linked models cannot be deleted.")
continue
if element.is_a("IfcGridAxis"): if element.is_a("IfcGridAxis"):
# Deleting the last W axis is OK # Deleting the last W axis is OK
if ((grid := element.PartOfU) and len(grid[0].UAxes) == 1) or ( 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.") operator.report({"ERROR"}, f"Drawing '{obj.name}' not duplicated.")
continue 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): if tool.Geometry.is_locked(element):
objects_to_remove.add(obj) objects_to_remove.add(obj)
operator.report({"ERROR"}, lock_error_message(obj.name)) operator.report({"ERROR"}, lock_error_message(obj.name))
@@ -51,7 +51,7 @@ class RemoveGeoreferencing(bpy.types.Operator, tool.Ifc.Operator):
bl_description = "Remove the georeferencing" bl_description = "Remove the georeferencing"
def _execute(self, context): def _execute(self, context):
core.remove_georeferencing(tool.Ifc) core.remove_georeferencing(tool.Ifc, tool.Georeference)
class EditGeoreferencing(bpy.types.Operator, tool.Ifc.Operator): class EditGeoreferencing(bpy.types.Operator, tool.Ifc.Operator):
@@ -224,16 +224,12 @@ class BIMGeoreferenceProperties(PropertyGroup):
x_axis_ordinate: StringProperty(name="X Axis Ordinate", update=update_grid_north_vector) x_axis_ordinate: StringProperty(name="X Axis Ordinate", update=update_grid_north_vector)
x_axis_is_null: BoolProperty(name="X Axis Is Null") 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. # 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 # 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 # project load, project create, and when linking for the first time from an
# empty Blender session. # 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: StringProperty(name="Model Origin")
model_origin_si: StringProperty(name="Model Origin SI") model_origin_si: StringProperty(name="Model Origin SI")
model_project_north: StringProperty(name="Model Angle to Grid North") model_project_north: StringProperty(name="Model Angle to Grid North")
@@ -275,10 +271,6 @@ class BIMGeoreferenceProperties(PropertyGroup):
x_axis_ordinate: str x_axis_ordinate: str
x_axis_is_null: bool x_axis_is_null: bool
host_model_origin: str
host_model_origin_si: str
host_model_project_north: str
model_origin: str model_origin: str
model_origin_si: str model_origin_si: str
model_project_north: str model_project_north: str
@@ -31,27 +31,31 @@ classes = (
operator.BIM_OT_load_clipping_planes, operator.BIM_OT_load_clipping_planes,
operator.BIM_OT_save_clipping_planes, operator.BIM_OT_save_clipping_planes,
operator.ChangeLibraryElement, operator.ChangeLibraryElement,
operator.ClearMeasurement,
operator.ClearRecentIFCProjects, operator.ClearRecentIFCProjects,
operator.CreateClippingPlane, operator.CreateClippingPlane,
operator.CreateProject, operator.CreateProject,
operator.DisableCulling, operator.DisableCulling,
operator.DisableEditingHeader, operator.DisableEditingHeader,
operator.DisableEditingLink,
operator.EditHeader, operator.EditHeader,
operator.EditLink,
operator.EditProjectLibrary, operator.EditProjectLibrary,
operator.EnableCulling, operator.EnableCulling,
operator.EnableEditingHeader, operator.EnableEditingHeader,
operator.EnableEditingLink,
operator.ExportIFC, operator.ExportIFC,
operator.FlipClippingPlane, operator.FlipClippingPlane,
operator.IFCFileHandlerOperator, operator.IFCFileHandlerOperator,
operator.ImageScalingTool, operator.ImageScalingTool,
operator.LinkIfc, operator.LinkIfc,
operator.LoadBlendMetadataAndIFC,
operator.LoadLink, operator.LoadLink,
operator.LoadLinkedProject, operator.LoadLinkedProject,
operator.LoadProject, operator.LoadProject,
operator.LoadProjectElements, operator.LoadProjectElements,
operator.MeasureTool,
operator.MeasureFaceAreaTool, operator.MeasureFaceAreaTool,
operator.ClearMeasurement, operator.MeasureTool,
operator.NewProject, operator.NewProject,
operator.QueryLinkedElement, operator.QueryLinkedElement,
operator.RefreshClippingPlanes, operator.RefreshClippingPlanes,
@@ -69,7 +73,6 @@ classes = (
operator.UnassignLibraryDeclaration, operator.UnassignLibraryDeclaration,
operator.UnlinkIfc, operator.UnlinkIfc,
operator.UnloadLink, operator.UnloadLink,
operator.LoadBlendMetadataAndIFC,
workspace.ExploreHotkey, workspace.ExploreHotkey,
prop.LibraryBreadcrumb, prop.LibraryBreadcrumb,
prop.LibraryElement, prop.LibraryElement,
+240 -113
View File
@@ -18,6 +18,7 @@
import datetime import datetime
import json import json
import math
import logging import logging
import os import os
import subprocess import subprocess
@@ -59,6 +60,15 @@ import bonsai.core.project as core
import bonsai.tool as tool import bonsai.tool as tool
from bonsai.bim import export_ifc, import_ifc from bonsai.bim import export_ifc, import_ifc
from bonsai.bim.ifc import IfcStore from bonsai.bim.ifc import IfcStore
from bonsai.bim.ui import IFCFileSelector
from bonsai.bim import import_ifc
from bonsai.bim import export_ifc
from math import radians, 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.decorator import FaceAreaDecorator, PolylineDecorator
from bonsai.bim.module.model.polyline import PolylineOperator from bonsai.bim.module.model.polyline import PolylineOperator
from bonsai.bim.module.project.data import LinksData, ProjectLibraryData from bonsai.bim.module.project.data import LinksData, ProjectLibraryData
@@ -1321,7 +1331,7 @@ class ToggleFilterCategories(bpy.types.Operator):
return {"FINISHED"} return {"FINISHED"}
class LinkIfc(bpy.types.Operator, ImportHelper): class LinkIfc(bpy.types.Operator, ImportHelper, tool.Ifc.Operator):
bl_idname = "bim.link_ifc" bl_idname = "bim.link_ifc"
bl_label = "Link IFC" bl_label = "Link IFC"
bl_options = {"REGISTER", "UNDO"} bl_options = {"REGISTER", "UNDO"}
@@ -1360,134 +1370,148 @@ class LinkIfc(bpy.types.Operator, ImportHelper):
row = self.layout.row() row = self.layout.row()
row.prop(pprops, "project_north") 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() start = time.time()
files = [f.name for f in self.files] if self.files else [self.filepath] 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: for filename in files:
if not filename or not filename.strip():
continue
filepath = Path(self.directory) / filename filepath = Path(self.directory) / filename
if bpy.data.filepath and filepath.samefile(bpy.data.filepath): if bpy.data.filepath and filepath.samefile(bpy.data.filepath):
self.report({"INFO"}, "Can't link the current .blend file") self.report({"INFO"}, "Can't link the current .blend file")
continue continue
props = tool.Project.get_project_props() props = tool.Project.get_project_props()
new = props.links.add()
filepath = tool.Ifc.get_uri(filepath, use_relative_path=self.use_relative_path) 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 new.name = filepath
status = bpy.ops.bim.load_link(filepath=filepath, use_cache=self.use_cache) new.filepath = filepath
if status == {"CANCELLED"}: bpy.ops.bim.load_link(link_index=-1, use_cache=self.use_cache)
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"}
class UnlinkIfc(bpy.types.Operator): class UnlinkIfc(bpy.types.Operator, tool.Ifc.Operator):
bl_idname = "bim.unlink_ifc" bl_idname = "bim.unlink_ifc"
bl_label = "Unlink IFC" bl_label = "Unlink IFC"
bl_options = {"REGISTER", "UNDO"} bl_options = {"REGISTER", "UNDO"}
bl_description = "Remove the selected file from the link list" 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): def _execute(self, context):
filepath = Path(self.filepath).as_posix()
bpy.ops.bim.unload_link(filepath=filepath)
props = tool.Project.get_project_props() props = tool.Project.get_project_props()
index = props.links.find(filepath) link = props.links[self.link_index]
if index != -1: bpy.ops.bim.unload_link(link_index=self.link_index)
props.links.remove(index) if tool.Ifc.get():
return {"FINISHED"} 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_idname = "bim.unload_link"
bl_label = "Unload Link" bl_label = "Unload Link"
bl_options = {"REGISTER", "UNDO"} bl_options = {"REGISTER", "UNDO"}
bl_description = "Unload the selected linked file" bl_description = "Unload the selected linked file"
filepath: bpy.props.StringProperty() link_index: bpy.props.IntProperty(name="Link Index")
def execute(self, context): def _execute(self, context):
filepath = tool.Blender.ensure_blender_path_is_abs(Path(self.filepath)) link = tool.Project.get_project_props().links[self.link_index]
if filepath.suffix.lower() == ".ifc": if obj := tool.Project.get_link_empty_handle(link):
filepath = filepath.with_suffix(".ifc.cache.blend") collection = obj.instance_collection
library = collection.library
for library in list(bpy.data.libraries): tool.Ifc.unlink(obj=obj)
if tool.Blender.ensure_blender_path_is_abs(Path(library.filepath)) == filepath: 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) 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 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() ProjectDecorator.uninstall()
return {"FINISHED"}
class LoadLink(bpy.types.Operator, tool.Ifc.Operator):
class LoadLink(bpy.types.Operator):
bl_idname = "bim.load_link" bl_idname = "bim.load_link"
bl_label = "Load Link" bl_label = "Load Link"
bl_options = {"REGISTER", "UNDO"} bl_options = {"REGISTER", "UNDO"}
bl_description = "Load the selected file" bl_description = "Load the selected file"
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) use_cache: bpy.props.BoolProperty(name="Use Cache", default=True)
filepath_: Path def _execute(self, context):
self.link = tool.Project.get_project_props().links[self.link_index]
def execute(self, context): filepath = Path(tool.Ifc.resolve_uri(self.link.filepath))
filepath = Path(tool.Ifc.resolve_uri(self.filepath))
if not filepath.exists(): if not filepath.exists():
self.report({"ERROR"}, f"File does not exist: '{filepath}'") self.report({"ERROR"}, f"File does not exist: '{filepath}'")
return {"CANCELLED"} return {"CANCELLED"}
self.filepath_ = filepath self.filepath_ = filepath
if filepath.suffix.lower().endswith(".blend"): if filepath.suffix.lower().endswith(".ifc"):
self.link_blend(filepath) return self.link_ifc()
elif filepath.suffix.lower().endswith(".ifc"):
status = self.link_ifc()
if status:
return status
return {"FINISHED"}
def link_blend(self, filepath: Path) -> None: def link_blend(self, filepath: Path) -> None:
with bpy.data.libraries.load(str(filepath), link=True) as (data_from, data_to): with bpy.data.libraries.load(str(filepath), link=True) as (data_from, data_to):
data_to.scenes = data_from.scenes data_to.collections = [c for c in data_from.collections if "IfcProject" in c]
link = tool.Project.get_project_props().links[self.filepath]
for scene in bpy.data.scenes: # Find the linked collection
if not scene.library or Path(scene.library.filepath) != filepath: for collection in bpy.data.collections:
if not collection.library or Path(collection.library.filepath) != filepath:
continue continue
for child in scene.collection.children: # Create unique empty instance for this link
if "IfcProject" not in child.name: empty_name = collection.name
continue empty = bpy.data.objects.new(empty_name, None)
empty = bpy.data.objects.new(child.name, None)
empty.instance_type = "COLLECTION" empty.instance_type = "COLLECTION"
empty.instance_collection = child empty.instance_collection = collection
link.empty_handle = empty 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) bpy.context.scene.collection.objects.link(empty)
break self.link.is_loaded = True
break if tool.Ifc.get(): # For non-IFC projects, locking has no meaning
link.is_loaded = True tool.Geometry.lock_object(empty)
tool.Blender.select_and_activate_single_object(bpy.context, empty) tool.Blender.select_and_activate_single_object(bpy.context, empty)
break
else:
print(f"WARNING: No IfcProject collection found in {filepath}")
self.link.is_loaded = False
def link_ifc(self) -> Union[set[str], None]: def link_ifc(self) -> Union[set[str], None]:
blend_filepath = self.filepath_.with_suffix(".ifc.cache.blend") blend_filepath = self.filepath_.with_suffix(".ifc.cache.blend")
@@ -1502,14 +1526,12 @@ class LoadLink(bpy.types.Operator):
code = f""" code = f"""
import bpy import bpy
import sys
def run(): def run():
import bonsai.tool as tool import bonsai.tool as tool
gprops = tool.Georeference.get_georeference_props() gprops = tool.Georeference.get_georeference_props()
# Our model origin becomes their host model origin # 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.has_blender_offset = {gprops.has_blender_offset}
gprops.blender_offset_x = "{gprops.blender_offset_x}" gprops.blender_offset_x = "{gprops.blender_offset_x}"
gprops.blender_offset_y = "{gprops.blender_offset_y}" gprops.blender_offset_y = "{gprops.blender_offset_y}"
@@ -1522,7 +1544,12 @@ def run():
pprops.false_origin = "{pprops.false_origin}" pprops.false_origin = "{pprops.false_origin}"
pprops.project_north = "{pprops.project_north}" pprops.project_north = "{pprops.project_north}"
# Use absolute path to be safe from cwd changes. # Use absolute path to be safe from cwd changes.
try:
bpy.ops.bim.load_linked_project(filepath=r"{str(self.filepath_)}") 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. # Use str instead of as_posix to avoid issues with Windows shared paths.
bpy.ops.wm.save_as_mainfile(filepath=r"{str(blend_filepath)}") bpy.ops.wm.save_as_mainfile(filepath=r"{str(blend_filepath)}")
@@ -1557,13 +1584,16 @@ except Exception as e:
return {"CANCELLED"} return {"CANCELLED"}
self.set_model_origin_from_link() self.set_model_origin_from_link()
self.set_georeferencing_indicator()
self.link_blend(blend_filepath) self.link_blend(blend_filepath)
def set_model_origin_from_link(self) -> None: def set_model_origin_from_link(self) -> None:
if tool.Ifc.get(): if tool.Ifc.get():
return # The current model's coordinates always take priority. 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") json_filepath = self.filepath_.with_suffix(".ifc.cache.json")
if not json_filepath.exists(): if not json_filepath.exists():
return return
@@ -1576,21 +1606,36 @@ except Exception as e:
if (value := data.get(prop, None)) is not None: if (value := data.get(prop, None)) is not None:
setattr(gprops, prop, value) 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): class ReloadLink(bpy.types.Operator):
bl_idname = "bim.reload_link" bl_idname = "bim.reload_link"
bl_label = "Reload Link" bl_label = "Reload Link"
bl_options = {"REGISTER", "UNDO"} bl_options = {"REGISTER", "UNDO"}
bl_description = "Reload the selected file" bl_description = "Reload the selected file"
filepath: bpy.props.StringProperty() link_index: bpy.props.IntProperty(name="Link Index")
def execute(self, context): def execute(self, context):
is_abs = os.path.isabs(Path(self.filepath)) bpy.ops.bim.unload_link(link_index=self.link_index)
use_relative_path = not is_abs return bpy.ops.bim.load_link(link_index=self.link_index, use_cache=False) or {"FINISHED"}
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"}
class ToggleLinkSelectability(bpy.types.Operator): class ToggleLinkSelectability(bpy.types.Operator):
@@ -1598,16 +1643,18 @@ class ToggleLinkSelectability(bpy.types.Operator):
bl_label = "Toggle Link Selectability" bl_label = "Toggle Link Selectability"
bl_options = {"REGISTER", "UNDO"} bl_options = {"REGISTER", "UNDO"}
bl_description = "Toggle selectability" bl_description = "Toggle selectability"
link: bpy.props.StringProperty(name="Linked IFC Filepath") link_index: bpy.props.IntProperty(name="Link Index")
def execute(self, context): def execute(self, context):
props = tool.Project.get_project_props() props = tool.Project.get_project_props()
link = props.links[self.link] link = props.links[self.link_index]
self.library_filepath = tool.Blender.ensure_blender_path_is_abs(Path(self.link).with_suffix(".ifc.cache.blend")) 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) link.is_selectable = (is_selectable := not link.is_selectable)
for collection in self.get_linked_collections(): for collection in self.get_linked_collections():
collection.hide_select = not is_selectable 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 handle.hide_select = not is_selectable
return {"FINISHED"} return {"FINISHED"}
@@ -1624,13 +1671,15 @@ class ToggleLinkVisibility(bpy.types.Operator):
bl_label = "Toggle Link Visibility" bl_label = "Toggle Link Visibility"
bl_options = {"REGISTER", "UNDO"} bl_options = {"REGISTER", "UNDO"}
bl_description = "Toggle visibility between SOLID and WIREFRAME" bl_description = "Toggle visibility between SOLID and WIREFRAME"
link: 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"))) mode: bpy.props.EnumProperty(name="Visibility Mode", items=((i, i, "") for i in ("WIREFRAME", "VISIBLE")))
def execute(self, context): def execute(self, context):
props = tool.Project.get_project_props() props = tool.Project.get_project_props()
link = props.links[self.link] link = props.links[self.link_index]
self.library_filepath = tool.Blender.ensure_blender_path_is_abs(Path(self.link).with_suffix(".ifc.cache.blend")) self.library_filepath = tool.Blender.ensure_blender_path_is_abs(
Path(link.filepath).with_suffix(".ifc.cache.blend")
)
if self.mode == "WIREFRAME": if self.mode == "WIREFRAME":
self.toggle_wireframe(link) self.toggle_wireframe(link)
elif self.mode == "VISIBLE": elif self.mode == "VISIBLE":
@@ -1652,7 +1701,7 @@ class ToggleLinkVisibility(bpy.types.Operator):
layer_collections = tool.Blender.get_layer_collections_mapping(linked_collections) layer_collections = tool.Blender.get_layer_collections_mapping(linked_collections)
for layer_collection in layer_collections.values(): for layer_collection in layer_collections.values():
layer_collection.exclude = is_hidden layer_collection.exclude = is_hidden
if handle := link.empty_handle: if handle := tool.Project.get_link_empty_handle(link):
handle.hide_set(is_hidden) handle.hide_set(is_hidden)
def get_linked_collections(self) -> list[bpy.types.Collection]: 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): class SelectLinkHandle(bpy.types.Operator):
bl_idname = "bim.select_link_handle" bl_idname = "bim.select_link_handle"
bl_label = "Select Link Handle" bl_label = "Select Link Handle"
bl_options = {"REGISTER", "UNDO"} bl_options = {"REGISTER", "UNDO"}
bl_description = "Select link empty object handle" 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): def execute(self, context):
props = tool.Project.get_project_props() props = tool.Project.get_project_props()
link = props.links[self.index] link = props.links[self.link_index]
handle = link.empty_handle handle = tool.Project.get_link_empty_handle(link)
if not handle: if not handle:
self.report({"ERROR"}, "Link has no empty handle (probably it was deleted).") self.report({"ERROR"}, "Link has no empty handle (probably it was deleted).")
return {"CANCELLED"} return {"CANCELLED"}
@@ -1866,7 +1993,14 @@ class LoadLinkedProject(bpy.types.Operator, ImportHelper):
print("Processing", self.filepath) print("Processing", self.filepath)
self.collection = bpy.data.collections.new("IfcProject/" + os.path.basename(self.filepath)) self.collection = bpy.data.collections.new("IfcProject/" + os.path.basename(self.filepath))
try:
self.file = ifcopenshell.open(self.filepath) 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) tool.Ifc.set(self.file)
print("Finished opening") 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: if tool.Loader.settings.false_origin_mode == "MANUAL" and tool.Loader.settings.false_origin:
tool.Loader.set_manual_blender_offset(self.file) tool.Loader.set_manual_blender_offset(self.file)
elif tool.Loader.settings.false_origin_mode == "AUTOMATIC": 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() tool.Georeference.set_model_origin()
self.json_filepath = self.filepath + ".cache.json" self.json_filepath = self.filepath + ".cache.json"
data = { data = {
"host_model_origin": gprops.host_model_origin, "model_is_georeferenced": gprops.model_is_georeferenced,
"host_model_origin_si": gprops.host_model_origin_si, "model_crs": gprops.model_crs,
"host_model_project_north": gprops.host_model_project_north,
"model_origin": gprops.model_origin, "model_origin": gprops.model_origin,
"model_origin_si": gprops.model_origin_si, "model_origin_si": gprops.model_origin_si,
"model_project_north": gprops.model_project_north, "model_project_north": gprops.model_project_north,
+42 -4
View File
@@ -16,8 +16,9 @@
# You should have received a copy of the GNU General Public License # You should have received a copy of the GNU General Public License
# along with Bonsai. If not, see <http://www.gnu.org/licenses/>. # along with Bonsai. If not, see <http://www.gnu.org/licenses/>.
import os import math
from collections.abc import Generator from collections.abc import Generator
from pathlib import Path
from typing import TYPE_CHECKING, Literal, Union, assert_never, get_args from typing import TYPE_CHECKING, Literal, Union, assert_never, get_args
import bpy import bpy
@@ -219,29 +220,62 @@ class FilterCategory(PropertyGroup):
class Link(PropertyGroup): class Link(PropertyGroup):
name: StringProperty( name: StringProperty(name="Name")
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)", 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_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_selectable: BoolProperty(name="Is Selectable", default=True)
is_wireframe: BoolProperty(name="Is Wireframe", default=False) is_wireframe: BoolProperty(name="Is Wireframe", default=False)
is_hidden: BoolProperty(name="Is Hidden", default=False) is_hidden: BoolProperty(name="Is Hidden", default=False)
include_in_drawings: BoolProperty(name="Include in Drawings", default=True, options=set()) include_in_drawings: BoolProperty(name="Include in Drawings", default=True, options=set())
empty_handle: PointerProperty( empty_handle: PointerProperty(
name="Empty Object Handle", 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, 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: if TYPE_CHECKING:
name: str name: str
filepath: str
transformation: str
georeferenced: Literal["NONE", "NOT_COMPATIBLE", "FULL_COMPATIBLE"]
has_transformation: bool
is_loaded: bool is_loaded: bool
is_editing: bool
is_selectable: bool is_selectable: bool
is_wireframe: bool is_wireframe: bool
is_hidden: bool is_hidden: bool
include_in_drawings: bool include_in_drawings: bool
empty_handle: Union[bpy.types.Object, None] empty_handle: Union[bpy.types.Object, None]
ifc_definition_id: int
class EditedObj(PropertyGroup): class EditedObj(PropertyGroup):
@@ -424,6 +458,10 @@ class BIMProjectProperties(PropertyGroup):
clipping_planes_active_index: bpy.props.IntProperty(min=0, default=0, max=5) clipping_planes_active_index: bpy.props.IntProperty(min=0, default=0, max=5)
edited_objs: bpy.props.CollectionProperty(type=EditedObj) edited_objs: bpy.props.CollectionProperty(type=EditedObj)
@property
def active_link(self) -> Union[Link, None]:
return tool.Blender.get_active_uilist_element(self.links, self.active_link_index)
@property @property
def active_clipping_plane(self) -> ObjProperty | None: def active_clipping_plane(self) -> ObjProperty | None:
return tool.Blender.get_active_uilist_element(self.clipping_planes, self.clipping_planes_active_index) return tool.Blender.get_active_uilist_element(self.clipping_planes, self.clipping_planes_active_index)
+39 -44
View File
@@ -22,6 +22,7 @@ import os
from typing import TYPE_CHECKING from typing import TYPE_CHECKING
import bpy import bpy
import math
import ifcopenshell import ifcopenshell
from bpy.types import Menu, Panel, UIList 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.helper import draw_attributes, prop_with_search
from bonsai.bim.ifc import IfcStore from bonsai.bim.ifc import IfcStore
from bonsai.bim.module.project.data import LinksData, ProjectData from bonsai.bim.module.project.data import LinksData, ProjectData
from typing import TYPE_CHECKING
if TYPE_CHECKING: if TYPE_CHECKING:
from bonsai.bim.module.project.prop import ( from bonsai.bim.module.project.prop import (
@@ -477,17 +479,27 @@ class BIM_PT_links(Panel):
def draw(self, context): def draw(self, context):
self.props = tool.Project.get_project_props() self.props = tool.Project.get_project_props()
row = self.layout.row(align=True) row = self.layout.row(align=True)
row.operator("bim.link_ifc") row.operator("bim.link_ifc")
if self.props.links: if self.props.links:
self.layout.template_list( if self.props.active_link:
"BIM_UL_links", row = self.layout.row(align=True)
"", row.alignment = "RIGHT"
self.props, index = self.props.active_link_index
"links", if self.props.active_link.is_editing:
self.props, row.operator("bim.edit_link", text="", icon="CHECKMARK")
"active_link_index", 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: if LinksData.enable_culling:
row = self.layout.row(align=True) row = self.layout.row(align=True)
@@ -607,47 +619,30 @@ class BIM_UL_links(UIList):
active_propname, active_propname,
index, index,
): ):
if item:
row = layout.row(align=True) row = layout.row(align=True)
if item.is_loaded: if item.is_loaded:
row.label(text=item.name) if item.georeferenced == "NONE":
op = row.operator( row.label(text="", icon="QUESTION")
"bim.toggle_link_selectability", elif item.georeferenced == "NOT_COMPATIBLE":
text="", row.label(text="", icon="ERROR")
icon="RESTRICT_SELECT_OFF" if item.is_selectable else "RESTRICT_SELECT_ON", elif item.georeferenced == "FULL_COMPATIBLE":
emboss=False, row.label(text="", icon="WORLD")
) if item.has_transformation:
op.link = item.name row.label(text="", icon="OBJECT_ORIGIN")
op = row.operator(
"bim.toggle_link_visibility", row.label(text=item.filepath)
text="", icon = "RESTRICT_SELECT_OFF" if item.is_selectable else "RESTRICT_SELECT_ON"
icon="CUBE" if item.is_wireframe else "MESH_CUBE", row.operator("bim.toggle_link_selectability", text="", icon=icon, emboss=False).link_index = index
emboss=False, icon = "CUBE" if item.is_wireframe else "MESH_CUBE"
) op = row.operator("bim.toggle_link_visibility", text="", icon=icon, emboss=False)
op.link = item.name op.link_index = index
op.mode = "WIREFRAME" op.mode = "WIREFRAME"
op = row.operator( icon = "HIDE_ON" if item.is_hidden else "HIDE_OFF"
"bim.toggle_link_visibility", op = row.operator("bim.toggle_link_visibility", text="", icon=icon, emboss=False)
text="", op.link_index = index
icon="HIDE_ON" if item.is_hidden else "HIDE_OFF",
emboss=False,
)
op.link = item.name
op.mode = "VISIBLE" 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: else:
row.prop(item, "name", text="") row.label(text=item.filepath)
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
class BIM_PT_purge(Panel): class BIM_PT_purge(Panel):
+20 -38
View File
@@ -268,63 +268,45 @@ class SaveBlendMetadataFile(bpy.types.Operator):
import bpy import bpy
# Ensure all styles are loaded before attempting to remove them # Ensure all styles are loaded before attempting to remove them
try: bpy.ops.bim.load_styles()
bpy.ops.bim.load_styles()
except Exception:
pass
# 1. Collect all IfcStyle material names # 1. Collect all IfcStyle material names
ifcstyle_material_names = [] ifcstyle_material_names = []
try: styles_props = getattr(bpy.context.scene, "BIMStylesProperties", None)
styles_props = getattr(bpy.context.scene, "BIMStylesProperties", None) if styles_props is None and bpy.data.scenes:
if styles_props is None and bpy.data.scenes:
styles_props = getattr(bpy.data.scenes[0], "BIMStylesProperties", None) styles_props = getattr(bpy.data.scenes[0], "BIMStylesProperties", None)
if styles_props: if styles_props:
for style in list(styles_props.styles): for style in list(styles_props.styles):
material = getattr(style, "blender_material", None) material = getattr(style, "blender_material", None)
if material and material.name: if material and material.name:
ifcstyle_material_names.append(material.name) ifcstyle_material_names.append(material.name)
except Exception:
pass
# 2. Purge IfcStore # 2. Purge IfcStore
try: from bonsai.bim.ifc import IfcStore
from bonsai.bim.ifc import IfcStore IfcStore.purge()
except ImportError:
IfcStore = None
if IfcStore:
try:
IfcStore.purge()
except Exception:
pass
# 3. Remove all collections named IfcProject* # 3. Remove all collections named IfcProject*
for collection in list(bpy.data.collections): for collection in list(bpy.data.collections):
if collection.name.startswith('IfcProject'): if collection.name.startswith('IfcProject'):
try:
bpy.data.collections.remove(collection, do_unlink=True) bpy.data.collections.remove(collection, do_unlink=True)
except Exception:
pass
# 4. Purge orphaned data blocks after removing IfcProject collections # 4.1 Remove all collections from linked libraries (they will be recreated by bonsai)
try: for collection in list(bpy.data.collections):
bpy.ops.outliner.orphans_purge(do_local_ids=True, do_linked_ids=True, do_recursive=True) if collection.library:
except Exception: bpy.data.collections.remove(collection, do_unlink=True)
pass
# 5. Remove all materials corresponding to the IfcStyles we collected # 4.2. Remove all empty objects that are collection instances for linked models
materials_removed = 0 for obj in list(bpy.data.objects):
try: if obj.type == 'EMPTY' and obj.instance_type == 'COLLECTION' and obj.name.startswith('IfcProject/'):
for mat_name in ifcstyle_material_names: 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: if mat_name in bpy.data.materials:
try:
bpy.data.materials.remove(bpy.data.materials[mat_name], do_unlink=True) bpy.data.materials.remove(bpy.data.materials[mat_name], do_unlink=True)
materials_removed += 1
except Exception:
pass
except Exception:
pass
bpy.ops.wm.save_as_mainfile(filepath=r'{blendmetadata_path}') bpy.ops.wm.save_as_mainfile(filepath=r'{blendmetadata_path}')
""" """
+3 -1
View File
@@ -29,6 +29,7 @@ if TYPE_CHECKING:
def add_georeferencing(georeference: type[tool.Georeference]) -> None: def add_georeferencing(georeference: type[tool.Georeference]) -> None:
georeference.add_georeferencing() georeference.add_georeferencing()
georeference.set_model_origin()
def enable_editing_georeferencing(georeference: type[tool.Georeference]) -> None: 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() 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") ifc.run("georeference.remove_georeferencing")
georeference.set_model_origin()
def disable_editing_georeferencing(georeference: type[tool.Georeference]) -> None: def disable_editing_georeferencing(georeference: type[tool.Georeference]) -> None:
+8
View File
@@ -251,11 +251,19 @@ class Document(bonsai.core.tool.Document):
def get_document_references( def get_document_references(
cls, document: ifcopenshell.entity_instance cls, document: ifcopenshell.entity_instance
) -> tuple[ifcopenshell.entity_instance, ...]: ) -> tuple[ifcopenshell.entity_instance, ...]:
# TODO: migrate to util.document and replace all instances
"""Get IfcDocumentReference.ReferencedDocuments, compatible with IFC2X3.""" """Get IfcDocumentReference.ReferencedDocuments, compatible with IFC2X3."""
if document.file.schema == "IFC2X3": if document.file.schema == "IFC2X3":
return document.DocumentReferences or () return document.DocumentReferences or ()
return document.HasDocumentReferences 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 @classmethod
def clear_active_document(cls) -> None: def clear_active_document(cls) -> None:
props = cls.get_document_props() props = cls.get_document_props()
+18
View File
@@ -315,6 +315,21 @@ class Georeference(bonsai.core.tool.Georeference):
) )
return coordinates 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 @classmethod
def import_plot(cls, filepath: str) -> None: def import_plot(cls, filepath: str) -> None:
import bmesh import bmesh
@@ -385,6 +400,9 @@ class Georeference(bonsai.core.tool.Georeference):
unit_scale = ifcopenshell.util.unit.calculate_unit_scale(tool.Ifc.get()) unit_scale = ifcopenshell.util.unit.calculate_unit_scale(tool.Ifc.get())
gprops = tool.Georeference.get_georeference_props() gprops = tool.Georeference.get_georeference_props()
e, n, h = cls.xyz2enh((0, 0, 0), should_return_in_map_units=False) 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 = f"{e},{n},{h}"
gprops.model_origin_si = f"{e * unit_scale},{n * unit_scale},{h * unit_scale}" gprops.model_origin_si = f"{e * unit_scale},{n * unit_scale},{h * unit_scale}"
angle = ifcopenshell.util.geolocation.get_grid_north(tool.Ifc.get()) angle = ifcopenshell.util.geolocation.get_grid_north(tool.Ifc.get())
+66 -57
View File
@@ -19,10 +19,14 @@
from __future__ import annotations from __future__ import annotations
import os import os
import json
import math
import shutil import shutil
import numpy as np
from collections import defaultdict from collections import defaultdict
from math import radians
from pathlib import Path from pathlib import Path
from typing import TYPE_CHECKING, NamedTuple, Optional, Union from typing import TYPE_CHECKING, NamedTuple, Optional
import bpy import bpy
import ifcopenshell import ifcopenshell
@@ -58,6 +62,47 @@ class Project(bonsai.core.tool.Project):
assert (scene := bpy.context.scene) assert (scene := bpy.context.scene)
return scene.MeasureToolSettings # pyright: ignore[reportAttributeAccessIssue] 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 @classmethod
def append_all_types_from_template(cls, template: str) -> None: def append_all_types_from_template(cls, template: str) -> None:
# TODO refactor # TODO refactor
@@ -249,68 +294,32 @@ class Project(bonsai.core.tool.Project):
tool.Root.reload_grid_decorator() tool.Root.reload_grid_decorator()
@classmethod @classmethod
def get_linked_models_document(cls) -> Union[ifcopenshell.entity_instance, None]: def get_linked_models_documents(cls) -> dict[str, ifcopenshell.entity_instance]:
for document in tool.Ifc.get().by_type("IfcDocumentInformation"): linked_docs = {}
if document.Name == "BBIM_Linked_Models": for doc in tool.Ifc.get().by_type("IfcDocumentInformation"):
return document 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 @classmethod
def load_linked_models_from_ifc(cls) -> None: def load_linked_models_from_ifc(cls) -> None:
links = tool.Project.get_project_props().links links = tool.Project.get_project_props().links
links.clear() links.clear()
links_document = cls.get_linked_models_document() for doc in tool.Ifc.get().by_type("IfcDocumentInformation"):
if not links_document: if doc.Scope != "LINKED_MODEL":
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 continue
path = Path(location) for reference in tool.Drawing.get_document_references(doc):
if path in paths_to_add: filepath = reference.Location
paths_to_add.remove(path) link = links.add()
else: link.name = filepath
references_to_remove.append(reference) link.filepath = filepath
link.ifc_definition_id = reference.id()
if paths_to_add: link.has_transformation = False
if links_document is None: if reference[1]:
links_document = ifcopenshell.api.document.add_information(ifc_file) m = np.fromstring(reference[1], sep=",", dtype=np.float64).reshape(4, 4)
links_document.Name = "BBIM_Linked_Models" link.has_transformation = not np.allclose(m, np.eye(4))
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)
@classmethod @classmethod
def get_project_library_elements( def get_project_library_elements(
+67 -44
View File
@@ -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 "10.366,3.813,-1"
And the object "IfcActuator/J" has a vertex at "12.298,4.331,-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 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" Then "scene.BIMProjectProperties.links['{cwd}/test/files/basic.ifc'].is_loaded" is "True"
And the collection "IfcProject/basic.ifc" exists And the collection "IfcProject/basic.ifc" exists
And the object "Chunk" exists And the object "Chunk" exists
And the object "Chunk" is placed in the collection "IfcProject/basic.ifc" 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) 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 Given an empty IFC project
# Not currently possible via UI # Not currently possible via UI
And I set "scene.BIMProjectProperties.distance_limit" to "5" And I set "scene.BIMProjectProperties.distance_limit" to "5"
And I set "scene.BIMProjectProperties.false_origin_mode" to "AUTOMATIC" 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 Then the object "Chunk" exists
And the object "Chunk" has a vertex at "2,2,-1" 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 "9,-1,-1"
@@ -712,31 +724,42 @@ Scenario: Link IFC - from an empty Blender session - automatic false origin mode
# Not currently possible via UI # Not currently possible via UI
And I set "scene.BIMProjectProperties.distance_limit" to "5" And I set "scene.BIMProjectProperties.distance_limit" to "5"
And I set "scene.BIMProjectProperties.false_origin_mode" to "AUTOMATIC" 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 Then the object "Chunk" exists
And the object "Chunk" has a vertex at "-11,-2,0" 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,-5,0"
And the object "Chunk" has a vertex at "4,0,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 Given an empty Blender session
# Not currently possible via UI # Not currently possible via UI
And I set "scene.BIMProjectProperties.distance_limit" to "5" 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_mode" to "MANUAL"
And I set "scene.BIMProjectProperties.false_origin" to "10000,0,0" 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 Then the object "Chunk" exists
And the object "Chunk" has a vertex at "-8,2,-1" 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 "-1,-1,-1"
And the object "Chunk" has a vertex at "7,4,-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 Given an empty Blender session
# Not currently possible via UI # Not currently possible via UI
And I set "scene.BIMProjectProperties.distance_limit" to "5" And I set "scene.BIMProjectProperties.distance_limit" to "5"
And I set "scene.BIMProjectProperties.false_origin_mode" to "AUTOMATIC" 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)"
And I link IFC project from "{cwd}/test/files/geolocation-mapconversion-angle.ifc" 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 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" exists
And the object "Col:IfcProject/geolocation.ifc:Chunk" has a vertex at "-11,-2,0" 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 "9.294,-9.366,0"
And the object "Col:IfcProject/geolocation-mapconversion-angle.ifc:Chunk" has a vertex at "18.722,-9.036,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 Given an empty Blender session
# Not currently possible via UI # Not currently possible via UI
And I set "scene.BIMProjectProperties.distance_limit" to "5" And I set "scene.BIMProjectProperties.distance_limit" to "5"
And I set "scene.BIMProjectProperties.false_origin_mode" to "AUTOMATIC" And I set "scene.BIMProjectProperties.false_origin_mode" to "AUTOMATIC"
When I link IFC project from "{cwd}/test/files/geolocation-mapconversion-angle.ifc" When I press "bim.link_ifc(filepath='{cwd}/test/files/geolocation-mapconversion-angle.ifc', use_cache=False)"
And I link IFC project from "{cwd}/test/files/geolocation.ifc" And I press "bim.link_ifc(filepath='{cwd}/test/files/geolocation.ifc', use_cache=False)"
Then the object "Col:IfcProject/geolocation.ifc:Chunk" exists 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" exists
And the object "Col:IfcProject/geolocation-mapconversion-angle.ifc:Chunk" has a vertex at "-11,-2,0" 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 "-17.696,-7.866,0"
And the object "Col:IfcProject/geolocation.ifc:Chunk" has a vertex at "-13.268,0.464,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 Given an empty Blender session
# Not currently possible via UI # Not currently possible via UI
And I set "scene.BIMProjectProperties.distance_limit" to "5" And I set "scene.BIMProjectProperties.distance_limit" to "5"
And I set "scene.BIMProjectProperties.false_origin_mode" to "AUTOMATIC" And I set "scene.BIMProjectProperties.false_origin_mode" to "AUTOMATIC"
When I link IFC project from "{cwd}/test/files/geolocation-unit1.ifc" When I press "bim.link_ifc(filepath='{cwd}/test/files/geolocation-unit1.ifc', use_cache=False)"
And I link IFC project from "{cwd}/test/files/geolocation-unit2.ifc" And I press "bim.link_ifc(filepath='{cwd}/test/files/geolocation-unit2.ifc', use_cache=False)"
And I link IFC project from "{cwd}/test/files/geolocation-unit3.ifc" 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 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-unit2.ifc:Chunk" exists
And the object "Col:IfcProject/geolocation-unit3.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 Scenario: Toggle link visibility - wireframe mode
Given an empty IFC project Given an empty IFC project
And I link IFC project from "{cwd}/test/files/basic.ifc" And I press "bim.link_ifc(filepath='{cwd}/test/files/basic.ifc')"
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 "True" Then "scene.BIMProjectProperties.links['{cwd}/test/files/basic.ifc'].is_wireframe" is "True"
And the object "Chunk" should display as "WIRE" 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" Then "scene.BIMProjectProperties.links['{cwd}/test/files/basic.ifc'].is_wireframe" is "False"
And the object "Chunk" should display as "TEXTURED" And the object "Chunk" should display as "TEXTURED"
Scenario: Toggle link selectability Scenario: Toggle link selectability
Given an empty IFC project Given an empty IFC project
And I link IFC project from "{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='{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" Then "scene.BIMProjectProperties.links['{cwd}/test/files/basic.ifc'].is_selectable" is "False"
And the collection "IfcProject/basic.ifc" is unselectable 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" Then "scene.BIMProjectProperties.links['{cwd}/test/files/basic.ifc'].is_selectable" is "True"
And the collection "IfcProject/basic.ifc" is selectable And the collection "IfcProject/basic.ifc" is selectable
Scenario: Toggle link visibility - visible mode Scenario: Toggle link visibility - visible mode
Given an empty IFC project Given an empty IFC project
And I link IFC project from "{cwd}/test/files/basic.ifc" And I press "bim.link_ifc(filepath='{cwd}/test/files/basic.ifc')"
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 "True" Then "scene.BIMProjectProperties.links['{cwd}/test/files/basic.ifc'].is_hidden" is "True"
And the object "IfcProject/basic.ifc" is not visible 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" Then "scene.BIMProjectProperties.links['{cwd}/test/files/basic.ifc'].is_hidden" is "False"
And the object "IfcProject/basic.ifc" is visible And the object "IfcProject/basic.ifc" is visible
Scenario: Unload link Scenario: Unload link
Given an empty Blender session Given an empty Blender session
And I link IFC project from "{cwd}/test/files/basic.ifc" And I press "bim.link_ifc(filepath='{cwd}/test/files/basic.ifc')"
When I press "bim.unload_link(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" Then "scene.BIMProjectProperties.links['{cwd}/test/files/basic.ifc'].is_loaded" is "False"
And the collection "IfcProject/basic.ifc" does not exist And the collection "IfcProject/basic.ifc" does not exist
Scenario: Load link Scenario: Load link
Given an empty Blender session Given an empty Blender session
And I link IFC project from "{cwd}/test/files/basic.ifc" And I press "bim.link_ifc(filepath='{cwd}/test/files/basic.ifc')"
And I press "bim.unload_link(filepath='{cwd}/test/files/basic.ifc')" And I press "bim.unload_link(link_index=0)"
When I press "bim.load_link(filepath='{cwd}/test/files/basic.ifc')" When I press "bim.load_link(link_index=0)"
Then "scene.BIMProjectProperties.links['{cwd}/test/files/basic.ifc'].is_loaded" is "True" Then "scene.BIMProjectProperties.links['{cwd}/test/files/basic.ifc'].is_loaded" is "True"
And the object "IfcProject/basic.ifc" exists And the object "IfcProject/basic.ifc" exists
Scenario: Unlink IFC Scenario: Unlink IFC
Given an empty Blender session Given an empty Blender session
And I link IFC project from "{cwd}/test/files/basic.ifc" And I press "bim.link_ifc(filepath='{cwd}/test/files/basic.ifc')"
And I press "bim.unload_link(filepath='{cwd}/test/files/basic.ifc')" And I press "bim.unload_link(link_index=0)"
When I press "bim.unlink_ifc(filepath='{cwd}/test/files/basic.ifc')" When I press "bim.unlink_ifc(link_index=0)"
Then "scene.BIMProjectProperties.links.get('{cwd}/test/files/basic.ifc')" is "None" Then "scene.BIMProjectProperties.links.get('{cwd}/test/files/basic.ifc')" is "None"
And "scene.collection.children.get('IfcProject/basic.ifc')" is "None" And "scene.collection.children.get('IfcProject/basic.ifc')" is "None"
And the object "Chunk" does not exist And the object "Chunk" does not exist
+12 -35
View File
@@ -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`. as resets the ``bpy.context`` and some it's members become `None`.
""" """
TMP = Path.cwd() / "test/files/temp" TMP = Path(f"{variables['cwd']}/test/files/temp")
TEST_FILES_DIR = Path.cwd() / "test/files"
CLEAN_LINKED_FILES_CACHE = False
EPSET_DRAWING = Path.cwd() / "bonsai/bim/data/pset/EPset_Drawing.ifc" EPSET_DRAWING = Path.cwd() / "bonsai/bim/data/pset/EPset_Drawing.ifc"
EPSET_DRAWING_BYTES = EPSET_DRAWING.read_bytes() 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))) 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") @given("an untestable scenario")
def an_untestable_scenario(): def an_untestable_scenario():
pass pass
@@ -384,16 +362,6 @@ def saving_ifc_project() -> None:
tool.Project.save_test_project() 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") @given("the Brickschema is stubbed")
def 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 # 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 is_pass = False
target = Vector([float(co) for co in location.split(",")]) target = Vector([float(co) for co in location.split(",")])
verts = [] verts = []
for v in obj.data.vertices: depsgraph = bpy.context.evaluated_depsgraph_get()
verts.append(obj.matrix_world @ v.co) 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: if (verts[-1] - target).length < 0.001:
is_pass = True is_pass = True
finally:
obj_eval.to_mesh_clear()
assert is_pass, f"No verts found at {location}: {verts}" assert is_pass, f"No verts found at {location}: {verts}"
+69 -71
View File
@@ -16,9 +16,12 @@
# You should have received a copy of the GNU General Public License # You should have received a copy of the GNU General Public License
# along with Bonsai. If not, see <http://www.gnu.org/licenses/>. # along with Bonsai. If not, see <http://www.gnu.org/licenses/>.
import json
import contextlib import contextlib
import tempfile import tempfile
import numpy as np
from pathlib import Path from pathlib import Path
from tempfile import NamedTemporaryFile
import bpy import bpy
import ifcopenshell import ifcopenshell
@@ -263,7 +266,7 @@ class TestLoadLinkedModels(NewFile):
props = tool.Project.get_project_props() props = tool.Project.get_project_props()
ifcopenshell.api.root.create_entity(ifc, "IfcProject") ifcopenshell.api.root.create_entity(ifc, "IfcProject")
document = ifcopenshell.api.document.add_information(ifc) document = ifcopenshell.api.document.add_information(ifc)
document.Name = "BBIM_Linked_Models" document.Name = "X"
tool.Ifc.set(ifc) tool.Ifc.set(ifc)
subject.load_linked_models_from_ifc() subject.load_linked_models_from_ifc()
assert len(props.links) == 0 assert len(props.links) == 0
@@ -273,86 +276,81 @@ class TestLoadLinkedModels(NewFile):
props = tool.Project.get_project_props() props = tool.Project.get_project_props()
ifcopenshell.api.root.create_entity(ifc, "IfcProject") ifcopenshell.api.root.create_entity(ifc, "IfcProject")
document = ifcopenshell.api.document.add_information(ifc) document = ifcopenshell.api.document.add_information(ifc)
document.Name = "BBIM_Linked_Models" document.Scope = "LINKED_MODEL"
reference = ifcopenshell.api.document.add_reference(ifc, document) reference = ifcopenshell.api.document.add_reference(ifc, document)
linked_model_path = "test.ifc" reference.Location = "test.ifc"
reference.Location = linked_model_path 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) tool.Ifc.set(ifc)
subject.load_linked_models_from_ifc() subject.load_linked_models_from_ifc()
assert len(props.links) == 1 assert len(props.links) == 2
assert props.links[0].name == linked_model_path 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): class TestCalculateLinkMatrix(NewFile):
def test_save_linked_models_to_ifc_no_links(self): def test_linking_a_model_without_an_offset_to_our_session_with_no_offset(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")
props = tool.Project.get_project_props() 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 = props.links.add()
linked_model_path = "test.ifc" link.filepath = tmp.name.replace(".ifc.cache.json", ".ifc")
link.name = linked_model_path json.dump({"model_project_north": "0", "model_origin_si": "0,0,0"}, tmp)
tool.Ifc.set(ifc) tmp.flush()
subject.save_linked_models_to_ifc() gprops.model_project_north = "0"
assert len(documents := ifc.by_type("IfcDocumentInformation")) == 1 gprops.model_origin_si = "0,0,0"
assert documents[0].Name == "BBIM_Linked_Models" assert np.allclose(subject.calculate_link_matrix(link), np.eye(4))
assert len(references := ifc.by_type("IfcDocumentReference")) == 1
assert references[0].Location == linked_model_path
def test_save_linked_models_to_ifc_already_created_references(self): def test_linking_an_offset_model_to_our_session_with_no_offset(self):
ifc = ifcopenshell.file() props = tool.Project.get_project_props()
links = tool.Project.get_project_props().links gprops = tool.Georeference.get_georeference_props()
ifcopenshell.api.root.create_entity(ifc, "IfcProject") 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) def test_linking_an_offset_model_to_our_session_with_offset(self):
document.Name = "BBIM_Linked_Models" props = tool.Project.get_project_props()
document_id = document.id() gprops = tool.Georeference.get_georeference_props()
reference = ifcopenshell.api.document.add_reference(ifc, document) with NamedTemporaryFile(suffix=".ifc.cache.json", mode="w", delete=True) as tmp:
linked_model_path = "test.ifc" link = props.links.add()
reference.Location = linked_model_path link.filepath = tmp.name.replace(".ifc.cache.json", ".ifc")
reference_id = reference.id() 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() def test_linking_an_offset_model_to_our_session_with_offset_and_transformation(self):
linked_model_path = "test.ifc" props = tool.Project.get_project_props()
link.name = linked_model_path gprops = tool.Georeference.get_georeference_props()
tool.Ifc.set(ifc) with NamedTemporaryFile(suffix=".ifc.cache.json", mode="w", delete=True) as tmp:
subject.save_linked_models_to_ifc() link = props.links.add()
link.filepath = tmp.name.replace(".ifc.cache.json", ".ifc")
# Information and references to stay intact. transformation = np.eye(4)
assert len(documents := ifc.by_type("IfcDocumentInformation")) == 1 transformation[0][3] = 4
assert documents[0].id() == document_id link.transformation = ",".join(map(str, transformation.reshape(-1)))
assert documents[0].Name == "BBIM_Linked_Models" json.dump({"model_project_north": "0", "model_origin_si": "5,0,0"}, tmp)
assert len(references := ifc.by_type("IfcDocumentReference")) == 1 tmp.flush()
assert references[0].id() == reference_id gprops.model_project_north = "0"
assert references[0].Location == linked_model_path gprops.model_origin_si = "2,0,0"
m = np.eye(4)
def test_save_linked_models_to_ifc_references_to_remove(self): m[0][3] = 7
ifc = ifcopenshell.file() assert np.allclose(subject.calculate_link_matrix(link), m)
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
class TestLoadingIfcSqlite(NewFile): class TestLoadingIfcSqlite(NewFile):
@@ -18,7 +18,7 @@
import math import math
from decimal import ROUND_HALF_UP, Decimal from decimal import ROUND_HALF_UP, Decimal
from typing import NamedTuple, Optional, Union from typing import NamedTuple, Optional, Union, Any
import numpy as np 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) 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: 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 """Convert a Z coordinate to an elevation using model georeferencing data