From b246998f68c710e36d82247bd5133e19aa1851f5 Mon Sep 17 00:00:00 2001 From: falken10vdl <33285113+falken10vdl@users.noreply.github.com> Date: Sun, 15 Feb 2026 09:28:43 +0100 Subject: [PATCH 01/15] 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 99ae768ddf409e19c05d4ea1e44c9bdfd03702f9. * 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 Co-authored-by: Dion Moult --- .gitignore | 2 + src/bonsai/bonsai/bim/export_ifc.py | 1 - .../bonsai/bim/module/drawing/operator.py | 4 +- src/bonsai/bonsai/bim/module/drawing/ui.py | 2 +- .../bonsai/bim/module/geometry/operator.py | 9 + .../bim/module/georeference/operator.py | 2 +- .../bonsai/bim/module/georeference/prop.py | 12 +- .../bonsai/bim/module/project/__init__.py | 9 +- .../bonsai/bim/module/project/operator.py | 367 ++++++++++++------ src/bonsai/bonsai/bim/module/project/prop.py | 46 ++- src/bonsai/bonsai/bim/module/project/ui.py | 93 +++-- src/bonsai/bonsai/bim/operator.py | 74 ++-- src/bonsai/bonsai/core/georeference.py | 4 +- src/bonsai/bonsai/tool/document.py | 8 + src/bonsai/bonsai/tool/georeference.py | 18 + src/bonsai/bonsai/tool/project.py | 125 +++--- src/bonsai/test/bim/feature/project.feature | 111 +++--- src/bonsai/test/bim/test_feature.py | 51 +-- src/bonsai/test/tool/test_project.py | 142 ++++--- .../ifcopenshell/util/geolocation.py | 11 +- 20 files changed, 641 insertions(+), 450 deletions(-) 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 From 65d5df7801678158eee63193c2e529f32343ea63 Mon Sep 17 00:00:00 2001 From: Richard Brice <37087370+RickBrice@users.noreply.github.com> Date: Sun, 15 Feb 2026 11:15:04 -0800 Subject: [PATCH 02/15] IfcAxis2PlacementLinear mapping used with IfcSectionedSurface and IfcSectionedSolidHorizontal IfcSectionedSurface and IfcSectionedSolidHorizontal both of CrossSectionPositions attributes which are lists of IfcAxis2PlacementLinear. The implementation of each class used its own bespoke mapping of IfcAxis2PlacementLinear, which were identical to each other and slightly different than IfcAxis2PlacementLinear. Now the two sectioned classes use the one and only mapping for IfcAxis2PlacementLinear --- .../mapping/IfcSectionedSolidHorizontal.cpp | 30 ++--------------- src/ifcgeom/mapping/IfcSectionedSurface.cpp | 33 +++---------------- 2 files changed, 7 insertions(+), 56 deletions(-) diff --git a/src/ifcgeom/mapping/IfcSectionedSolidHorizontal.cpp b/src/ifcgeom/mapping/IfcSectionedSolidHorizontal.cpp index a2bc648f08..ad1e2e0d4c 100644 --- a/src/ifcgeom/mapping/IfcSectionedSolidHorizontal.cpp +++ b/src/ifcgeom/mapping/IfcSectionedSolidHorizontal.cpp @@ -61,33 +61,9 @@ taxonomy::ptr mapping::map_impl(const IfcSchema::IfcSectionedSolidHorizontal* in longitudes.push_back(*pbde->DistanceAlong()->as(true) * length_unit_); - // Corresponds to the profile X, Y directions (hopefully). - Eigen::Vector3d po( - pbde->OffsetLateral().get_value_or(0.), - // @todo I don't understand whether vertical is an offset relative to the tangent plane or to the global XY plane - pbde->OffsetVertical().get_value_or(0.), - 0. - ); - - profile_offsets.push_back(po); - - boost::optional rot; - if (csp->Axis() && csp->RefDirection()) { - rot = taxonomy::matrix4( - Eigen::Vector3d(0, 0, 0), - taxonomy::cast(map(csp->Axis()))->ccomponents(), - taxonomy::cast(map(csp->RefDirection()))->ccomponents()).ccomponents().block<3,3>(0,0); - } else if (csp->Axis()) { - rot = taxonomy::matrix4( - Eigen::Vector3d(0, 0, 0), - taxonomy::cast(map(csp->Axis()))->ccomponents()).ccomponents().block<3, 3>(0, 0); - } else if (csp->RefDirection()) { - rot = taxonomy::matrix4( - Eigen::Vector3d(0, 0, 0), - Eigen::Vector3d(0, 0, 1), - taxonomy::cast(map(csp->RefDirection()))->ccomponents() - ).ccomponents().block<3, 3>(0, 0); - } + auto linear_placement = taxonomy::cast(map(csp)); + profile_offsets.push_back(linear_placement->ccomponents().block<3, 1>(0, 3)); + boost::optional rot(linear_placement->ccomponents().block<3,3>(0,0)); profile_rotations.push_back(rot); } if (faces.size() != profile_offsets.size()) { diff --git a/src/ifcgeom/mapping/IfcSectionedSurface.cpp b/src/ifcgeom/mapping/IfcSectionedSurface.cpp index 49fde15759..b91b7b62c2 100644 --- a/src/ifcgeom/mapping/IfcSectionedSurface.cpp +++ b/src/ifcgeom/mapping/IfcSectionedSurface.cpp @@ -63,35 +63,10 @@ taxonomy::ptr mapping::map_impl(const IfcSchema::IfcSectionedSurface* inst) { longitudes.push_back(*pbde->DistanceAlong()->as(true) * length_unit_); - // Corresponds to the profile X, Y directions (hopefully). - Eigen::Vector3d po( - pbde->OffsetLateral().get_value_or(0.), - // @todo I don't understand whether vertical is an offset relative to the tangent plane or to the global XY plane - pbde->OffsetVertical().get_value_or(0.), - 0. - ); - - profile_offsets.push_back(po); - - boost::optional rot; - if (csp->Axis() && csp->RefDirection()) { - rot = taxonomy::matrix4( - Eigen::Vector3d(0, 0, 0), - taxonomy::cast(map(csp->Axis()))->ccomponents(), - taxonomy::cast(map(csp->RefDirection()))->ccomponents()).ccomponents().block<3, 3>(0, 0); - } else if (csp->Axis()) { - rot = taxonomy::matrix4( - Eigen::Vector3d(0, 0, 0), - taxonomy::cast(map(csp->Axis()))->ccomponents()).ccomponents().block<3, 3>(0, 0); - } else if (csp->RefDirection()) { - rot = taxonomy::matrix4( - Eigen::Vector3d(0, 0, 0), - Eigen::Vector3d(0, 0, 1), - taxonomy::cast(map(csp->RefDirection()))->ccomponents()) - .ccomponents() - .block<3, 3>(0, 0); - } - profile_rotations.push_back(rot); + auto linear_placement = taxonomy::cast(map(csp)); + profile_offsets.push_back(linear_placement->ccomponents().block<3, 1>(0, 3)); + boost::optional rot(linear_placement->ccomponents().block<3, 3>(0, 0)); + profile_rotations.push_back(rot); } #else return nullptr; From 1d1f158fe231f8cbb9b4a5a2082fb9ac6802005d Mon Sep 17 00:00:00 2001 From: Dion Moult Date: Mon, 16 Feb 2026 08:23:35 +1100 Subject: [PATCH 03/15] Remove no longer relevant invoke code for linking IFCs --- .../bonsai/bim/module/project/operator.py | 23 ------------------- 1 file changed, 23 deletions(-) diff --git a/src/bonsai/bonsai/bim/module/project/operator.py b/src/bonsai/bonsai/bim/module/project/operator.py index e4e57275ba..1d0e52976b 100644 --- a/src/bonsai/bonsai/bim/module/project/operator.py +++ b/src/bonsai/bonsai/bim/module/project/operator.py @@ -1370,29 +1370,6 @@ class LinkIfc(bpy.types.Operator, ImportHelper, tool.Ifc.Operator): row = self.layout.row() row.prop(pprops, "project_north") - 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] From 379c74b31fceb50ead27d7a7c9c0e3dccc489c5d Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Fri, 13 Feb 2026 22:45:21 +0000 Subject: [PATCH 04/15] Bump ruff from 0.15.0 to 0.15.1 Bumps [ruff](https://github.com/astral-sh/ruff) from 0.15.0 to 0.15.1. - [Release notes](https://github.com/astral-sh/ruff/releases) - [Changelog](https://github.com/astral-sh/ruff/blob/main/CHANGELOG.md) - [Commits](https://github.com/astral-sh/ruff/compare/0.15.0...0.15.1) --- updated-dependencies: - dependency-name: ruff dependency-version: 0.15.1 dependency-type: direct:production update-type: version-update:semver-patch ... Signed-off-by: dependabot[bot] --- pyproject.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index 74c3b0ee18..95b61e2c39 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -3,7 +3,7 @@ name = "IfcOpenShell" version = "0.0.0" dependencies = [ "black==26.1.0", - "ruff==0.15.0", + "ruff==0.15.1", "poethepoet", "gersemi==0.25.4", ] From 8cfb162851888202d03b3812552d4f306cedc3cb Mon Sep 17 00:00:00 2001 From: Dion Moult Date: Mon, 16 Feb 2026 17:57:03 +1100 Subject: [PATCH 05/15] Fix #7656. Regression in text editing where leaders were accidentally removed. Added tests. --- src/bonsai/bonsai/tool/drawing.py | 6 ++++-- src/bonsai/test/tool/test_drawing.py | 30 ++++++++++++++++++++++++++++ 2 files changed, 34 insertions(+), 2 deletions(-) diff --git a/src/bonsai/bonsai/tool/drawing.py b/src/bonsai/bonsai/tool/drawing.py index a7238c75dc..864568c835 100644 --- a/src/bonsai/bonsai/tool/drawing.py +++ b/src/bonsai/bonsai/tool/drawing.py @@ -857,9 +857,11 @@ class Drawing(bonsai.core.tool.Drawing): def edit_text_literals(cls, obj: bpy.types.Object, literal_attributes: dict) -> None: assert (element := tool.Ifc.get_entity(obj)) assert (rep := cls.get_annotation_representation(element)) - for literal in cls.get_text_literal(obj, return_list=True): + to_remove = [i for i in rep.Items if i.is_a("IfcTextLiteral")] + new_literals = [cls.add_literal(**a) for a in literal_attributes] + rep.Items = [i for i in rep.Items if not i.is_a("IfcTextLiteral")] + new_literals + for literal in to_remove: ifcopenshell.util.element.remove_deep2(tool.Ifc.get(), literal) - rep.Items = [cls.add_literal(**a) for a in literal_attributes] @classmethod def add_literal(cls, **attributes: str) -> ifcopenshell.entity_instance: diff --git a/src/bonsai/test/tool/test_drawing.py b/src/bonsai/test/tool/test_drawing.py index f7beb94358..532186d7ff 100644 --- a/src/bonsai/test/tool/test_drawing.py +++ b/src/bonsai/test/tool/test_drawing.py @@ -21,6 +21,7 @@ import xml.etree.ElementTree as ET from pathlib import Path import bpy +import pytest import ifcopenshell import ifcopenshell.api.drawing import ifcopenshell.api.group @@ -31,6 +32,7 @@ import ifcopenshell.util.element import mathutils import numpy as np from mathutils import Vector +from ifcopenshell.util.shape_builder import ShapeBuilder import bonsai.core.tool import bonsai.tool as tool @@ -150,6 +152,34 @@ class TestDisableEditingSheets(NewFile): assert props.is_editing_sheets == False +class TestEditTextLiterals(NewFile): + def test_run(self): + ifc = ifcopenshell.file() + tool.Ifc.set(ifc) + obj = bpy.data.objects.new("Object", None) + element = ifc.createIfcAnnotation() + element.Representation = ifc.createIfcProductDefinitionShape() + context = ifc.createIfcGeometricRepresentationSubContext(ContextType="Plan", ContextIdentifier="Annotation") + item = ifc.createIfcTextLiteralWithExtent(Literal="Literal", Path="RIGHT", BoxAlignment="bottom-left") + builder = ShapeBuilder(tool.Ifc.get()) + polyline = builder.polyline([(0.,0.,0.), (1.,0.,0.)]) + representation = ifc.createIfcShapeRepresentation(ContextOfItems=context, Items=[item, polyline]) + element.Representation.Representations = [representation] + tool.Ifc.link(element, obj) + literal_attributes = [ + { + "Literal": "Foo", + "Path": "RIGHT", + "BoxAlignment": "bottom-left", + } + ] + subject.edit_text_literals(obj, literal_attributes) + assert len(ifc.by_type("IfcTextLiteralWithExtent")) == 1 + literal = ifc.by_type("IfcTextLiteralWithExtent")[0] + assert literal in representation.Items + assert literal.Literal == "Foo" + + class TestDisableEditingText(NewFile): def test_run(self): obj = bpy.data.objects.new("Object", None) From 8c0bed0c614800d3bf118ec35ae24a76b3fd3a92 Mon Sep 17 00:00:00 2001 From: Dion Moult Date: Mon, 16 Feb 2026 18:24:37 +1100 Subject: [PATCH 06/15] Stub open command so running tests doesn't keep on launching apps --- src/bonsai/bonsai/bim/module/drawing/operator.py | 1 + src/bonsai/test/bim/bootstrap.py | 1 + 2 files changed, 2 insertions(+) diff --git a/src/bonsai/bonsai/bim/module/drawing/operator.py b/src/bonsai/bonsai/bim/module/drawing/operator.py index db389427e6..2f858a9d9e 100644 --- a/src/bonsai/bonsai/bim/module/drawing/operator.py +++ b/src/bonsai/bonsai/bim/module/drawing/operator.py @@ -3165,6 +3165,7 @@ class EditTextPopup(bpy.types.Operator): bpy.ops.bim.disable_editing_text() def execute(self, context): + # TODO: check for possible subtle undo bug here # can't use invoke() because this operator # will be run indirectly by hotkey # so we use execute() and track whether it's the first run of the operator diff --git a/src/bonsai/test/bim/bootstrap.py b/src/bonsai/test/bim/bootstrap.py index c799c74c00..587b8f5a2d 100644 --- a/src/bonsai/test/bim/bootstrap.py +++ b/src/bonsai/test/bim/bootstrap.py @@ -34,6 +34,7 @@ from bonsai.bim.ifc import IfcStore # Monkey-patch webbrowser opening since we want to test headlessly webbrowser.open = lambda x: True +tool.Drawing.open_with_user_command = lambda x, y: True variables = {"cwd": os.getcwd(), "ifc": "IfcStore.get_file()"} From e20e286168092ce52d1ea8da417a60f66c7f50d0 Mon Sep 17 00:00:00 2001 From: Dion Moult Date: Mon, 16 Feb 2026 18:25:54 +1100 Subject: [PATCH 07/15] Revert "feat(drawing): support multiple file selection in Add Reference" This reverts commit cf5ffad9af7dc911d1a3c9a90767fb8c950f7dbc. --- .../bonsai/bim/module/drawing/operator.py | 15 +--- src/bonsai/test/tool/test_drawing.py | 68 ------------------- 2 files changed, 2 insertions(+), 81 deletions(-) diff --git a/src/bonsai/bonsai/bim/module/drawing/operator.py b/src/bonsai/bonsai/bim/module/drawing/operator.py index 2f858a9d9e..abc6bbfb5c 100644 --- a/src/bonsai/bonsai/bim/module/drawing/operator.py +++ b/src/bonsai/bonsai/bim/module/drawing/operator.py @@ -3094,20 +3094,9 @@ class AddReference(bpy.types.Operator, tool.Ifc.Operator, ImportHelper): use_relative_path: bpy.props.BoolProperty(name="Use Relative Path", default=True) filename_ext = ".svg" - files: bpy.props.CollectionProperty(type=bpy.types.OperatorFileListElement) - directory: bpy.props.StringProperty(subtype="DIR_PATH") - def _execute(self, context): - # Handle both single and multiple file selection - if self.files: - for file_elem in self.files: - filepath = os.path.join(self.directory, file_elem.name) - uri = tool.Ifc.get_uri(filepath, use_relative_path=self.use_relative_path) - core.add_document(tool.Ifc, tool.Drawing, "REFERENCE", uri=uri) - else: - # Fallback for single file (backward compatibility) - filepath = tool.Ifc.get_uri(self.filepath, use_relative_path=self.use_relative_path) - core.add_document(tool.Ifc, tool.Drawing, "REFERENCE", uri=filepath) + filepath = tool.Ifc.get_uri(self.filepath, use_relative_path=self.use_relative_path) + core.add_document(tool.Ifc, tool.Drawing, "REFERENCE", uri=filepath) class RemoveReference(bpy.types.Operator, tool.Ifc.Operator): diff --git a/src/bonsai/test/tool/test_drawing.py b/src/bonsai/test/tool/test_drawing.py index 532186d7ff..8501b5b9ea 100644 --- a/src/bonsai/test/tool/test_drawing.py +++ b/src/bonsai/test/tool/test_drawing.py @@ -958,71 +958,3 @@ class TestAddReferenceImage(NewFile): uv_node = material_nodes["Texture Coordinate"] assert len(uv_node.outputs["Generated"].links[:]) == 1 - - -class TestAddReference(NewFile): - def test_add_single_reference(self): - """Test adding a single reference file (backward compatibility)""" - bpy.ops.bim.create_project() - ifc_path = Path("test/files/temp/test.ifc").absolute() - bpy.ops.bim.save_project(filepath=str(ifc_path), should_save_as=True) - - # Create a temporary SVG file - svg_path = Path("test/files/temp/reference.svg").absolute() - svg_path.parent.mkdir(parents=True, exist_ok=True) - with open(svg_path, "w") as f: - f.write('') - - try: - # Add single reference - bpy.ops.bim.add_reference(filepath=str(svg_path)) - - # Verify reference was added - ifc = tool.Ifc.get() - references = [doc for doc in ifc.by_type("IfcDocumentInformation") if doc.Scope == "REFERENCE"] - assert len(references) == 1 - assert references[0].Name == "reference" - finally: - # Cleanup - if svg_path.exists(): - svg_path.unlink() - - def test_add_multiple_references(self): - """Test adding multiple reference files at once""" - bpy.ops.bim.create_project() - ifc_path = Path("test/files/temp/test.ifc").absolute() - bpy.ops.bim.save_project(filepath=str(ifc_path), should_save_as=True) - - # Create temporary SVG files - temp_dir = Path("test/files/temp").absolute() - temp_dir.mkdir(parents=True, exist_ok=True) - - svg_files = [] - for i in range(3): - svg_path = temp_dir / f"reference_{i}.svg" - with open(svg_path, "w") as f: - f.write('') - svg_files.append(svg_path) - - try: - # Test by directly calling core.add_document multiple times - # (simulating what the operator does with multiple files) - ifc = tool.Ifc.get() - for svg_file in svg_files: - uri = tool.Ifc.get_uri(str(svg_file), use_relative_path=True) - from bonsai.bim import core - - core.drawing.add_document(tool.Ifc, tool.Drawing, "REFERENCE", uri=uri) - - # Verify all references were added - references = [doc for doc in ifc.by_type("IfcDocumentInformation") if doc.Scope == "REFERENCE"] - assert len(references) == 3 - - reference_names = {ref.Name for ref in references} - expected_names = {f"reference_{i}" for i in range(3)} - assert reference_names == expected_names - finally: - # Cleanup - for svg_file in svg_files: - if svg_file.exists(): - svg_file.unlink() From 37fe0ad9934a3bd427efa7027f79d5c47f582702 Mon Sep 17 00:00:00 2001 From: Dion Moult Date: Tue, 17 Feb 2026 11:18:24 +1100 Subject: [PATCH 08/15] Reimplement adding multiple references / schedules cf5ffad9af7dc911d1a3c9a90767fb8c950f7dbc Previously it was implemented inline. This now implements it as a tool.Blender function with tests. Also the previous tests didn't actually run and weren't actually testing any tools despite being in a tool tests. --- .../bonsai/bim/module/drawing/operator.py | 16 ++++++--- src/bonsai/bonsai/tool/blender.py | 10 ++++++ src/bonsai/test/tool/test_blender.py | 34 +++++++++++++++++++ 3 files changed, 56 insertions(+), 4 deletions(-) diff --git a/src/bonsai/bonsai/bim/module/drawing/operator.py b/src/bonsai/bonsai/bim/module/drawing/operator.py index abc6bbfb5c..f92f7e0085 100644 --- a/src/bonsai/bonsai/bim/module/drawing/operator.py +++ b/src/bonsai/bonsai/bim/module/drawing/operator.py @@ -2896,12 +2896,16 @@ class AddSchedule(bpy.types.Operator, tool.Ifc.Operator, ImportHelper): bl_options = {"REGISTER", "UNDO"} bl_description = "Add an .ods, .xls or .xlsx file as a schedule" + files: bpy.props.CollectionProperty(name="Files", type=bpy.types.OperatorFileListElement) + directory: bpy.props.StringProperty(subtype="DIR_PATH") filter_glob: bpy.props.StringProperty(default="*.ods;*.xls;*.xlsx", options={"HIDDEN"}) use_relative_path: bpy.props.BoolProperty(name="Use Relative Path", default=True) def _execute(self, context): - filepath = tool.Ifc.get_uri(self.filepath, use_relative_path=self.use_relative_path) - core.add_document(tool.Ifc, tool.Drawing, "SCHEDULE", uri=filepath) + for filepath in tool.Blender.get_selected_files( + self.directory, self.files, use_relative_path=self.use_relative_path + ): + core.add_document(tool.Ifc, tool.Drawing, "SCHEDULE", uri=filepath) class RemoveSchedule(bpy.types.Operator, tool.Ifc.Operator): @@ -3090,13 +3094,17 @@ class AddReference(bpy.types.Operator, tool.Ifc.Operator, ImportHelper): bl_description = "Import a .svg file to the project as a reference" bl_options = {"REGISTER", "UNDO"} + files: bpy.props.CollectionProperty(name="Files", type=bpy.types.OperatorFileListElement) + directory: bpy.props.StringProperty(subtype="DIR_PATH") filter_glob: bpy.props.StringProperty(default="*.svg", options={"HIDDEN"}) use_relative_path: bpy.props.BoolProperty(name="Use Relative Path", default=True) filename_ext = ".svg" def _execute(self, context): - filepath = tool.Ifc.get_uri(self.filepath, use_relative_path=self.use_relative_path) - core.add_document(tool.Ifc, tool.Drawing, "REFERENCE", uri=filepath) + for filepath in tool.Blender.get_selected_files( + self.directory, self.files, use_relative_path=self.use_relative_path + ): + core.add_document(tool.Ifc, tool.Drawing, "REFERENCE", uri=filepath) class RemoveReference(bpy.types.Operator, tool.Ifc.Operator): diff --git a/src/bonsai/bonsai/tool/blender.py b/src/bonsai/bonsai/tool/blender.py index e499f35106..a2a0f09ed8 100644 --- a/src/bonsai/bonsai/tool/blender.py +++ b/src/bonsai/bonsai/tool/blender.py @@ -2165,3 +2165,13 @@ class Blender(bonsai.core.tool.Blender): if cls.BLENDER_5: return np.array(mathutils_type) return np.array(mathutils_type, dtype=np.float32) + + @classmethod + def get_selected_files( + cls, directory: str, files: bpy.types.OperatorFileListElement, use_relative_path=False + ) -> list[Path]: + return [ + tool.Ifc.get_uri(Path(directory) / f.name, use_relative_path=use_relative_path) + for f in files + if (Path(directory) / f.name).is_file() + ] diff --git a/src/bonsai/test/tool/test_blender.py b/src/bonsai/test/tool/test_blender.py index 43d04c7c19..97e68b9d08 100644 --- a/src/bonsai/test/tool/test_blender.py +++ b/src/bonsai/test/tool/test_blender.py @@ -24,8 +24,10 @@ import pytest import bonsai.core.tool import bonsai.tool as tool +import tempfile from bonsai.tool.blender import Blender as subject from test.bim.bootstrap import NewFile +from pathlib import Path if TYPE_CHECKING: import bpy.stub_internal.rna_enums as rna_enums @@ -110,3 +112,35 @@ class TestBlenderErrorMessageExtraction(NewFile): assert error_reports == [] bpy.utils.unregister_class(OBJECT_OT_test_fail_operator) + + +class TestGetSelectedFiles(NewFile): + def test_get_a_single_file(self) -> None: + with tempfile.NamedTemporaryFile() as f: + file = type("", (object,), {"name": f.name})() + assert subject.get_selected_files(Path(f.name).parent, [file]) == [f.name] + + def test_get_multiple_files(self) -> None: + with tempfile.NamedTemporaryFile() as f: + with tempfile.NamedTemporaryFile() as g: + file = type("", (object,), {"name": f.name})() + file2 = type("", (object,), {"name": g.name})() + assert subject.get_selected_files(Path(f.name).parent, [file, file2]) == [f.name, g.name] + + def test_exclude_directories(self) -> None: + with tempfile.NamedTemporaryFile() as f: + with tempfile.TemporaryDirectory() as d: + file = type("", (object,), {"name": f.name})() + directory = type("", (object,), {"name": d})() + assert subject.get_selected_files(Path(f.name).parent, [file, directory]) == [f.name] + + def test_get_relative_paths(self) -> None: + with tempfile.TemporaryDirectory() as tmp_dir: + base_path = Path(tmp_dir) + with tempfile.NamedTemporaryFile(dir=tmp_dir, suffix=".ifc") as f: + tool.Ifc.set_path(str(f.name)) + with tempfile.NamedTemporaryFile(dir=tmp_dir) as g: + file = type("", (object,), {"name": g.name}) + assert subject.get_selected_files(Path(g.name).parent, [file], use_relative_path=True) == [ + Path(g.name).name + ] From c6b14d14747088be9b350c76d19cf6130a4c7036 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jos=C3=A9=20Aliste?= Date: Mon, 16 Feb 2026 23:33:48 -0300 Subject: [PATCH 09/15] Fixes snap angle. In my previous commit, I mistakenly believed that there was an API change from snap_angle_increment to snap_angle_increment_3d But since the feature was introduced in blender 4.2 the setting is called snap_angle_increment_3d. --- src/bonsai/bonsai/tool/snap.py | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/src/bonsai/bonsai/tool/snap.py b/src/bonsai/bonsai/tool/snap.py index bc35505826..0dc11e86b0 100644 --- a/src/bonsai/bonsai/tool/snap.py +++ b/src/bonsai/bonsai/tool/snap.py @@ -118,14 +118,10 @@ class Snap(bonsai.core.tool.Snap): def get_angle_snap_value(cls, context: bpy.types.Context) -> float: """Get the angle snap increment from Blender's tool settings. - Uses snap_angle_increment_3d (Blender 5.0+) or snap_angle_increment (Blender 4.x). - :param context: Blender context :return: Angle snap increment in degrees """ - if bpy.app.version >= (5, 0, 0): - return math.degrees(context.scene.tool_settings.snap_angle_increment_3d) - return math.degrees(context.scene.tool_settings.snap_angle_increment) + return math.degrees(context.scene.tool_settings.snap_angle_increment_3d) @classmethod def get_snap_points_on_raycasted_face(cls, context, event, obj, face_index): From fcc80ad14a66468d3a353124bd4fd3a30e9875a2 Mon Sep 17 00:00:00 2001 From: Dion Moult Date: Tue, 17 Feb 2026 18:11:13 +1100 Subject: [PATCH 10/15] Simplify add reference image size implementation and fix segfaulting tests Previously, there was a dance between invoke, execute, and draw. This can probably be resolved, but is a high-risk for undo bugs. This simplifies the logic flow to just a traditional _invoke -> _execute. I add a new feature test to at least make sure it does something, and this also fixes the segfault in tool tests as it no longer requires the launching of the file browser. --- .../bonsai/bim/module/drawing/operator.py | 101 +++--------------- src/bonsai/test/bim/feature/drawing.feature | 13 +-- src/bonsai/test/tool/test_drawing.py | 2 +- 3 files changed, 23 insertions(+), 93 deletions(-) diff --git a/src/bonsai/bonsai/bim/module/drawing/operator.py b/src/bonsai/bonsai/bim/module/drawing/operator.py index f92f7e0085..144a687af6 100644 --- a/src/bonsai/bonsai/bim/module/drawing/operator.py +++ b/src/bonsai/bonsai/bim/module/drawing/operator.py @@ -3815,91 +3815,14 @@ class AddReferenceImage(bpy.types.Operator, tool.Ifc.Operator, ImportHelper): description="Existing object name to add a style with reference image to. If not provided will create a new object.", options={"SKIP_SAVE"}, ) - - x_length: bpy.props.FloatProperty( - name="X Length", - description="Width of the reference image in project units", - default=1.0, - min=0.001, - soft_min=0.01, - precision=3, - ) - y_length: bpy.props.FloatProperty( - name="Y Length", - description="Height of the reference image in project units", - default=1.0, - min=0.001, - soft_min=0.01, - precision=3, - ) - - show_dimensions_dialog: bpy.props.BoolProperty(default=False, options={"HIDDEN", "SKIP_SAVE"}) + size: bpy.props.FloatProperty(name="Size", description="Size of the reference image", default=1.0, unit="LENGTH") def draw(self, context): - layout = self.layout - - if getattr(self, "show_dimensions_dialog", False): - if tool.Ifc.get(): - length_unit = ifcopenshell.util.unit.get_project_unit(tool.Ifc.get(), "LENGTHUNIT") - if length_unit: - unit_name = ifcopenshell.util.unit.get_full_unit_name(length_unit).lower() - else: - unit_name = "project units" - layout.label(text=f"Set Reference Image Dimensions (in {unit_name}):") - else: - layout.label(text="Set Reference Image Dimensions (in project units):") - layout.separator() - layout.prop(self, "x_length") - layout.prop(self, "y_length") - else: - if Path(tool.Ifc.get_path()).is_file(): - layout.prop(self, "use_relative_path") - else: - self.use_relative_path = False - layout.label(text="Save the .ifc file first ") - layout.label(text="to use relative paths.") - layout.prop(self, "override_existing_image") - layout.prop(self, "use_existing_object_by_name") - - def invoke(self, context, event): - if not getattr(self, "show_dimensions_dialog", False): - context.window_manager.fileselect_add(self) - return {"RUNNING_MODAL"} - else: - return context.window_manager.invoke_props_dialog(self) - - def execute(self, context): - if not getattr(self, "show_dimensions_dialog", False): - abs_path = Path(self.filepath).absolute().resolve() - if self.override_existing_image: - params = {"check_existing": True, "force_reload": True} - else: - params = {"check_existing": False} - - try: - image = load_image(abs_path.name, str(abs_path.parent), **params) - - image_width_px = image.size[0] - image_height_px = image.size[1] - aspect_ratio = image_width_px / image_height_px - - if aspect_ratio >= 1.0: - self.x_length = 1.0 - self.y_length = 1.0 / aspect_ratio - else: - self.x_length = aspect_ratio - self.y_length = 1.0 - - bpy.data.images.remove(image) - - except Exception as e: - self.report({"ERROR"}, f"Failed to load image: {str(e)}") - return {"CANCELLED"} - - self.show_dimensions_dialog = True - return context.window_manager.invoke_props_dialog(self) - - return self._execute(context) + if Path(tool.Ifc.get_path()).is_file(): + self.layout.prop(self, "use_relative_path") + self.layout.prop(self, "override_existing_image") + self.layout.prop(self, "use_existing_object_by_name") + self.layout.prop(self, "size") def _execute(self, context): space = tool.Blender.get_view3d_space() @@ -3920,11 +3843,19 @@ class AddReferenceImage(bpy.types.Operator, tool.Ifc.Operator, ImportHelper): params = {"check_existing": False} image = load_image(abs_path.name, str(abs_path.parent), **params) + aspect_ratio = image.size[0] / image.size[1] + if aspect_ratio >= 1.0: # Landscape + x_length = self.size + y_length = self.size / aspect_ratio + else: + x_length = self.size / aspect_ratio + y_length = self.size + def bm_add_image_plane(mesh): bm = tool.Blender.get_bmesh_for_mesh(mesh, clean=True) unit_scale = ifcopenshell.util.unit.calculate_unit_scale(ifc_file) - plane_scale = Vector((self.x_length * unit_scale / 2.0, self.y_length * unit_scale / 2.0, 1.0)) + plane_scale = Vector((x_length / 2.0, y_length / 2.0, 1.0)) matrix = Matrix.LocRotScale(None, None, plane_scale) bmesh.ops.create_grid(bm, x_segments=1, y_segments=1, size=1, matrix=matrix, calc_uvs=False) @@ -4028,8 +3959,6 @@ class AddReferenceImage(bpy.types.Operator, tool.Ifc.Operator, ImportHelper): tool.Style.reload_material_from_ifc(material) tool.Geometry.record_object_materials(obj) - return {"FINISHED"} - class ConvertSVGToDXF(bpy.types.Operator): bl_idname = "bim.convert_svg_to_dxf" diff --git a/src/bonsai/test/bim/feature/drawing.feature b/src/bonsai/test/bim/feature/drawing.feature index 6a24168e4b..b7b78e748d 100644 --- a/src/bonsai/test/bim/feature/drawing.feature +++ b/src/bonsai/test/bim/feature/drawing.feature @@ -3,12 +3,6 @@ Feature: Drawing Scenario: Duplicate drawing Given an empty IFC project - And I add a cube - And the object "Cube" is selected - And I look at the "Class" panel - And I set the "Products" property to "IfcElement" - And I set the "Class" property to "IfcWall" - And I click "Assign IFC Class" And I save IFC project And I look at the "Drawings" panel And I click "IMPORT" @@ -315,3 +309,10 @@ Scenario: Create sheet - with a drawing added to it And I click "IMAGE_PLANE" When I click "OUTPUT" Then the file "{ifc_dir}/sheets/A01 - UNTITLED.svg" should contain "IfcWall" + +Scenario: Add reference image + Given an empty IFC project + And I save IFC project + When I press "bim.add_reference_image(filepath='{cwd}/test/files/image.jpg')" + Then the object "IfcAnnotation/image" exists + And the object "IfcAnnotation/image" dimensions are "1.0,0.565,0." diff --git a/src/bonsai/test/tool/test_drawing.py b/src/bonsai/test/tool/test_drawing.py index 8501b5b9ea..b0d2756198 100644 --- a/src/bonsai/test/tool/test_drawing.py +++ b/src/bonsai/test/tool/test_drawing.py @@ -938,7 +938,7 @@ class TestAddReferenceImage(NewFile): obj = bpy.data.objects["IfcAnnotation/image"] assert obj is not None - assert tool.Cad.are_vectors_equal(obj.dimensions, Vector((3.53982, 2.0, 0.0))) + assert tool.Cad.are_vectors_equal(obj.dimensions, Vector((1.0, 0.565, 0.0))) material = obj.active_material assert material From 8023a992da750b44fd738fec6218e3c440e820fb Mon Sep 17 00:00:00 2001 From: Dion Moult Date: Tue, 17 Feb 2026 18:16:20 +1100 Subject: [PATCH 11/15] Fix tests where panel name and tab panel name is identical For now probably just easier to skip tabs. They are just containers and not worth testing. Famous last words :) --- src/bonsai/test/bim/test_feature.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/bonsai/test/bim/test_feature.py b/src/bonsai/test/bim/test_feature.py index a6d93c0006..262dce65ed 100644 --- a/src/bonsai/test/bim/test_feature.py +++ b/src/bonsai/test/bim/test_feature.py @@ -280,6 +280,8 @@ def create_ui_name_cache(): try: panel_type = getattr(bpy.types, bl_idname) if panel_type.bl_rna.base.name == "Panel": + if "_tab_" in panel_type.bl_idname: + continue # Tab panels are just groups and not relevant in testing ui_name_cache[panel_type.bl_label] = panel_type.bl_idname elif panel_type.bl_rna.base.name == "Operator": ui_name_cache[panel_type.bl_label] = bl_idname From ed500d58ba3cb093bc56beacef62eed5514b4f2f Mon Sep 17 00:00:00 2001 From: Dion Moult Date: Tue, 17 Feb 2026 18:16:33 +1100 Subject: [PATCH 12/15] For consistency, maxfail=1 for module tool tests --- src/bonsai/Makefile | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/bonsai/Makefile b/src/bonsai/Makefile index 6b4704b7b3..38fbc61355 100644 --- a/src/bonsai/Makefile +++ b/src/bonsai/Makefile @@ -371,7 +371,7 @@ test-tool: ifndef MODULE pytest test/tool else - pytest test/tool/test_$(MODULE).py + pytest test/tool/test_$(MODULE).py --maxfail=1 endif # Reregistering test is not added to the standard test suite because during unregister From 291e815770a9d6d3aa0d3c1bde13f1d14f3b3a7f Mon Sep 17 00:00:00 2001 From: Bruno Postle Date: Fri, 13 Feb 2026 18:24:46 +0000 Subject: [PATCH 13/15] Add AGENTS.md contributor guide Guidelines for external contributors using AI coding tools, covering licensing, AI disclosure requirements, PR scope, commit style, code formatting, and testing expectations. Generated with the assistance of an AI coding tool. --- AGENTS.md | 153 ++++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 153 insertions(+) create mode 100644 AGENTS.md diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000000..fd0e305245 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,153 @@ + + +# AGENTS.md + +Guidelines for AI coding agents contributing to IfcOpenShell. This file is +intended to be read by all AI agents regardless of platform (Claude Code, +Copilot, Cursor, etc.) in addition to any tool-specific configuration files. + +Human contributors using AI tools should also read this document carefully, +as they are responsible for ensuring their contributions comply with these +guidelines. + +## Project Overview + +IfcOpenShell is an open source library for working with Industry Foundation +Classes (IFC). It provides C++ and Python APIs, geometry processing, and an +ecosystem of tools including IfcConvert and the Bonsai Blender add-on. + +## Licensing + +All contributions must be compatible with the project's licensing: + +- **Library code** (everything except Bonsai): **LGPL-3.0-or-later** +- **Bonsai** (`src/bonsai/`): **GPL-3.0-or-later** + +There is no Contributor License Agreement (CLA). By submitting a pull request, +you agree that your contribution is licensed under the applicable license above. + +## Indicating AI-Generated Code + +Contributors must clearly indicate when code has been generated or +substantially written by an AI tool. + +### Commits + +Commits that modify existing code must include a note in the **body** of the +commit message (not the subject line) indicating that the change was +AI-generated. For example: + +``` +Fix off-by-one error in element iteration + +The loop termination condition was incorrect when processing +IfcRelAggregates relationships. + +Generated with the assistance of an AI coding tool. +``` + +### New Files + +New files that are AI-generated must include a comment near the top of the +file indicating this. Use the appropriate comment syntax for the language: + +```python +# This file was generated with the assistance of an AI coding tool. +``` + +```cpp +// This file was generated with the assistance of an AI coding tool. +``` + +### Pull Requests + +Pull requests containing AI-generated code must indicate in the PR description +which parts of the contribution are AI-generated. If the entire PR is +AI-generated, state that clearly. If only specific commits or files are +AI-generated, identify them. + +## Pull Request Guidelines + +### Scope and Size + +- Each pull request should address a **single issue or feature**. +- Do not mix unrelated changes (e.g., bug fixes with refactoring or style + changes) in the same PR. +- Large pull requests should be broken down into **multiple small, standalone + commits** that are each easy to review independently. Rewrite commit history + for this purpose if necessary. +- PRs that are minimal, focused solutions to a specific problem are much more + likely to be accepted. + +### What to Avoid + +- **Over-engineering**: Do not add features, abstractions, or configurability + beyond what is needed to solve the immediate problem. +- **Scope creep**: Do not make changes to files or code that are not directly + related to the task at hand. +- **Unnecessary additions**: Do not add docstrings, comments, type annotations, + or error handling to code you did not otherwise need to change. +- **Cosmetic changes**: Do not reformat, rename, or reorganize code that is + unrelated to your change. + +## Commit Messages + +- The **subject line** must be **50 characters or less**. +- Use the **imperative mood** (e.g., "Fix crash in geometry kernel", not + "Fixed crash" or "Fixes crash"). +- A commit message can be a single line if the purpose is obvious from the + subject alone. +- Otherwise, add a blank line after the subject followed by a short explanation + of a few lines in the body. + +## Code Style + +### Python + +- **Line length**: 120 characters +- **Formatter**: black +- **Linter**: ruff +- Configuration is in `pyproject.toml` + +### C++ + +- **Standard**: C++17 minimum +- **Formatter**: clang-format (configuration in `.clang-format`) +- **Linter**: clang-tidy (configuration in `.clang-tidy`) + +Run linters and formatters **before submitting** your pull request. Do not rely +on CI to catch formatting issues. + +## Testing + +- Pull requests with test coverage are **much more likely to be merged**. +- If tests are appropriate and feasible for your change, they should be + included. +- Tests are not required for every change (e.g., documentation-only changes), + but the expectation is that testable code changes come with tests. +- Python tests use **pytest** and are located in `test/` or `tests/` directories + within each package under `src/`. +- Run the existing test suite for the package you modified before submitting. + +## Architecture Quick Reference + +### Directory Structure + +- `src/ifcparse/` — C++ IFC file parsing +- `src/ifcgeom/` — C++ geometry processing (OpenCASCADE and CGAL kernels) +- `src/serializers/` — Output format serializers (glTF, Collada, SVG, etc.) +- `src/ifcwrap/` — SWIG Python bindings +- `src/ifcconvert/` — CLI conversion tool +- `src/ifcopenshell-python/` — Python API (`ifcopenshell` package) +- `src/bonsai/` — Blender add-on (GPL-3.0-or-later) +- `src/ifctester/` — IDS model auditing +- `src/ifcpatch/` — IFC file manipulation scripts +- `src/ifcdiff/` — IFC model comparison +- `src/ifcclash/` — Clash detection +- `src/ifccsv/` — Schedule import/export + +### IFC Schema Versions + +The library supports IFC2x3 TC1, IFC4 Add2 TC1, IFC4x1, IFC4x2, and +IFC4x3 Add2. Schema-specific code is compiled conditionally. Be aware of +which schema versions your change affects. From a5461c074849f81568f215a49a1e460f97a2e95e Mon Sep 17 00:00:00 2001 From: Sebastian Schilling Date: Thu, 12 Feb 2026 12:04:36 +0100 Subject: [PATCH 14/15] buildingSMART Data Dictionary module: added textfield to change data dictionary url --- src/bonsai/bonsai/bim/module/bsdd/__init__.py | 1 + src/bonsai/bonsai/bim/module/bsdd/prop.py | 19 +++++++++++- src/bonsai/bonsai/bim/module/bsdd/ui.py | 29 +++++++++++++++++++ 3 files changed, 48 insertions(+), 1 deletion(-) diff --git a/src/bonsai/bonsai/bim/module/bsdd/__init__.py b/src/bonsai/bonsai/bim/module/bsdd/__init__.py index 25d6f501f0..eecc5880fa 100644 --- a/src/bonsai/bonsai/bim/module/bsdd/__init__.py +++ b/src/bonsai/bonsai/bim/module/bsdd/__init__.py @@ -37,6 +37,7 @@ classes = ( ui.BIM_UL_bsdd_classes, ui.BIM_UL_bsdd_properties, ui.BIM_PT_bsdd, + ui.BIM_OT_bsdd_reset_baseurl, ) diff --git a/src/bonsai/bonsai/bim/module/bsdd/prop.py b/src/bonsai/bonsai/bim/module/bsdd/prop.py index 347cdab1b2..b7f1e57569 100644 --- a/src/bonsai/bonsai/bim/module/bsdd/prop.py +++ b/src/bonsai/bonsai/bim/module/bsdd/prop.py @@ -19,6 +19,7 @@ from typing import TYPE_CHECKING, Literal, Union import bpy +import bsdd from bpy.props import ( BoolProperty, CollectionProperty, @@ -73,6 +74,14 @@ def update_active_class_index(self: "BIMBSDDProperties", context: bpy.types.Cont BSDDData.data["active_dictionary"] = BSDDData.active_dictionary() +def update_bsdd_baseurl(self: "BIMBSDDProperties", context: bpy.types.Context) -> None: + try: + tool.Bsdd.client = bsdd.Client() + if hasattr(tool.Bsdd.client, "baseurl"): + tool.Bsdd.client.baseurl = self.bsdd_baseurl + except Exception: + pass + class BSDDDictionary(PropertyGroup): uri: StringProperty(name="URI") default_language_code: StringProperty(name="Language") @@ -164,6 +173,13 @@ class BIMBSDDProperties(PropertyGroup): ) classification_psets: CollectionProperty(name="Classification Psets", type=BSDDPset) + bsdd_baseurl: StringProperty( + name="Other URL:", + description="URL from another Dictionary with bSDD API", + default="", + update=update_bsdd_baseurl, + ) + if TYPE_CHECKING: active_dictionary: str active_dictionary: str @@ -182,7 +198,8 @@ class BIMBSDDProperties(PropertyGroup): should_filter_ifc_class: bool use_only_ifc_properties: bool classification_psets: bpy.types.bpy_prop_collection_idprop[BSDDPset] - + bsdd_baseurl: str + @property def active_class(self) -> Union[BSDDClassification, None]: return tool.Blender.get_active_uilist_element(self.classes, self.active_class_index) diff --git a/src/bonsai/bonsai/bim/module/bsdd/ui.py b/src/bonsai/bonsai/bim/module/bsdd/ui.py index c6966c01d8..a44be23664 100644 --- a/src/bonsai/bonsai/bim/module/bsdd/ui.py +++ b/src/bonsai/bonsai/bim/module/bsdd/ui.py @@ -24,6 +24,7 @@ import bpy from bpy.types import Panel, UIList import bonsai.tool as tool +import bsdd from bonsai.bim.module.bsdd.data import BSDDData if TYPE_CHECKING: @@ -50,6 +51,17 @@ class BIM_PT_bsdd(Panel): props = tool.Bsdd.get_bsdd_props() assert self.layout layout = self.layout + try: + if not props.bsdd_baseurl: + try: + props.bsdd_baseurl = tool.Bsdd.client.baseurl + except Exception: + props.bsdd_baseurl = bsdd.Client().baseurl + except Exception: + pass + row = layout.row(align=True) + row.prop(props, "bsdd_baseurl", text="Other URL", emboss=True) + row.operator("bim.bsdd_reset_baseurl", icon="LOOP_BACK", text="") if len(props.dictionaries): row = self.layout.row() row.operator("bim.load_bsdd_dictionaries", icon="FILE_REFRESH") @@ -83,6 +95,23 @@ class BIM_PT_bsdd(Panel): row = self.layout.row() row.operator("bim.load_bsdd_dictionaries") +class BIM_OT_bsdd_reset_baseurl(bpy.types.Operator): + bl_idname = "bim.bsdd_reset_baseurl" + bl_label = "Reset bSDD Base URL" + bl_description = "Resets the bSDD base URL to the default value" + + def execute(self, context): + import bsdd + props = tool.Bsdd.get_bsdd_props() + props.bsdd_baseurl = "" + try: + tool.Bsdd.client = bsdd.Client() + if hasattr(tool.Bsdd.client, "baseurl"): + tool.Bsdd.client.baseurl = default_url + except Exception: + pass + self.report({'INFO'}, "bSDD base URL reset.") + return {'FINISHED'} class BIM_UL_bsdd_dictionaries(UIList): def draw_item( From 418d410b5c0e0a5d775249f12d0be611bdb2a4df Mon Sep 17 00:00:00 2001 From: Sebastian Schilling Date: Tue, 17 Feb 2026 15:10:13 +0100 Subject: [PATCH 15/15] moved change of bsdd baseurl change to addon settings --- src/bonsai/bonsai/bim/module/bsdd/__init__.py | 1 - src/bonsai/bonsai/bim/module/bsdd/prop.py | 17 ----------- src/bonsai/bonsai/bim/module/bsdd/ui.py | 29 ------------------- src/bonsai/bonsai/bim/ui.py | 5 ++++ src/bonsai/bonsai/tool/bsdd.py | 4 +++ 5 files changed, 9 insertions(+), 47 deletions(-) diff --git a/src/bonsai/bonsai/bim/module/bsdd/__init__.py b/src/bonsai/bonsai/bim/module/bsdd/__init__.py index eecc5880fa..25d6f501f0 100644 --- a/src/bonsai/bonsai/bim/module/bsdd/__init__.py +++ b/src/bonsai/bonsai/bim/module/bsdd/__init__.py @@ -37,7 +37,6 @@ classes = ( ui.BIM_UL_bsdd_classes, ui.BIM_UL_bsdd_properties, ui.BIM_PT_bsdd, - ui.BIM_OT_bsdd_reset_baseurl, ) diff --git a/src/bonsai/bonsai/bim/module/bsdd/prop.py b/src/bonsai/bonsai/bim/module/bsdd/prop.py index b7f1e57569..efd8db46db 100644 --- a/src/bonsai/bonsai/bim/module/bsdd/prop.py +++ b/src/bonsai/bonsai/bim/module/bsdd/prop.py @@ -19,7 +19,6 @@ from typing import TYPE_CHECKING, Literal, Union import bpy -import bsdd from bpy.props import ( BoolProperty, CollectionProperty, @@ -74,14 +73,6 @@ def update_active_class_index(self: "BIMBSDDProperties", context: bpy.types.Cont BSDDData.data["active_dictionary"] = BSDDData.active_dictionary() -def update_bsdd_baseurl(self: "BIMBSDDProperties", context: bpy.types.Context) -> None: - try: - tool.Bsdd.client = bsdd.Client() - if hasattr(tool.Bsdd.client, "baseurl"): - tool.Bsdd.client.baseurl = self.bsdd_baseurl - except Exception: - pass - class BSDDDictionary(PropertyGroup): uri: StringProperty(name="URI") default_language_code: StringProperty(name="Language") @@ -172,13 +163,6 @@ class BIMBSDDProperties(PropertyGroup): default=False, ) classification_psets: CollectionProperty(name="Classification Psets", type=BSDDPset) - - bsdd_baseurl: StringProperty( - name="Other URL:", - description="URL from another Dictionary with bSDD API", - default="", - update=update_bsdd_baseurl, - ) if TYPE_CHECKING: active_dictionary: str @@ -198,7 +182,6 @@ class BIMBSDDProperties(PropertyGroup): should_filter_ifc_class: bool use_only_ifc_properties: bool classification_psets: bpy.types.bpy_prop_collection_idprop[BSDDPset] - bsdd_baseurl: str @property def active_class(self) -> Union[BSDDClassification, None]: diff --git a/src/bonsai/bonsai/bim/module/bsdd/ui.py b/src/bonsai/bonsai/bim/module/bsdd/ui.py index a44be23664..f239eb41cf 100644 --- a/src/bonsai/bonsai/bim/module/bsdd/ui.py +++ b/src/bonsai/bonsai/bim/module/bsdd/ui.py @@ -51,17 +51,6 @@ class BIM_PT_bsdd(Panel): props = tool.Bsdd.get_bsdd_props() assert self.layout layout = self.layout - try: - if not props.bsdd_baseurl: - try: - props.bsdd_baseurl = tool.Bsdd.client.baseurl - except Exception: - props.bsdd_baseurl = bsdd.Client().baseurl - except Exception: - pass - row = layout.row(align=True) - row.prop(props, "bsdd_baseurl", text="Other URL", emboss=True) - row.operator("bim.bsdd_reset_baseurl", icon="LOOP_BACK", text="") if len(props.dictionaries): row = self.layout.row() row.operator("bim.load_bsdd_dictionaries", icon="FILE_REFRESH") @@ -95,24 +84,6 @@ class BIM_PT_bsdd(Panel): row = self.layout.row() row.operator("bim.load_bsdd_dictionaries") -class BIM_OT_bsdd_reset_baseurl(bpy.types.Operator): - bl_idname = "bim.bsdd_reset_baseurl" - bl_label = "Reset bSDD Base URL" - bl_description = "Resets the bSDD base URL to the default value" - - def execute(self, context): - import bsdd - props = tool.Bsdd.get_bsdd_props() - props.bsdd_baseurl = "" - try: - tool.Bsdd.client = bsdd.Client() - if hasattr(tool.Bsdd.client, "baseurl"): - tool.Bsdd.client.baseurl = default_url - except Exception: - pass - self.report({'INFO'}, "bSDD base URL reset.") - return {'FINISHED'} - class BIM_UL_bsdd_dictionaries(UIList): def draw_item( self, diff --git a/src/bonsai/bonsai/bim/ui.py b/src/bonsai/bonsai/bim/ui.py index 32240a3fee..30ce93f076 100644 --- a/src/bonsai/bonsai/bim/ui.py +++ b/src/bonsai/bonsai/bim/ui.py @@ -660,6 +660,10 @@ class BIM_ADDON_preferences(bpy.types.AddonPreferences): bsdd_load_test_dictionaries: BoolProperty( name="Load Test Dictionaries", description="Load dictionaries that are for testing only", default=False ) + bsdd_baseurl: StringProperty( + name="bSDD API Base URL", description="Base URL for data dictionary API requests, e.g. https://api.bsdd.buildingsmart.org/api/", + default="https://api.bsdd.buildingsmart.org/api/", + ) should_disable_undo_on_save: BoolProperty( name="Disable Undo When Saving (Faster saves, no undo for you!)", default=False ) @@ -972,6 +976,7 @@ class BIM_ADDON_preferences(bpy.types.AddonPreferences): layout.prop(self, "bsdd_load_preview_dictionaries") layout.prop(self, "bsdd_load_inactive_dictionaries") layout.prop(self, "bsdd_load_test_dictionaries") + layout.prop(self, "bsdd_baseurl") def draw_extras_settings(self, layout: bpy.types.UILayout, context: bpy.types.Context) -> None: layout.prop(self, "container_hide_show_isolate") diff --git a/src/bonsai/bonsai/tool/bsdd.py b/src/bonsai/bonsai/tool/bsdd.py index dc42708867..47c948abcc 100644 --- a/src/bonsai/bonsai/tool/bsdd.py +++ b/src/bonsai/bonsai/tool/bsdd.py @@ -123,6 +123,10 @@ class Bsdd(bonsai.core.tool.Bsdd): @classmethod def get_dictionaries(cls) -> list[bsdd.DictionaryContractV1]: prefs = tool.Blender.get_addon_preferences() + baseurl = getattr(prefs, "bsdd_baseurl", "https://api.bsdd.buildingsmart.org/api/") + cls.client = bsdd.Client() + if hasattr(cls.client, "baseurl"): + cls.client.baseurl = baseurl response = cls.client.get_dictionary(include_test_dictionaries=prefs.bsdd_load_test_dictionaries) dicts = response.get("dictionaries") or [] statuses = ["Active"]