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/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.
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",
]
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
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/bsdd/prop.py b/src/bonsai/bonsai/bim/module/bsdd/prop.py
index 347cdab1b2..efd8db46db 100644
--- a/src/bonsai/bonsai/bim/module/bsdd/prop.py
+++ b/src/bonsai/bonsai/bim/module/bsdd/prop.py
@@ -163,7 +163,7 @@ class BIMBSDDProperties(PropertyGroup):
default=False,
)
classification_psets: CollectionProperty(name="Classification Psets", type=BSDDPset)
-
+
if TYPE_CHECKING:
active_dictionary: str
active_dictionary: str
@@ -182,7 +182,7 @@ class BIMBSDDProperties(PropertyGroup):
should_filter_ifc_class: bool
use_only_ifc_properties: bool
classification_psets: bpy.types.bpy_prop_collection_idprop[BSDDPset]
-
+
@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..f239eb41cf 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:
@@ -83,7 +84,6 @@ class BIM_PT_bsdd(Panel):
row = self.layout.row()
row.operator("bim.load_bsdd_dictionaries")
-
class BIM_UL_bsdd_dictionaries(UIList):
def draw_item(
self,
diff --git a/src/bonsai/bonsai/bim/module/drawing/operator.py b/src/bonsai/bonsai/bim/module/drawing/operator.py
index d02e4c6c76..144a687af6 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
@@ -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,23 +3094,16 @@ 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"
- 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)
+ 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)
@@ -3165,6 +3162,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
@@ -3817,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()
@@ -3922,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)
@@ -4030,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/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..1d0e52976b 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,125 @@ class LinkIfc(bpy.types.Operator, ImportHelper):
row = self.layout.row()
row.prop(pprops, "project_north")
- def execute(self, context):
+ 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 +1503,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 +1521,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 +1560,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 +1583,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 +1620,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 +1648,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 +1678,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 +1689,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 +1970,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 +2008,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/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/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/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/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"]
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/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/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/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):
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()"}
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/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..262dce65ed 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()
@@ -283,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
@@ -311,25 +310,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 +364,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 +1557,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_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
+ ]
diff --git a/src/bonsai/test/tool/test_drawing.py b/src/bonsai/test/tool/test_drawing.py
index f7beb94358..b0d2756198 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)
@@ -908,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
@@ -928,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()
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/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;
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