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