Merge branch 'v0.8.0' into ifcmax/initial-refresh

This commit is contained in:
Josef Wienerroither
2026-02-18 10:23:01 +01:00
36 changed files with 909 additions and 693 deletions
+2
View File
@@ -82,6 +82,8 @@ src/bonsai/bonsai/translations.py
# bonsai test temp files # bonsai test temp files
src/bonsai/test/files/temp src/bonsai/test/files/temp
src/bonsai/test/files/basic.ifc.cache.blend
src/bonsai/test/files/basic.ifc.cache.sqlite
# bonsai data # bonsai data
src/bonsai/bonsai/bim/data/build/ src/bonsai/bonsai/bim/data/build/
+153
View File
@@ -0,0 +1,153 @@
<!-- This file was generated with the assistance of an AI coding tool. -->
# 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.
+1 -1
View File
@@ -3,7 +3,7 @@ name = "IfcOpenShell"
version = "0.0.0" version = "0.0.0"
dependencies = [ dependencies = [
"black==26.1.0", "black==26.1.0",
"ruff==0.15.0", "ruff==0.15.1",
"poethepoet", "poethepoet",
"gersemi==0.25.4", "gersemi==0.25.4",
] ]
+1 -1
View File
@@ -371,7 +371,7 @@ test-tool:
ifndef MODULE ifndef MODULE
pytest test/tool pytest test/tool
else else
pytest test/tool/test_$(MODULE).py pytest test/tool/test_$(MODULE).py --maxfail=1
endif endif
# Reregistering test is not added to the standard test suite because during unregister # Reregistering test is not added to the standard test suite because during unregister
-1
View File
@@ -52,7 +52,6 @@ class IfcExporter:
self.set_header() self.set_header()
IfcStore.update_cache() IfcStore.update_cache()
self.sync_all_objects() self.sync_all_objects()
tool.Project.save_linked_models_to_ifc()
extension = self.ifc_export_settings.output_file.split(".")[-1].lower() extension = self.ifc_export_settings.output_file.split(".")[-1].lower()
if extension == "ifczip": if extension == "ifczip":
with tempfile.TemporaryDirectory() as unzipped_path: with tempfile.TemporaryDirectory() as unzipped_path:
+2 -2
View File
@@ -163,7 +163,7 @@ class BIMBSDDProperties(PropertyGroup):
default=False, default=False,
) )
classification_psets: CollectionProperty(name="Classification Psets", type=BSDDPset) classification_psets: CollectionProperty(name="Classification Psets", type=BSDDPset)
if TYPE_CHECKING: if TYPE_CHECKING:
active_dictionary: str active_dictionary: str
active_dictionary: str active_dictionary: str
@@ -182,7 +182,7 @@ class BIMBSDDProperties(PropertyGroup):
should_filter_ifc_class: bool should_filter_ifc_class: bool
use_only_ifc_properties: bool use_only_ifc_properties: bool
classification_psets: bpy.types.bpy_prop_collection_idprop[BSDDPset] classification_psets: bpy.types.bpy_prop_collection_idprop[BSDDPset]
@property @property
def active_class(self) -> Union[BSDDClassification, None]: def active_class(self) -> Union[BSDDClassification, None]:
return tool.Blender.get_active_uilist_element(self.classes, self.active_class_index) return tool.Blender.get_active_uilist_element(self.classes, self.active_class_index)
+1 -1
View File
@@ -24,6 +24,7 @@ import bpy
from bpy.types import Panel, UIList from bpy.types import Panel, UIList
import bonsai.tool as tool import bonsai.tool as tool
import bsdd
from bonsai.bim.module.bsdd.data import BSDDData from bonsai.bim.module.bsdd.data import BSDDData
if TYPE_CHECKING: if TYPE_CHECKING:
@@ -83,7 +84,6 @@ class BIM_PT_bsdd(Panel):
row = self.layout.row() row = self.layout.row()
row.operator("bim.load_bsdd_dictionaries") row.operator("bim.load_bsdd_dictionaries")
class BIM_UL_bsdd_dictionaries(UIList): class BIM_UL_bsdd_dictionaries(UIList):
def draw_item( def draw_item(
self, self,
+29 -102
View File
@@ -928,7 +928,7 @@ class CreateDrawing(bpy.types.Operator):
props = tool.Project.get_project_props() props = tool.Project.get_project_props()
for link in props.get_loaded_links_for_drawings(): for link in props.get_loaded_links_for_drawings():
files[link.name] = self.get_linked_file(link) files[link.filepath] = self.get_linked_file(link)
target_view = ifcopenshell.util.element.get_psets(self.camera_element)["EPset_Drawing"]["TargetView"] target_view = ifcopenshell.util.element.get_psets(self.camera_element)["EPset_Drawing"]["TargetView"]
self.setup_serialiser(target_view) self.setup_serialiser(target_view)
@@ -1374,7 +1374,7 @@ class CreateDrawing(bpy.types.Operator):
return True return True
def get_linked_file(self, link: "Link") -> ifcopenshell.file: def get_linked_file(self, link: "Link") -> ifcopenshell.file:
link_path = link.name link_path = link.filepath
ifc_file = IfcStore.session_files.get(link_path, None) ifc_file = IfcStore.session_files.get(link_path, None)
if ifc_file is not None: if ifc_file is not None:
return ifc_file return ifc_file
@@ -2896,12 +2896,16 @@ class AddSchedule(bpy.types.Operator, tool.Ifc.Operator, ImportHelper):
bl_options = {"REGISTER", "UNDO"} bl_options = {"REGISTER", "UNDO"}
bl_description = "Add an .ods, .xls or .xlsx file as a schedule" 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"}) filter_glob: bpy.props.StringProperty(default="*.ods;*.xls;*.xlsx", options={"HIDDEN"})
use_relative_path: bpy.props.BoolProperty(name="Use Relative Path", default=True) use_relative_path: bpy.props.BoolProperty(name="Use Relative Path", default=True)
def _execute(self, context): def _execute(self, context):
filepath = tool.Ifc.get_uri(self.filepath, use_relative_path=self.use_relative_path) for filepath in tool.Blender.get_selected_files(
core.add_document(tool.Ifc, tool.Drawing, "SCHEDULE", uri=filepath) 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): 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_description = "Import a .svg file to the project as a reference"
bl_options = {"REGISTER", "UNDO"} 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"}) filter_glob: bpy.props.StringProperty(default="*.svg", options={"HIDDEN"})
use_relative_path: bpy.props.BoolProperty(name="Use Relative Path", default=True) use_relative_path: bpy.props.BoolProperty(name="Use Relative Path", default=True)
filename_ext = ".svg" filename_ext = ".svg"
files: bpy.props.CollectionProperty(type=bpy.types.OperatorFileListElement)
directory: bpy.props.StringProperty(subtype="DIR_PATH")
def _execute(self, context): def _execute(self, context):
# Handle both single and multiple file selection for filepath in tool.Blender.get_selected_files(
if self.files: self.directory, self.files, use_relative_path=self.use_relative_path
for file_elem in self.files: ):
filepath = os.path.join(self.directory, file_elem.name)
uri = tool.Ifc.get_uri(filepath, use_relative_path=self.use_relative_path)
core.add_document(tool.Ifc, tool.Drawing, "REFERENCE", uri=uri)
else:
# Fallback for single file (backward compatibility)
filepath = tool.Ifc.get_uri(self.filepath, use_relative_path=self.use_relative_path)
core.add_document(tool.Ifc, tool.Drawing, "REFERENCE", uri=filepath) 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() bpy.ops.bim.disable_editing_text()
def execute(self, context): def execute(self, context):
# TODO: check for possible subtle undo bug here
# can't use invoke() because this operator # can't use invoke() because this operator
# will be run indirectly by hotkey # will be run indirectly by hotkey
# so we use execute() and track whether it's the first run of the operator # 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.", description="Existing object name to add a style with reference image to. If not provided will create a new object.",
options={"SKIP_SAVE"}, options={"SKIP_SAVE"},
) )
size: bpy.props.FloatProperty(name="Size", description="Size of the reference image", default=1.0, unit="LENGTH")
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"})
def draw(self, context): def draw(self, context):
layout = self.layout if Path(tool.Ifc.get_path()).is_file():
self.layout.prop(self, "use_relative_path")
if getattr(self, "show_dimensions_dialog", False): self.layout.prop(self, "override_existing_image")
if tool.Ifc.get(): self.layout.prop(self, "use_existing_object_by_name")
length_unit = ifcopenshell.util.unit.get_project_unit(tool.Ifc.get(), "LENGTHUNIT") self.layout.prop(self, "size")
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)
def _execute(self, context): def _execute(self, context):
space = tool.Blender.get_view3d_space() space = tool.Blender.get_view3d_space()
@@ -3922,11 +3843,19 @@ class AddReferenceImage(bpy.types.Operator, tool.Ifc.Operator, ImportHelper):
params = {"check_existing": False} params = {"check_existing": False}
image = load_image(abs_path.name, str(abs_path.parent), **params) 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): def bm_add_image_plane(mesh):
bm = tool.Blender.get_bmesh_for_mesh(mesh, clean=True) bm = tool.Blender.get_bmesh_for_mesh(mesh, clean=True)
unit_scale = ifcopenshell.util.unit.calculate_unit_scale(ifc_file) 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) 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) 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.Style.reload_material_from_ifc(material)
tool.Geometry.record_object_materials(obj) tool.Geometry.record_object_materials(obj)
return {"FINISHED"}
class ConvertSVGToDXF(bpy.types.Operator): class ConvertSVGToDXF(bpy.types.Operator):
bl_idname = "bim.convert_svg_to_dxf" bl_idname = "bim.convert_svg_to_dxf"
+1 -1
View File
@@ -89,7 +89,7 @@ class BIM_PT_camera(Panel):
for link in links: for link in links:
row = panel.row(align=True) row = panel.row(align=True)
split = row.split(factor=0.9) split = row.split(factor=0.9)
split.label(text=link.name, icon="FILE") split.label(text=link.filepath, icon="FILE")
split.prop(link, "include_in_drawings", text="") split.prop(link, "include_in_drawings", text="")
else: else:
panel.label(text="No IFC projects linked and loaded.") panel.label(text="No IFC projects linked and loaded.")
@@ -902,6 +902,7 @@ class OverrideDelete(bpy.types.Operator):
if not is_valid_data_block: if not is_valid_data_block:
continue continue
element = tool.Ifc.get_entity(obj) element = tool.Ifc.get_entity(obj)
if element: if element:
if tool.Geometry.is_locked(element): if tool.Geometry.is_locked(element):
@@ -912,6 +913,9 @@ class OverrideDelete(bpy.types.Operator):
if ifcopenshell.util.element.get_pset(element, "BBIM_Array"): if ifcopenshell.util.element.get_pset(element, "BBIM_Array"):
self.report({"INFO"}, "Elements that are part of an array cannot be deleted.") self.report({"INFO"}, "Elements that are part of an array cannot be deleted.")
continue continue
if element.is_a("IfcDocumentReference"):
self.report({"INFO"}, "Linked models cannot be deleted.")
continue
if element.is_a("IfcGridAxis"): if element.is_a("IfcGridAxis"):
# Deleting the last W axis is OK # Deleting the last W axis is OK
if ((grid := element.PartOfU) and len(grid[0].UAxes) == 1) or ( if ((grid := element.PartOfU) and len(grid[0].UAxes) == 1) or (
@@ -1208,6 +1212,11 @@ class OverrideDuplicateMove(bpy.types.Operator):
operator.report({"ERROR"}, f"Drawing '{obj.name}' not duplicated.") operator.report({"ERROR"}, f"Drawing '{obj.name}' not duplicated.")
continue continue
if element.is_a("IfcDocumentReference"):
objects_to_remove.add(obj)
operator.report({"ERROR"}, f"Linked model '{obj.name}' not duplicated.")
continue
if tool.Geometry.is_locked(element): if tool.Geometry.is_locked(element):
objects_to_remove.add(obj) objects_to_remove.add(obj)
operator.report({"ERROR"}, lock_error_message(obj.name)) operator.report({"ERROR"}, lock_error_message(obj.name))
@@ -51,7 +51,7 @@ class RemoveGeoreferencing(bpy.types.Operator, tool.Ifc.Operator):
bl_description = "Remove the georeferencing" bl_description = "Remove the georeferencing"
def _execute(self, context): def _execute(self, context):
core.remove_georeferencing(tool.Ifc) core.remove_georeferencing(tool.Ifc, tool.Georeference)
class EditGeoreferencing(bpy.types.Operator, tool.Ifc.Operator): class EditGeoreferencing(bpy.types.Operator, tool.Ifc.Operator):
@@ -224,16 +224,12 @@ class BIMGeoreferenceProperties(PropertyGroup):
x_axis_ordinate: StringProperty(name="X Axis Ordinate", update=update_grid_north_vector) x_axis_ordinate: StringProperty(name="X Axis Ordinate", update=update_grid_north_vector)
x_axis_is_null: BoolProperty(name="X Axis Is Null") x_axis_is_null: BoolProperty(name="X Axis Is Null")
# These are only for reference to capture data about a host model from a linked model
# If you relink a model from a new host origin, we can autodetect it in theory with this
host_model_origin: StringProperty(name="Host Model Origin")
host_model_origin_si: StringProperty(name="Host Model Origin SI")
host_model_project_north: StringProperty(name="Host Model Angle to Grid North")
# This is the ENH in project units and SI units of the Blender session's 0,0,0. # This is the ENH in project units and SI units of the Blender session's 0,0,0.
# These are only for reference, using tool.Georeference.set_model_origin on # These are only for reference, using tool.Georeference.set_model_origin on
# project load, project create, and when linking for the first time from an # project load, project create, and when linking for the first time from an
# empty Blender session. # empty Blender session.
model_is_georeferenced: BoolProperty(name="Model Is Georeferenced")
model_crs: StringProperty(name="Model CRS")
model_origin: StringProperty(name="Model Origin") model_origin: StringProperty(name="Model Origin")
model_origin_si: StringProperty(name="Model Origin SI") model_origin_si: StringProperty(name="Model Origin SI")
model_project_north: StringProperty(name="Model Angle to Grid North") model_project_north: StringProperty(name="Model Angle to Grid North")
@@ -275,10 +271,6 @@ class BIMGeoreferenceProperties(PropertyGroup):
x_axis_ordinate: str x_axis_ordinate: str
x_axis_is_null: bool x_axis_is_null: bool
host_model_origin: str
host_model_origin_si: str
host_model_project_north: str
model_origin: str model_origin: str
model_origin_si: str model_origin_si: str
model_project_north: str model_project_north: str
@@ -31,27 +31,31 @@ classes = (
operator.BIM_OT_load_clipping_planes, operator.BIM_OT_load_clipping_planes,
operator.BIM_OT_save_clipping_planes, operator.BIM_OT_save_clipping_planes,
operator.ChangeLibraryElement, operator.ChangeLibraryElement,
operator.ClearMeasurement,
operator.ClearRecentIFCProjects, operator.ClearRecentIFCProjects,
operator.CreateClippingPlane, operator.CreateClippingPlane,
operator.CreateProject, operator.CreateProject,
operator.DisableCulling, operator.DisableCulling,
operator.DisableEditingHeader, operator.DisableEditingHeader,
operator.DisableEditingLink,
operator.EditHeader, operator.EditHeader,
operator.EditLink,
operator.EditProjectLibrary, operator.EditProjectLibrary,
operator.EnableCulling, operator.EnableCulling,
operator.EnableEditingHeader, operator.EnableEditingHeader,
operator.EnableEditingLink,
operator.ExportIFC, operator.ExportIFC,
operator.FlipClippingPlane, operator.FlipClippingPlane,
operator.IFCFileHandlerOperator, operator.IFCFileHandlerOperator,
operator.ImageScalingTool, operator.ImageScalingTool,
operator.LinkIfc, operator.LinkIfc,
operator.LoadBlendMetadataAndIFC,
operator.LoadLink, operator.LoadLink,
operator.LoadLinkedProject, operator.LoadLinkedProject,
operator.LoadProject, operator.LoadProject,
operator.LoadProjectElements, operator.LoadProjectElements,
operator.MeasureTool,
operator.MeasureFaceAreaTool, operator.MeasureFaceAreaTool,
operator.ClearMeasurement, operator.MeasureTool,
operator.NewProject, operator.NewProject,
operator.QueryLinkedElement, operator.QueryLinkedElement,
operator.RefreshClippingPlanes, operator.RefreshClippingPlanes,
@@ -69,7 +73,6 @@ classes = (
operator.UnassignLibraryDeclaration, operator.UnassignLibraryDeclaration,
operator.UnlinkIfc, operator.UnlinkIfc,
operator.UnloadLink, operator.UnloadLink,
operator.LoadBlendMetadataAndIFC,
workspace.ExploreHotkey, workspace.ExploreHotkey,
prop.LibraryBreadcrumb, prop.LibraryBreadcrumb,
prop.LibraryElement, prop.LibraryElement,
+224 -120
View File
@@ -18,6 +18,7 @@
import datetime import datetime
import json import json
import math
import logging import logging
import os import os
import subprocess import subprocess
@@ -59,6 +60,15 @@ import bonsai.core.project as core
import bonsai.tool as tool import bonsai.tool as tool
from bonsai.bim import export_ifc, import_ifc from bonsai.bim import export_ifc, import_ifc
from bonsai.bim.ifc import IfcStore from bonsai.bim.ifc import IfcStore
from bonsai.bim.ui import IFCFileSelector
from bonsai.bim import import_ifc
from bonsai.bim import export_ifc
from math import radians, degrees
from pathlib import Path
from collections import defaultdict
from mathutils import Vector, Matrix
from bpy.app.handlers import persistent
from ifcopenshell.geom import ShapeElementType
from bonsai.bim.module.model.decorator import FaceAreaDecorator, PolylineDecorator from bonsai.bim.module.model.decorator import FaceAreaDecorator, PolylineDecorator
from bonsai.bim.module.model.polyline import PolylineOperator from bonsai.bim.module.model.polyline import PolylineOperator
from bonsai.bim.module.project.data import LinksData, ProjectLibraryData from bonsai.bim.module.project.data import LinksData, ProjectLibraryData
@@ -1321,7 +1331,7 @@ class ToggleFilterCategories(bpy.types.Operator):
return {"FINISHED"} return {"FINISHED"}
class LinkIfc(bpy.types.Operator, ImportHelper): class LinkIfc(bpy.types.Operator, ImportHelper, tool.Ifc.Operator):
bl_idname = "bim.link_ifc" bl_idname = "bim.link_ifc"
bl_label = "Link IFC" bl_label = "Link IFC"
bl_options = {"REGISTER", "UNDO"} bl_options = {"REGISTER", "UNDO"}
@@ -1360,134 +1370,125 @@ class LinkIfc(bpy.types.Operator, ImportHelper):
row = self.layout.row() row = self.layout.row()
row.prop(pprops, "project_north") row.prop(pprops, "project_north")
def execute(self, context): def _execute(self, context):
start = time.time() start = time.time()
files = [f.name for f in self.files] if self.files else [self.filepath] files = [f.name for f in self.files] if self.files else [self.filepath]
if not files or all(not f or not f.strip() for f in files):
self.report({"ERROR"}, "No file selected")
return {"CANCELLED"}
existing_links = tool.Project.get_linked_models_documents() if tool.Ifc.get() else {}
for filename in files: for filename in files:
if not filename or not filename.strip():
continue
filepath = Path(self.directory) / filename filepath = Path(self.directory) / filename
if bpy.data.filepath and filepath.samefile(bpy.data.filepath): if bpy.data.filepath and filepath.samefile(bpy.data.filepath):
self.report({"INFO"}, "Can't link the current .blend file") self.report({"INFO"}, "Can't link the current .blend file")
continue continue
props = tool.Project.get_project_props() props = tool.Project.get_project_props()
new = props.links.add()
filepath = tool.Ifc.get_uri(filepath, use_relative_path=self.use_relative_path) filepath = tool.Ifc.get_uri(filepath, use_relative_path=self.use_relative_path)
new = props.links.add()
if tool.Ifc.get():
if not (document := existing_links.get(filepath)):
document = ifcopenshell.api.document.add_information(tool.Ifc.get())
document.Name = Path(filepath).name
document.Scope = "LINKED_MODEL"
reference = ifcopenshell.api.document.add_reference(tool.Ifc.get(), information=document)
reference[1] = ",".join([str(o) for o in np.eye(4).flatten().tolist()])
reference.Location = filepath.replace("\\", "/")
new.ifc_definition_id = reference.id()
new.name = filepath new.name = filepath
status = bpy.ops.bim.load_link(filepath=filepath, use_cache=self.use_cache) new.filepath = filepath
if status == {"CANCELLED"}: bpy.ops.bim.load_link(link_index=-1, use_cache=self.use_cache)
error_msg = (
f'Error processing IFC file "{filepath}" '
"was critical and blend file either wasn't saved or wasn't updated. "
"See logs above in system console for details."
)
print(error_msg)
self.report({"ERROR"}, error_msg)
return {"FINISHED"}
print(f"Finished linking {len(files)} IFCs", time.time() - start)
return {"FINISHED"}
class UnlinkIfc(bpy.types.Operator): class UnlinkIfc(bpy.types.Operator, tool.Ifc.Operator):
bl_idname = "bim.unlink_ifc" bl_idname = "bim.unlink_ifc"
bl_label = "Unlink IFC" bl_label = "Unlink IFC"
bl_options = {"REGISTER", "UNDO"} bl_options = {"REGISTER", "UNDO"}
bl_description = "Remove the selected file from the link list" bl_description = "Remove the selected file from the link list"
filepath: bpy.props.StringProperty() link_index: bpy.props.IntProperty(name="Link Index")
def execute(self, context): def _execute(self, context):
filepath = Path(self.filepath).as_posix()
bpy.ops.bim.unload_link(filepath=filepath)
props = tool.Project.get_project_props() props = tool.Project.get_project_props()
index = props.links.find(filepath) link = props.links[self.link_index]
if index != -1: bpy.ops.bim.unload_link(link_index=self.link_index)
props.links.remove(index) if tool.Ifc.get():
return {"FINISHED"} reference = tool.Ifc.get().by_id(link.ifc_definition_id)
document = tool.Document.get_reference_document(reference)
ifcopenshell.api.document.remove_reference(tool.Ifc.get(), reference)
if document and not tool.Document.get_document_references(document):
ifcopenshell.api.document.remove_information(tool.Ifc.get(), document)
props.links.remove(self.link_index)
class UnloadLink(bpy.types.Operator): class UnloadLink(bpy.types.Operator, tool.Ifc.Operator):
bl_idname = "bim.unload_link" bl_idname = "bim.unload_link"
bl_label = "Unload Link" bl_label = "Unload Link"
bl_options = {"REGISTER", "UNDO"} bl_options = {"REGISTER", "UNDO"}
bl_description = "Unload the selected linked file" bl_description = "Unload the selected linked file"
filepath: bpy.props.StringProperty() link_index: bpy.props.IntProperty(name="Link Index")
def execute(self, context): def _execute(self, context):
filepath = tool.Blender.ensure_blender_path_is_abs(Path(self.filepath)) link = tool.Project.get_project_props().links[self.link_index]
if filepath.suffix.lower() == ".ifc": if obj := tool.Project.get_link_empty_handle(link):
filepath = filepath.with_suffix(".ifc.cache.blend") collection = obj.instance_collection
library = collection.library
for library in list(bpy.data.libraries): tool.Ifc.unlink(obj=obj)
if tool.Blender.ensure_blender_path_is_abs(Path(library.filepath)) == filepath: bpy.data.objects.remove(obj)
if collection.users == 0:
bpy.data.collections.remove(collection)
if not len([c for c in bpy.data.collections if c.library == library]):
bpy.data.libraries.remove(library) bpy.data.libraries.remove(library)
props = tool.Project.get_project_props()
links = props.links
link = links[self.filepath]
# Let's assume that user might delete it.
if empty_handle := link.empty_handle:
bpy.data.objects.remove(empty_handle)
# following lines removes the library also when use_relative_path=True, otherwise it doesn't
libraries = bpy.data.libraries
for library in libraries:
if library.name == self.filepath + ".cache.blend":
bpy.data.libraries.remove(library)
link.is_loaded = False link.is_loaded = False
ProjectDecorator.uninstall()
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"}
class LoadLink(bpy.types.Operator): class LoadLink(bpy.types.Operator, tool.Ifc.Operator):
bl_idname = "bim.load_link" bl_idname = "bim.load_link"
bl_label = "Load Link" bl_label = "Load Link"
bl_options = {"REGISTER", "UNDO"} bl_options = {"REGISTER", "UNDO"}
bl_description = "Load the selected file" bl_description = "Load the selected file"
filepath: bpy.props.StringProperty(name="Link Filepath") link_index: bpy.props.IntProperty(name="Link Index")
use_cache: bpy.props.BoolProperty(name="Use Cache", default=True) use_cache: bpy.props.BoolProperty(name="Use Cache", default=True)
filepath_: Path def _execute(self, context):
self.link = tool.Project.get_project_props().links[self.link_index]
def execute(self, context): filepath = Path(tool.Ifc.resolve_uri(self.link.filepath))
filepath = Path(tool.Ifc.resolve_uri(self.filepath))
if not filepath.exists(): if not filepath.exists():
self.report({"ERROR"}, f"File does not exist: '{filepath}'") self.report({"ERROR"}, f"File does not exist: '{filepath}'")
return {"CANCELLED"} return {"CANCELLED"}
self.filepath_ = filepath self.filepath_ = filepath
if filepath.suffix.lower().endswith(".blend"): if filepath.suffix.lower().endswith(".ifc"):
self.link_blend(filepath) return self.link_ifc()
elif filepath.suffix.lower().endswith(".ifc"):
status = self.link_ifc()
if status:
return status
return {"FINISHED"}
def link_blend(self, filepath: Path) -> None: def link_blend(self, filepath: Path) -> None:
with bpy.data.libraries.load(str(filepath), link=True) as (data_from, data_to): with bpy.data.libraries.load(str(filepath), link=True) as (data_from, data_to):
data_to.scenes = data_from.scenes data_to.collections = [c for c in data_from.collections if "IfcProject" in c]
link = tool.Project.get_project_props().links[self.filepath]
for scene in bpy.data.scenes: # Find the linked collection
if not scene.library or Path(scene.library.filepath) != filepath: for collection in bpy.data.collections:
if not collection.library or Path(collection.library.filepath) != filepath:
continue continue
for child in scene.collection.children: # Create unique empty instance for this link
if "IfcProject" not in child.name: empty_name = collection.name
continue empty = bpy.data.objects.new(empty_name, None)
empty = bpy.data.objects.new(child.name, None) empty.instance_type = "COLLECTION"
empty.instance_type = "COLLECTION" empty.instance_collection = collection
empty.instance_collection = child empty.matrix_world = Matrix(tool.Project.calculate_link_matrix(self.link))
link.empty_handle = empty
bpy.context.scene.collection.objects.link(empty) tool.Project.set_link_empty_handle(self.link, empty)
break 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 break
link.is_loaded = True else:
tool.Blender.select_and_activate_single_object(bpy.context, empty) print(f"WARNING: No IfcProject collection found in {filepath}")
self.link.is_loaded = False
def link_ifc(self) -> Union[set[str], None]: def link_ifc(self) -> Union[set[str], None]:
blend_filepath = self.filepath_.with_suffix(".ifc.cache.blend") blend_filepath = self.filepath_.with_suffix(".ifc.cache.blend")
@@ -1502,14 +1503,12 @@ class LoadLink(bpy.types.Operator):
code = f""" code = f"""
import bpy import bpy
import sys
def run(): def run():
import bonsai.tool as tool import bonsai.tool as tool
gprops = tool.Georeference.get_georeference_props() gprops = tool.Georeference.get_georeference_props()
# Our model origin becomes their host model origin # Our model origin becomes their host model origin
gprops.host_model_origin = "{gprops.model_origin}"
gprops.host_model_origin_si = "{gprops.model_origin_si}"
gprops.host_model_project_north = "{gprops.model_project_north}"
gprops.has_blender_offset = {gprops.has_blender_offset} gprops.has_blender_offset = {gprops.has_blender_offset}
gprops.blender_offset_x = "{gprops.blender_offset_x}" gprops.blender_offset_x = "{gprops.blender_offset_x}"
gprops.blender_offset_y = "{gprops.blender_offset_y}" gprops.blender_offset_y = "{gprops.blender_offset_y}"
@@ -1522,7 +1521,12 @@ def run():
pprops.false_origin = "{pprops.false_origin}" pprops.false_origin = "{pprops.false_origin}"
pprops.project_north = "{pprops.project_north}" pprops.project_north = "{pprops.project_north}"
# Use absolute path to be safe from cwd changes. # Use absolute path to be safe from cwd changes.
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. # Use str instead of as_posix to avoid issues with Windows shared paths.
bpy.ops.wm.save_as_mainfile(filepath=r"{str(blend_filepath)}") bpy.ops.wm.save_as_mainfile(filepath=r"{str(blend_filepath)}")
@@ -1556,14 +1560,17 @@ except Exception as e:
if not blend_filepath.exists() or blend_filepath.stat().st_mtime < t: if not blend_filepath.exists() or blend_filepath.stat().st_mtime < t:
return {"CANCELLED"} return {"CANCELLED"}
self.set_model_origin_from_link() self.set_model_origin_from_link()
self.set_georeferencing_indicator()
self.link_blend(blend_filepath) self.link_blend(blend_filepath)
def set_model_origin_from_link(self) -> None: def set_model_origin_from_link(self) -> None:
if tool.Ifc.get(): if tool.Ifc.get():
return # The current model's coordinates always take priority. return # The current model's coordinates always take priority.
if len(tool.Project.get_project_props().links) > 1:
return # Only the first link sets the origin
json_filepath = self.filepath_.with_suffix(".ifc.cache.json") json_filepath = self.filepath_.with_suffix(".ifc.cache.json")
if not json_filepath.exists(): if not json_filepath.exists():
return return
@@ -1576,21 +1583,36 @@ except Exception as e:
if (value := data.get(prop, None)) is not None: if (value := data.get(prop, None)) is not None:
setattr(gprops, prop, value) setattr(gprops, prop, value)
def set_georeferencing_indicator(self) -> None:
if not tool.Ifc.get():
self.link.georeferenced = "NONE"
return
if not (crs_name := (ifcopenshell.util.geolocation.get_crs(tool.Ifc.get()) or {}).get("Name", "")):
self.link.georeferenced = "NONE"
return
reference = tool.Ifc.get().by_id(self.link.ifc_definition_id)
json_filepath = Path(reference.Location).with_suffix(".ifc.cache.json")
if not json_filepath.exists():
self.link.georeferenced = "NONE"
return
with open(json_filepath, "r") as f:
data = json.load(f)
if not data["model_is_georeferenced"]:
self.link.georeferenced = "NONE"
else:
self.link.georeferenced = "FULL_COMPATIBLE" if crs_name == data["model_crs"] else "NOT_COMPATIBLE"
class ReloadLink(bpy.types.Operator): class ReloadLink(bpy.types.Operator):
bl_idname = "bim.reload_link" bl_idname = "bim.reload_link"
bl_label = "Reload Link" bl_label = "Reload Link"
bl_options = {"REGISTER", "UNDO"} bl_options = {"REGISTER", "UNDO"}
bl_description = "Reload the selected file" bl_description = "Reload the selected file"
filepath: bpy.props.StringProperty() link_index: bpy.props.IntProperty(name="Link Index")
def execute(self, context): def execute(self, context):
is_abs = os.path.isabs(Path(self.filepath)) bpy.ops.bim.unload_link(link_index=self.link_index)
use_relative_path = not is_abs return bpy.ops.bim.load_link(link_index=self.link_index, use_cache=False) or {"FINISHED"}
bpy.ops.bim.unlink_ifc(filepath=self.filepath)
filepath = tool.Ifc.resolve_uri(self.filepath)
status = bpy.ops.bim.link_ifc(filepath=filepath, use_cache=False, use_relative_path=use_relative_path)
return {"FINISHED"}
class ToggleLinkSelectability(bpy.types.Operator): class ToggleLinkSelectability(bpy.types.Operator):
@@ -1598,16 +1620,18 @@ class ToggleLinkSelectability(bpy.types.Operator):
bl_label = "Toggle Link Selectability" bl_label = "Toggle Link Selectability"
bl_options = {"REGISTER", "UNDO"} bl_options = {"REGISTER", "UNDO"}
bl_description = "Toggle selectability" bl_description = "Toggle selectability"
link: bpy.props.StringProperty(name="Linked IFC Filepath") link_index: bpy.props.IntProperty(name="Link Index")
def execute(self, context): def execute(self, context):
props = tool.Project.get_project_props() props = tool.Project.get_project_props()
link = props.links[self.link] link = props.links[self.link_index]
self.library_filepath = tool.Blender.ensure_blender_path_is_abs(Path(self.link).with_suffix(".ifc.cache.blend")) self.library_filepath = tool.Blender.ensure_blender_path_is_abs(
Path(link.filepath).with_suffix(".ifc.cache.blend")
)
link.is_selectable = (is_selectable := not link.is_selectable) link.is_selectable = (is_selectable := not link.is_selectable)
for collection in self.get_linked_collections(): for collection in self.get_linked_collections():
collection.hide_select = not is_selectable collection.hide_select = not is_selectable
if handle := link.empty_handle: if handle := tool.Project.get_link_empty_handle(link):
handle.hide_select = not is_selectable handle.hide_select = not is_selectable
return {"FINISHED"} return {"FINISHED"}
@@ -1624,13 +1648,15 @@ class ToggleLinkVisibility(bpy.types.Operator):
bl_label = "Toggle Link Visibility" bl_label = "Toggle Link Visibility"
bl_options = {"REGISTER", "UNDO"} bl_options = {"REGISTER", "UNDO"}
bl_description = "Toggle visibility between SOLID and WIREFRAME" bl_description = "Toggle visibility between SOLID and WIREFRAME"
link: bpy.props.StringProperty(name="Linked IFC Filepath") link_index: bpy.props.IntProperty(name="Link Index")
mode: bpy.props.EnumProperty(name="Visibility Mode", items=((i, i, "") for i in ("WIREFRAME", "VISIBLE"))) mode: bpy.props.EnumProperty(name="Visibility Mode", items=((i, i, "") for i in ("WIREFRAME", "VISIBLE")))
def execute(self, context): def execute(self, context):
props = tool.Project.get_project_props() props = tool.Project.get_project_props()
link = props.links[self.link] link = props.links[self.link_index]
self.library_filepath = tool.Blender.ensure_blender_path_is_abs(Path(self.link).with_suffix(".ifc.cache.blend")) self.library_filepath = tool.Blender.ensure_blender_path_is_abs(
Path(link.filepath).with_suffix(".ifc.cache.blend")
)
if self.mode == "WIREFRAME": if self.mode == "WIREFRAME":
self.toggle_wireframe(link) self.toggle_wireframe(link)
elif self.mode == "VISIBLE": elif self.mode == "VISIBLE":
@@ -1652,7 +1678,7 @@ class ToggleLinkVisibility(bpy.types.Operator):
layer_collections = tool.Blender.get_layer_collections_mapping(linked_collections) layer_collections = tool.Blender.get_layer_collections_mapping(linked_collections)
for layer_collection in layer_collections.values(): for layer_collection in layer_collections.values():
layer_collection.exclude = is_hidden layer_collection.exclude = is_hidden
if handle := link.empty_handle: if handle := tool.Project.get_link_empty_handle(link):
handle.hide_set(is_hidden) handle.hide_set(is_hidden)
def get_linked_collections(self) -> list[bpy.types.Collection]: def get_linked_collections(self) -> list[bpy.types.Collection]:
@@ -1663,17 +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): class SelectLinkHandle(bpy.types.Operator):
bl_idname = "bim.select_link_handle" bl_idname = "bim.select_link_handle"
bl_label = "Select Link Handle" bl_label = "Select Link Handle"
bl_options = {"REGISTER", "UNDO"} bl_options = {"REGISTER", "UNDO"}
bl_description = "Select link empty object handle" bl_description = "Select link empty object handle"
index: bpy.props.IntProperty(name="Link Index") link_index: bpy.props.IntProperty(name="Link Index")
def execute(self, context): def execute(self, context):
props = tool.Project.get_project_props() props = tool.Project.get_project_props()
link = props.links[self.index] link = props.links[self.link_index]
handle = link.empty_handle handle = tool.Project.get_link_empty_handle(link)
if not handle: if not handle:
self.report({"ERROR"}, "Link has no empty handle (probably it was deleted).") self.report({"ERROR"}, "Link has no empty handle (probably it was deleted).")
return {"CANCELLED"} return {"CANCELLED"}
@@ -1866,7 +1970,14 @@ class LoadLinkedProject(bpy.types.Operator, ImportHelper):
print("Processing", self.filepath) print("Processing", self.filepath)
self.collection = bpy.data.collections.new("IfcProject/" + os.path.basename(self.filepath)) self.collection = bpy.data.collections.new("IfcProject/" + os.path.basename(self.filepath))
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) tool.Ifc.set(self.file)
print("Finished opening") 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: if tool.Loader.settings.false_origin_mode == "MANUAL" and tool.Loader.settings.false_origin:
tool.Loader.set_manual_blender_offset(self.file) tool.Loader.set_manual_blender_offset(self.file)
elif tool.Loader.settings.false_origin_mode == "AUTOMATIC": elif tool.Loader.settings.false_origin_mode == "AUTOMATIC":
if host_model_origin_si := gprops.host_model_origin_si: tool.Loader.guess_false_origin(self.file)
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.Georeference.set_model_origin() tool.Georeference.set_model_origin()
self.json_filepath = self.filepath + ".cache.json" self.json_filepath = self.filepath + ".cache.json"
data = { data = {
"host_model_origin": gprops.host_model_origin, "model_is_georeferenced": gprops.model_is_georeferenced,
"host_model_origin_si": gprops.host_model_origin_si, "model_crs": gprops.model_crs,
"host_model_project_north": gprops.host_model_project_north,
"model_origin": gprops.model_origin, "model_origin": gprops.model_origin,
"model_origin_si": gprops.model_origin_si, "model_origin_si": gprops.model_origin_si,
"model_project_north": gprops.model_project_north, "model_project_north": gprops.model_project_north,
+42 -4
View File
@@ -16,8 +16,9 @@
# You should have received a copy of the GNU General Public License # You should have received a copy of the GNU General Public License
# along with Bonsai. If not, see <http://www.gnu.org/licenses/>. # along with Bonsai. If not, see <http://www.gnu.org/licenses/>.
import os import math
from collections.abc import Generator from collections.abc import Generator
from pathlib import Path
from typing import TYPE_CHECKING, Literal, Union, assert_never, get_args from typing import TYPE_CHECKING, Literal, Union, assert_never, get_args
import bpy import bpy
@@ -219,29 +220,62 @@ class FilterCategory(PropertyGroup):
class Link(PropertyGroup): class Link(PropertyGroup):
name: StringProperty( name: StringProperty(name="Name")
name="Name", filepath: StringProperty(
name="Filepath",
description="Filepath to linked .ifc file, stored in posix format (could be relative to .ifc file, not to .blend)", description="Filepath to linked .ifc file, stored in posix format (could be relative to .ifc file, not to .blend)",
) )
transformation: StringProperty(
name="Transformation",
description="4x4 matrix transformation as a flattened comma separated list for the linked model",
default="",
)
georeferenced: EnumProperty(
name="Georeferenced",
description="Georeferencing status: compatibility between host and linked model",
items=[
("NONE", "No Georef", "Linked model has no georeferencing"),
("NOT_COMPATIBLE", "Not Compatible", "Has geo data but CRS differ from host"),
("FULL_COMPATIBLE", "Full Compatible", "Both CRS name and vertical datum match host"),
],
default="NONE",
)
has_transformation: BoolProperty(
name="Has Transformation",
description="Whether there is a transformation from its global coordinates",
default=False,
)
is_loaded: BoolProperty(name="Is Loaded", default=False) is_loaded: BoolProperty(name="Is Loaded", default=False)
is_editing: BoolProperty(name="Is Editing", description="Whether the link is being transformed", default=False)
is_selectable: BoolProperty(name="Is Selectable", default=True) is_selectable: BoolProperty(name="Is Selectable", default=True)
is_wireframe: BoolProperty(name="Is Wireframe", default=False) is_wireframe: BoolProperty(name="Is Wireframe", default=False)
is_hidden: BoolProperty(name="Is Hidden", default=False) is_hidden: BoolProperty(name="Is Hidden", default=False)
include_in_drawings: BoolProperty(name="Include in Drawings", default=True, options=set()) include_in_drawings: BoolProperty(name="Include in Drawings", default=True, options=set())
empty_handle: PointerProperty( empty_handle: PointerProperty(
name="Empty Object Handle", name="Empty Object Handle",
description="We use empty object handle to allow simple manipulations with a linked model (moving, scaling, rotating)", description="Storage for empty handle. Used in non-IFC scenarios or temporarily during link creation",
type=bpy.types.Object, type=bpy.types.Object,
) )
ifc_definition_id: IntProperty(
name="IFC Definition ID",
description="STEP ID of the IfcDocumentReference when linked to a parent IFC project. Zero when no parent IFC exists",
default=0,
)
if TYPE_CHECKING: if TYPE_CHECKING:
name: str name: str
filepath: str
transformation: str
georeferenced: Literal["NONE", "NOT_COMPATIBLE", "FULL_COMPATIBLE"]
has_transformation: bool
is_loaded: bool is_loaded: bool
is_editing: bool
is_selectable: bool is_selectable: bool
is_wireframe: bool is_wireframe: bool
is_hidden: bool is_hidden: bool
include_in_drawings: bool include_in_drawings: bool
empty_handle: Union[bpy.types.Object, None] empty_handle: Union[bpy.types.Object, None]
ifc_definition_id: int
class EditedObj(PropertyGroup): class EditedObj(PropertyGroup):
@@ -424,6 +458,10 @@ class BIMProjectProperties(PropertyGroup):
clipping_planes_active_index: bpy.props.IntProperty(min=0, default=0, max=5) clipping_planes_active_index: bpy.props.IntProperty(min=0, default=0, max=5)
edited_objs: bpy.props.CollectionProperty(type=EditedObj) edited_objs: bpy.props.CollectionProperty(type=EditedObj)
@property
def active_link(self) -> Union[Link, None]:
return tool.Blender.get_active_uilist_element(self.links, self.active_link_index)
@property @property
def active_clipping_plane(self) -> ObjProperty | None: def active_clipping_plane(self) -> ObjProperty | None:
return tool.Blender.get_active_uilist_element(self.clipping_planes, self.clipping_planes_active_index) return tool.Blender.get_active_uilist_element(self.clipping_planes, self.clipping_planes_active_index)
+44 -49
View File
@@ -22,6 +22,7 @@ import os
from typing import TYPE_CHECKING from typing import TYPE_CHECKING
import bpy import bpy
import math
import ifcopenshell import ifcopenshell
from bpy.types import Menu, Panel, UIList from bpy.types import Menu, Panel, UIList
@@ -30,6 +31,7 @@ import bonsai.tool as tool
from bonsai.bim.helper import draw_attributes, prop_with_search from bonsai.bim.helper import draw_attributes, prop_with_search
from bonsai.bim.ifc import IfcStore from bonsai.bim.ifc import IfcStore
from bonsai.bim.module.project.data import LinksData, ProjectData from bonsai.bim.module.project.data import LinksData, ProjectData
from typing import TYPE_CHECKING
if TYPE_CHECKING: if TYPE_CHECKING:
from bonsai.bim.module.project.prop import ( from bonsai.bim.module.project.prop import (
@@ -477,17 +479,27 @@ class BIM_PT_links(Panel):
def draw(self, context): def draw(self, context):
self.props = tool.Project.get_project_props() self.props = tool.Project.get_project_props()
row = self.layout.row(align=True) row = self.layout.row(align=True)
row.operator("bim.link_ifc") row.operator("bim.link_ifc")
if self.props.links: if self.props.links:
self.layout.template_list( if self.props.active_link:
"BIM_UL_links", row = self.layout.row(align=True)
"", row.alignment = "RIGHT"
self.props, index = self.props.active_link_index
"links", if self.props.active_link.is_editing:
self.props, row.operator("bim.edit_link", text="", icon="CHECKMARK")
"active_link_index", row.operator("bim.disable_editing_link", text="", icon="CANCEL")
) else:
row.operator("bim.enable_editing_link", text="", icon="GREASEPENCIL")
if self.props.active_link.is_loaded:
row.operator("bim.select_link_handle", text="", icon="OBJECT_DATA").link_index = index
row.operator("bim.unload_link", text="", icon="UNLINKED").link_index = index
row.operator("bim.reload_link", text="", icon="FILE_REFRESH").link_index = index
else:
row.operator("bim.load_link", text="", icon="LINKED").link_index = index
row.operator("bim.unlink_ifc", text="", icon="X").link_index = index
self.layout.template_list("BIM_UL_links", "", self.props, "links", self.props, "active_link_index")
if LinksData.enable_culling: if LinksData.enable_culling:
row = self.layout.row(align=True) row = self.layout.row(align=True)
@@ -607,47 +619,30 @@ class BIM_UL_links(UIList):
active_propname, active_propname,
index, index,
): ):
if item: row = layout.row(align=True)
row = layout.row(align=True) if item.is_loaded:
if item.is_loaded: if item.georeferenced == "NONE":
row.label(text=item.name) row.label(text="", icon="QUESTION")
op = row.operator( elif item.georeferenced == "NOT_COMPATIBLE":
"bim.toggle_link_selectability", row.label(text="", icon="ERROR")
text="", elif item.georeferenced == "FULL_COMPATIBLE":
icon="RESTRICT_SELECT_OFF" if item.is_selectable else "RESTRICT_SELECT_ON", row.label(text="", icon="WORLD")
emboss=False, if item.has_transformation:
) row.label(text="", icon="OBJECT_ORIGIN")
op.link = item.name
op = row.operator( row.label(text=item.filepath)
"bim.toggle_link_visibility", icon = "RESTRICT_SELECT_OFF" if item.is_selectable else "RESTRICT_SELECT_ON"
text="", row.operator("bim.toggle_link_selectability", text="", icon=icon, emboss=False).link_index = index
icon="CUBE" if item.is_wireframe else "MESH_CUBE", icon = "CUBE" if item.is_wireframe else "MESH_CUBE"
emboss=False, op = row.operator("bim.toggle_link_visibility", text="", icon=icon, emboss=False)
) op.link_index = index
op.link = item.name op.mode = "WIREFRAME"
op.mode = "WIREFRAME" icon = "HIDE_ON" if item.is_hidden else "HIDE_OFF"
op = row.operator( op = row.operator("bim.toggle_link_visibility", text="", icon=icon, emboss=False)
"bim.toggle_link_visibility", op.link_index = index
text="", op.mode = "VISIBLE"
icon="HIDE_ON" if item.is_hidden else "HIDE_OFF", else:
emboss=False, row.label(text=item.filepath)
)
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
class BIM_PT_purge(Panel): class BIM_PT_purge(Panel):
+28 -46
View File
@@ -268,63 +268,45 @@ class SaveBlendMetadataFile(bpy.types.Operator):
import bpy import bpy
# Ensure all styles are loaded before attempting to remove them # Ensure all styles are loaded before attempting to remove them
try: bpy.ops.bim.load_styles()
bpy.ops.bim.load_styles()
except Exception:
pass
# 1. Collect all IfcStyle material names # 1. Collect all IfcStyle material names
ifcstyle_material_names = [] ifcstyle_material_names = []
try: styles_props = getattr(bpy.context.scene, "BIMStylesProperties", None)
styles_props = getattr(bpy.context.scene, "BIMStylesProperties", None) if styles_props is None and bpy.data.scenes:
if styles_props is None and bpy.data.scenes: styles_props = getattr(bpy.data.scenes[0], "BIMStylesProperties", None)
styles_props = getattr(bpy.data.scenes[0], "BIMStylesProperties", None) if styles_props:
if styles_props: for style in list(styles_props.styles):
for style in list(styles_props.styles): material = getattr(style, "blender_material", None)
material = getattr(style, "blender_material", None) if material and material.name:
if material and material.name: ifcstyle_material_names.append(material.name)
ifcstyle_material_names.append(material.name)
except Exception:
pass
# 2. Purge IfcStore # 2. Purge IfcStore
try: from bonsai.bim.ifc import IfcStore
from bonsai.bim.ifc import IfcStore IfcStore.purge()
except ImportError:
IfcStore = None
if IfcStore:
try:
IfcStore.purge()
except Exception:
pass
# 3. Remove all collections named IfcProject* # 3. Remove all collections named IfcProject*
for collection in list(bpy.data.collections): for collection in list(bpy.data.collections):
if collection.name.startswith('IfcProject'): if collection.name.startswith('IfcProject'):
try: bpy.data.collections.remove(collection, do_unlink=True)
bpy.data.collections.remove(collection, do_unlink=True)
except Exception:
pass
# 4. Purge orphaned data blocks after removing IfcProject collections # 4.1 Remove all collections from linked libraries (they will be recreated by bonsai)
try: for collection in list(bpy.data.collections):
bpy.ops.outliner.orphans_purge(do_local_ids=True, do_linked_ids=True, do_recursive=True) if collection.library:
except Exception: bpy.data.collections.remove(collection, do_unlink=True)
pass
# 5. Remove all materials corresponding to the IfcStyles we collected # 4.2. Remove all empty objects that are collection instances for linked models
materials_removed = 0 for obj in list(bpy.data.objects):
try: if obj.type == 'EMPTY' and obj.instance_type == 'COLLECTION' and obj.name.startswith('IfcProject/'):
for mat_name in ifcstyle_material_names: bpy.data.objects.remove(obj, do_unlink=True)
if mat_name in bpy.data.materials:
try: # 5. Purge orphaned data blocks after removing IfcProject collections
bpy.data.materials.remove(bpy.data.materials[mat_name], do_unlink=True) bpy.ops.outliner.orphans_purge(do_local_ids=True, do_linked_ids=True, do_recursive=True)
materials_removed += 1
except Exception: # 6. Remove all materials corresponding to the IfcStyles we collected
pass for mat_name in ifcstyle_material_names:
except Exception: if mat_name in bpy.data.materials:
pass bpy.data.materials.remove(bpy.data.materials[mat_name], do_unlink=True)
bpy.ops.wm.save_as_mainfile(filepath=r'{blendmetadata_path}') bpy.ops.wm.save_as_mainfile(filepath=r'{blendmetadata_path}')
""" """
+5
View File
@@ -660,6 +660,10 @@ class BIM_ADDON_preferences(bpy.types.AddonPreferences):
bsdd_load_test_dictionaries: BoolProperty( bsdd_load_test_dictionaries: BoolProperty(
name="Load Test Dictionaries", description="Load dictionaries that are for testing only", default=False 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( should_disable_undo_on_save: BoolProperty(
name="Disable Undo When Saving (Faster saves, no undo for you!)", default=False 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_preview_dictionaries")
layout.prop(self, "bsdd_load_inactive_dictionaries") layout.prop(self, "bsdd_load_inactive_dictionaries")
layout.prop(self, "bsdd_load_test_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: def draw_extras_settings(self, layout: bpy.types.UILayout, context: bpy.types.Context) -> None:
layout.prop(self, "container_hide_show_isolate") layout.prop(self, "container_hide_show_isolate")
+3 -1
View File
@@ -29,6 +29,7 @@ if TYPE_CHECKING:
def add_georeferencing(georeference: type[tool.Georeference]) -> None: def add_georeferencing(georeference: type[tool.Georeference]) -> None:
georeference.add_georeferencing() georeference.add_georeferencing()
georeference.set_model_origin()
def enable_editing_georeferencing(georeference: type[tool.Georeference]) -> None: def enable_editing_georeferencing(georeference: type[tool.Georeference]) -> None:
@@ -37,8 +38,9 @@ def enable_editing_georeferencing(georeference: type[tool.Georeference]) -> None
georeference.enable_editing() georeference.enable_editing()
def remove_georeferencing(ifc: type[tool.Ifc]) -> None: def remove_georeferencing(ifc: type[tool.Ifc], georeference: type[tool.Georeference]) -> None:
ifc.run("georeference.remove_georeferencing") ifc.run("georeference.remove_georeferencing")
georeference.set_model_origin()
def disable_editing_georeferencing(georeference: type[tool.Georeference]) -> None: def disable_editing_georeferencing(georeference: type[tool.Georeference]) -> None:
+10
View File
@@ -2165,3 +2165,13 @@ class Blender(bonsai.core.tool.Blender):
if cls.BLENDER_5: if cls.BLENDER_5:
return np.array(mathutils_type) return np.array(mathutils_type)
return np.array(mathutils_type, dtype=np.float32) 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()
]
+4
View File
@@ -123,6 +123,10 @@ class Bsdd(bonsai.core.tool.Bsdd):
@classmethod @classmethod
def get_dictionaries(cls) -> list[bsdd.DictionaryContractV1]: def get_dictionaries(cls) -> list[bsdd.DictionaryContractV1]:
prefs = tool.Blender.get_addon_preferences() 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) response = cls.client.get_dictionary(include_test_dictionaries=prefs.bsdd_load_test_dictionaries)
dicts = response.get("dictionaries") or [] dicts = response.get("dictionaries") or []
statuses = ["Active"] statuses = ["Active"]
+8
View File
@@ -251,11 +251,19 @@ class Document(bonsai.core.tool.Document):
def get_document_references( def get_document_references(
cls, document: ifcopenshell.entity_instance cls, document: ifcopenshell.entity_instance
) -> tuple[ifcopenshell.entity_instance, ...]: ) -> tuple[ifcopenshell.entity_instance, ...]:
# TODO: migrate to util.document and replace all instances
"""Get IfcDocumentReference.ReferencedDocuments, compatible with IFC2X3.""" """Get IfcDocumentReference.ReferencedDocuments, compatible with IFC2X3."""
if document.file.schema == "IFC2X3": if document.file.schema == "IFC2X3":
return document.DocumentReferences or () return document.DocumentReferences or ()
return document.HasDocumentReferences return document.HasDocumentReferences
@classmethod
def get_reference_document(cls, reference: ifcopenshell.entity_instance) -> ifcopenshell.entity_instance | None:
# TODO: migrate to util.document and replace all instances
if reference.file.schema == "IFC2X3":
return (reference.ReferenceToDocument or (None))[0]
return reference.ReferencedDocument
@classmethod @classmethod
def clear_active_document(cls) -> None: def clear_active_document(cls) -> None:
props = cls.get_document_props() props = cls.get_document_props()
+4 -2
View File
@@ -857,9 +857,11 @@ class Drawing(bonsai.core.tool.Drawing):
def edit_text_literals(cls, obj: bpy.types.Object, literal_attributes: dict) -> None: def edit_text_literals(cls, obj: bpy.types.Object, literal_attributes: dict) -> None:
assert (element := tool.Ifc.get_entity(obj)) assert (element := tool.Ifc.get_entity(obj))
assert (rep := cls.get_annotation_representation(element)) 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) ifcopenshell.util.element.remove_deep2(tool.Ifc.get(), literal)
rep.Items = [cls.add_literal(**a) for a in literal_attributes]
@classmethod @classmethod
def add_literal(cls, **attributes: str) -> ifcopenshell.entity_instance: def add_literal(cls, **attributes: str) -> ifcopenshell.entity_instance:
+18
View File
@@ -315,6 +315,21 @@ class Georeference(bonsai.core.tool.Georeference):
) )
return coordinates return coordinates
@classmethod
def global2local(cls, matrix, is_specified_in_map_units: bool) -> tuple[float, float, float]:
matrix = ifcopenshell.util.geolocation.auto_global2local(tool.Ifc.get(), matrix, is_specified_in_map_units=is_specified_in_map_units)
props = cls.get_georeference_props()
if props.has_blender_offset:
matrix = ifcopenshell.util.geolocation.global2local(
matrix,
float(props.blender_offset_x),
float(props.blender_offset_y),
float(props.blender_offset_z),
float(props.blender_x_axis_abscissa),
float(props.blender_x_axis_ordinate),
)
return matrix
@classmethod @classmethod
def import_plot(cls, filepath: str) -> None: def import_plot(cls, filepath: str) -> None:
import bmesh import bmesh
@@ -385,6 +400,9 @@ class Georeference(bonsai.core.tool.Georeference):
unit_scale = ifcopenshell.util.unit.calculate_unit_scale(tool.Ifc.get()) unit_scale = ifcopenshell.util.unit.calculate_unit_scale(tool.Ifc.get())
gprops = tool.Georeference.get_georeference_props() gprops = tool.Georeference.get_georeference_props()
e, n, h = cls.xyz2enh((0, 0, 0), should_return_in_map_units=False) e, n, h = cls.xyz2enh((0, 0, 0), should_return_in_map_units=False)
crs = ifcopenshell.util.geolocation.get_crs(tool.Ifc.get()) or {}
gprops.model_is_georeferenced = bool(crs)
gprops.model_crs = crs.get("Name", "") or ""
gprops.model_origin = f"{e},{n},{h}" gprops.model_origin = f"{e},{n},{h}"
gprops.model_origin_si = f"{e * unit_scale},{n * unit_scale},{h * unit_scale}" gprops.model_origin_si = f"{e * unit_scale},{n * unit_scale},{h * unit_scale}"
angle = ifcopenshell.util.geolocation.get_grid_north(tool.Ifc.get()) angle = ifcopenshell.util.geolocation.get_grid_north(tool.Ifc.get())
+67 -58
View File
@@ -19,10 +19,14 @@
from __future__ import annotations from __future__ import annotations
import os import os
import json
import math
import shutil import shutil
import numpy as np
from collections import defaultdict from collections import defaultdict
from math import radians
from pathlib import Path from pathlib import Path
from typing import TYPE_CHECKING, NamedTuple, Optional, Union from typing import TYPE_CHECKING, NamedTuple, Optional
import bpy import bpy
import ifcopenshell import ifcopenshell
@@ -58,6 +62,47 @@ class Project(bonsai.core.tool.Project):
assert (scene := bpy.context.scene) assert (scene := bpy.context.scene)
return scene.MeasureToolSettings # pyright: ignore[reportAttributeAccessIssue] return scene.MeasureToolSettings # pyright: ignore[reportAttributeAccessIssue]
@classmethod
def get_link_empty_handle(cls, link) -> bpy.types.Object | None:
if tool.Ifc.get():
return tool.Ifc.get_object(tool.Ifc.get().by_id(link.ifc_definition_id))
return link.empty_handle
@classmethod
def set_link_empty_handle(cls, link, empty: bpy.types.Object) -> None:
if tool.Ifc.get():
tool.Ifc.link(tool.Ifc.get().by_id(link.ifc_definition_id), empty)
else:
link.empty_handle = empty
@classmethod
def calculate_link_matrix(cls, link) -> None:
filepath = Path(tool.Ifc.resolve_uri(link.filepath))
with open(filepath.with_suffix(".ifc.cache.json"), "r") as f:
metadata = json.load(f)
rot = ifcopenshell.util.shape_builder.np_rotation_matrix(
radians(-float(metadata["model_project_north"])), 4, "Z"
)
global_matrix = rot @ np.eye(4)
global_matrix[:, 3][:3] = [float(o) for o in metadata["model_origin_si"].split(",")]
if tool.Ifc.get():
transformation = tool.Ifc.get().by_id(link.ifc_definition_id)[1] # Identification
else:
transformation = link.transformation
if transformation:
transformation = np.fromstring(transformation, sep=",", dtype=np.float64).reshape(4, 4)
if not np.allclose(transformation, np.eye(4)):
global_matrix = transformation @ global_matrix
gprops = tool.Georeference.get_georeference_props()
rot = ifcopenshell.util.shape_builder.np_rotation_matrix(radians(-float(gprops.model_project_north)), 4, "Z")
local_matrix = rot @ np.eye(4)
local_matrix[:, 3][:3] = [float(o) for o in gprops.model_origin_si.split(",")]
return np.linalg.inv(local_matrix) @ global_matrix
@classmethod @classmethod
def append_all_types_from_template(cls, template: str) -> None: def append_all_types_from_template(cls, template: str) -> None:
# TODO refactor # TODO refactor
@@ -249,68 +294,32 @@ class Project(bonsai.core.tool.Project):
tool.Root.reload_grid_decorator() tool.Root.reload_grid_decorator()
@classmethod @classmethod
def get_linked_models_document(cls) -> Union[ifcopenshell.entity_instance, None]: def get_linked_models_documents(cls) -> dict[str, ifcopenshell.entity_instance]:
for document in tool.Ifc.get().by_type("IfcDocumentInformation"): linked_docs = {}
if document.Name == "BBIM_Linked_Models": for doc in tool.Ifc.get().by_type("IfcDocumentInformation"):
return document if doc.Scope == "LINKED_MODEL":
for reference in tool.Drawing.get_document_references(doc):
linked_docs[Path(reference.Location).as_posix()] = doc
break
return linked_docs
@classmethod @classmethod
def load_linked_models_from_ifc(cls) -> None: def load_linked_models_from_ifc(cls) -> None:
links = tool.Project.get_project_props().links links = tool.Project.get_project_props().links
links.clear() links.clear()
links_document = cls.get_linked_models_document() for doc in tool.Ifc.get().by_type("IfcDocumentInformation"):
if not links_document: if doc.Scope != "LINKED_MODEL":
return continue
for reference in tool.Drawing.get_document_references(doc):
references = tool.Document.get_document_references(links_document) filepath = reference.Location
if not references: link = links.add()
return link.name = filepath
link.filepath = filepath
for reference in references: link.ifc_definition_id = reference.id()
link = links.add() link.has_transformation = False
link.name = reference.Location if reference[1]:
m = np.fromstring(reference[1], sep=",", dtype=np.float64).reshape(4, 4)
@classmethod link.has_transformation = not np.allclose(m, np.eye(4))
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)
@classmethod @classmethod
def get_project_library_elements( def get_project_library_elements(
+1 -5
View File
@@ -118,14 +118,10 @@ class Snap(bonsai.core.tool.Snap):
def get_angle_snap_value(cls, context: bpy.types.Context) -> float: def get_angle_snap_value(cls, context: bpy.types.Context) -> float:
"""Get the angle snap increment from Blender's tool settings. """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 :param context: Blender context
:return: Angle snap increment in degrees :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_3d)
return math.degrees(context.scene.tool_settings.snap_angle_increment)
@classmethod @classmethod
def get_snap_points_on_raycasted_face(cls, context, event, obj, face_index): def get_snap_points_on_raycasted_face(cls, context, event, obj, face_index):
+1
View File
@@ -34,6 +34,7 @@ from bonsai.bim.ifc import IfcStore
# Monkey-patch webbrowser opening since we want to test headlessly # Monkey-patch webbrowser opening since we want to test headlessly
webbrowser.open = lambda x: True webbrowser.open = lambda x: True
tool.Drawing.open_with_user_command = lambda x, y: True
variables = {"cwd": os.getcwd(), "ifc": "IfcStore.get_file()"} variables = {"cwd": os.getcwd(), "ifc": "IfcStore.get_file()"}
+7 -6
View File
@@ -3,12 +3,6 @@ Feature: Drawing
Scenario: Duplicate drawing Scenario: Duplicate drawing
Given an empty IFC project 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 save IFC project
And I look at the "Drawings" panel And I look at the "Drawings" panel
And I click "IMPORT" And I click "IMPORT"
@@ -315,3 +309,10 @@ Scenario: Create sheet - with a drawing added to it
And I click "IMAGE_PLANE" And I click "IMAGE_PLANE"
When I click "OUTPUT" When I click "OUTPUT"
Then the file "{ifc_dir}/sheets/A01 - UNTITLED.svg" should contain "IfcWall" 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."
+67 -44
View File
@@ -677,31 +677,43 @@ Scenario: Load project elements - all georeferencing coordinate situations with
And the object "IfcActuator/J" has a vertex at "10.366,3.813,-1" And the object "IfcActuator/J" has a vertex at "10.366,3.813,-1"
And the object "IfcActuator/J" has a vertex at "12.298,4.331,-1" And the object "IfcActuator/J" has a vertex at "12.298,4.331,-1"
Scenario: Link IFC Scenario: Link IFC - from an empty IFC project
Given an empty IFC project Given an empty IFC project
When I link IFC project from "{cwd}/test/files/basic.ifc" When I press "bim.link_ifc(filepath='{cwd}/test/files/basic.ifc')"
Then "scene.BIMProjectProperties.links['{cwd}/test/files/basic.ifc'].is_loaded" is "True" Then "scene.BIMProjectProperties.links['{cwd}/test/files/basic.ifc'].is_loaded" is "True"
And the collection "IfcProject/basic.ifc" exists And the collection "IfcProject/basic.ifc" exists
And the object "Chunk" exists And the object "Chunk" exists
And the object "Chunk" is placed in the collection "IfcProject/basic.ifc" And the object "Chunk" is placed in the collection "IfcProject/basic.ifc"
Scenario: Link IFC - disabled false origin mode
Given an empty IFC project
# Not currently possible via UI
And I set "scene.BIMProjectProperties.distance_limit" to "5"
And I set "scene.BIMProjectProperties.false_origin_mode" to "DISABLED"
When I link IFC project from "{cwd}/test/files/geolocation.ifc"
Then the object "Chunk" exists
And the object "Chunk" has a vertex at "2,2,-1"
And the object "Chunk" has a vertex at "9,-1,-1"
And the object "Chunk" has a vertex at "17,4,-1"
Scenario: Link IFC - from an empty IFC project - automatic false origin mode (0,0,0 will be the false origin) Scenario: Link IFC - from an empty IFC project - automatic false origin mode (0,0,0 will be the false origin)
Given an empty IFC project Given an empty IFC project
# Not currently possible via UI # Not currently possible via UI
And I set "scene.BIMProjectProperties.distance_limit" to "5" And I set "scene.BIMProjectProperties.distance_limit" to "5"
And I set "scene.BIMProjectProperties.false_origin_mode" to "AUTOMATIC" And I set "scene.BIMProjectProperties.false_origin_mode" to "AUTOMATIC"
When I link IFC project from "{cwd}/test/files/geolocation.ifc" When I press "bim.link_ifc(filepath='{cwd}/test/files/geolocation.ifc', use_cache=False)"
Then the object "Chunk" exists
And the object "Chunk" has a vertex at "2,2,-1"
And the object "Chunk" has a vertex at "9,-1,-1"
And the object "Chunk" has a vertex at "17,4,-1"
Scenario: Link IFC - from an empty IFC project - manual false origin mode
Given an empty IFC project
# Not currently possible via UI
And I set "scene.BIMProjectProperties.distance_limit" to "5"
And I set "scene.BIMProjectProperties.false_origin_mode" to "MANUAL"
And I set "scene.BIMProjectProperties.false_origin" to "10000,0,0"
When I press "bim.link_ifc(filepath='{cwd}/test/files/geolocation.ifc', use_cache=False)"
Then the object "Chunk" exists
And the object "Chunk" has a vertex at "2,2,-1"
And the object "Chunk" has a vertex at "9,-1,-1"
And the object "Chunk" has a vertex at "17,4,-1"
Scenario: Link IFC - from an empty IFC project - disabled false origin mode
Given an empty IFC project
# Not currently possible via UI
And I set "scene.BIMProjectProperties.distance_limit" to "5"
And I set "scene.BIMProjectProperties.false_origin_mode" to "DISABLED"
When I press "bim.link_ifc(filepath='{cwd}/test/files/geolocation.ifc', use_cache=False)"
Then the object "Chunk" exists Then the object "Chunk" exists
And the object "Chunk" has a vertex at "2,2,-1" And the object "Chunk" has a vertex at "2,2,-1"
And the object "Chunk" has a vertex at "9,-1,-1" And the object "Chunk" has a vertex at "9,-1,-1"
@@ -712,31 +724,42 @@ Scenario: Link IFC - from an empty Blender session - automatic false origin mode
# Not currently possible via UI # Not currently possible via UI
And I set "scene.BIMProjectProperties.distance_limit" to "5" And I set "scene.BIMProjectProperties.distance_limit" to "5"
And I set "scene.BIMProjectProperties.false_origin_mode" to "AUTOMATIC" And I set "scene.BIMProjectProperties.false_origin_mode" to "AUTOMATIC"
When I link IFC project from "{cwd}/test/files/geolocation.ifc" When I press "bim.link_ifc(filepath='{cwd}/test/files/geolocation.ifc', use_cache=False)"
Then the object "Chunk" exists Then the object "Chunk" exists
And the object "Chunk" has a vertex at "-11,-2,0" And the object "Chunk" has a vertex at "-11,-2,0"
And the object "Chunk" has a vertex at "-4,-5,0" And the object "Chunk" has a vertex at "-4,-5,0"
And the object "Chunk" has a vertex at "4,0,0" And the object "Chunk" has a vertex at "4,0,0"
Scenario: Link IFC - manual false origin mode Scenario: Link IFC - from an empty Blender session - manual false origin mode
Given an empty Blender session Given an empty Blender session
# Not currently possible via UI # Not currently possible via UI
And I set "scene.BIMProjectProperties.distance_limit" to "5" And I set "scene.BIMProjectProperties.distance_limit" to "5"
And I set "scene.BIMProjectProperties.false_origin_mode" to "MANUAL" And I set "scene.BIMProjectProperties.false_origin_mode" to "MANUAL"
And I set "scene.BIMProjectProperties.false_origin" to "10000,0,0" And I set "scene.BIMProjectProperties.false_origin" to "10000,0,0"
When I link IFC project from "{cwd}/test/files/geolocation.ifc" When I press "bim.link_ifc(filepath='{cwd}/test/files/geolocation.ifc', use_cache=False)"
Then the object "Chunk" exists Then the object "Chunk" exists
And the object "Chunk" has a vertex at "-8,2,-1" And the object "Chunk" has a vertex at "-8,2,-1"
And the object "Chunk" has a vertex at "-1,-1,-1" And the object "Chunk" has a vertex at "-1,-1,-1"
And the object "Chunk" has a vertex at "7,4,-1" And the object "Chunk" has a vertex at "7,4,-1"
Scenario: Link IFC - automatic false origin mode - two different false origins and project norths - grid north is up because we start with geolocation.ifc Scenario: Link IFC - from an empty Blender session - disabled false origin mode
Given an empty Blender session
# Not currently possible via UI
And I set "scene.BIMProjectProperties.distance_limit" to "5"
And I set "scene.BIMProjectProperties.false_origin_mode" to "DISABLED"
When I press "bim.link_ifc(filepath='{cwd}/test/files/geolocation.ifc', use_cache=False)"
Then the object "Chunk" exists
And the object "Chunk" has a vertex at "2,2,-1"
And the object "Chunk" has a vertex at "9,-1,-1"
And the object "Chunk" has a vertex at "17,4,-1"
Scenario: Link IFC - from an empty Blender session - automatic false origin mode - two different false origins and project norths - grid north is up because we start with geolocation.ifc
Given an empty Blender session Given an empty Blender session
# Not currently possible via UI # Not currently possible via UI
And I set "scene.BIMProjectProperties.distance_limit" to "5" And I set "scene.BIMProjectProperties.distance_limit" to "5"
And I set "scene.BIMProjectProperties.false_origin_mode" to "AUTOMATIC" And I set "scene.BIMProjectProperties.false_origin_mode" to "AUTOMATIC"
When I link IFC project from "{cwd}/test/files/geolocation.ifc" When I press "bim.link_ifc(filepath='{cwd}/test/files/geolocation.ifc', use_cache=False)"
And I link IFC project from "{cwd}/test/files/geolocation-mapconversion-angle.ifc" And I press "bim.link_ifc(filepath='{cwd}/test/files/geolocation-mapconversion-angle.ifc', use_cache=False)"
Then the object "Col:IfcProject/geolocation.ifc:Chunk" exists Then the object "Col:IfcProject/geolocation.ifc:Chunk" exists
And the object "Col:IfcProject/geolocation-mapconversion-angle.ifc:Chunk" exists And the object "Col:IfcProject/geolocation-mapconversion-angle.ifc:Chunk" exists
And the object "Col:IfcProject/geolocation.ifc:Chunk" has a vertex at "-11,-2,0" And the object "Col:IfcProject/geolocation.ifc:Chunk" has a vertex at "-11,-2,0"
@@ -746,13 +769,13 @@ Scenario: Link IFC - automatic false origin mode - two different false origins a
And the object "Col:IfcProject/geolocation-mapconversion-angle.ifc:Chunk" has a vertex at "9.294,-9.366,0" And the object "Col:IfcProject/geolocation-mapconversion-angle.ifc:Chunk" has a vertex at "9.294,-9.366,0"
And the object "Col:IfcProject/geolocation-mapconversion-angle.ifc:Chunk" has a vertex at "18.722,-9.036,0" And the object "Col:IfcProject/geolocation-mapconversion-angle.ifc:Chunk" has a vertex at "18.722,-9.036,0"
Scenario: Link IFC - automatic false origin mode - two different false origins and project norths - project north is up because we start with geolocation-mapconversion-angle.ifc Scenario: Link IFC - from an empty Blender session - automatic false origin mode - two different false origins and project norths - project north is up because we start with geolocation-mapconversion-angle.ifc
Given an empty Blender session Given an empty Blender session
# Not currently possible via UI # Not currently possible via UI
And I set "scene.BIMProjectProperties.distance_limit" to "5" And I set "scene.BIMProjectProperties.distance_limit" to "5"
And I set "scene.BIMProjectProperties.false_origin_mode" to "AUTOMATIC" And I set "scene.BIMProjectProperties.false_origin_mode" to "AUTOMATIC"
When I link IFC project from "{cwd}/test/files/geolocation-mapconversion-angle.ifc" When I press "bim.link_ifc(filepath='{cwd}/test/files/geolocation-mapconversion-angle.ifc', use_cache=False)"
And I link IFC project from "{cwd}/test/files/geolocation.ifc" And I press "bim.link_ifc(filepath='{cwd}/test/files/geolocation.ifc', use_cache=False)"
Then the object "Col:IfcProject/geolocation.ifc:Chunk" exists Then the object "Col:IfcProject/geolocation.ifc:Chunk" exists
And the object "Col:IfcProject/geolocation-mapconversion-angle.ifc:Chunk" exists And the object "Col:IfcProject/geolocation-mapconversion-angle.ifc:Chunk" exists
And the object "Col:IfcProject/geolocation-mapconversion-angle.ifc:Chunk" has a vertex at "-11,-2,0" And the object "Col:IfcProject/geolocation-mapconversion-angle.ifc:Chunk" has a vertex at "-11,-2,0"
@@ -762,14 +785,14 @@ Scenario: Link IFC - automatic false origin mode - two different false origins a
And the object "Col:IfcProject/geolocation.ifc:Chunk" has a vertex at "-17.696,-7.866,0" And the object "Col:IfcProject/geolocation.ifc:Chunk" has a vertex at "-17.696,-7.866,0"
And the object "Col:IfcProject/geolocation.ifc:Chunk" has a vertex at "-13.268,0.464,0" And the object "Col:IfcProject/geolocation.ifc:Chunk" has a vertex at "-13.268,0.464,0"
Scenario: Link IFC - automatic false origin mode - three identical false origins but different project and map units Scenario: Link IFC - from an empty Blender session - automatic false origin mode - three identical false origins but different project and map units
Given an empty Blender session Given an empty Blender session
# Not currently possible via UI # Not currently possible via UI
And I set "scene.BIMProjectProperties.distance_limit" to "5" And I set "scene.BIMProjectProperties.distance_limit" to "5"
And I set "scene.BIMProjectProperties.false_origin_mode" to "AUTOMATIC" And I set "scene.BIMProjectProperties.false_origin_mode" to "AUTOMATIC"
When I link IFC project from "{cwd}/test/files/geolocation-unit1.ifc" When I press "bim.link_ifc(filepath='{cwd}/test/files/geolocation-unit1.ifc', use_cache=False)"
And I link IFC project from "{cwd}/test/files/geolocation-unit2.ifc" And I press "bim.link_ifc(filepath='{cwd}/test/files/geolocation-unit2.ifc', use_cache=False)"
And I link IFC project from "{cwd}/test/files/geolocation-unit3.ifc" And I press "bim.link_ifc(filepath='{cwd}/test/files/geolocation-unit3.ifc', use_cache=False)"
Then the object "Col:IfcProject/geolocation-unit1.ifc:Chunk" exists Then the object "Col:IfcProject/geolocation-unit1.ifc:Chunk" exists
And the object "Col:IfcProject/geolocation-unit2.ifc:Chunk" exists And the object "Col:IfcProject/geolocation-unit2.ifc:Chunk" exists
And the object "Col:IfcProject/geolocation-unit3.ifc:Chunk" exists And the object "Col:IfcProject/geolocation-unit3.ifc:Chunk" exists
@@ -779,54 +802,54 @@ Scenario: Link IFC - automatic false origin mode - three identical false origins
Scenario: Toggle link visibility - wireframe mode Scenario: Toggle link visibility - wireframe mode
Given an empty IFC project Given an empty IFC project
And I link IFC project from "{cwd}/test/files/basic.ifc" And I press "bim.link_ifc(filepath='{cwd}/test/files/basic.ifc')"
When I press "bim.toggle_link_visibility(link='{cwd}/test/files/basic.ifc', mode='WIREFRAME')" When I press "bim.toggle_link_visibility(link_index=0, mode='WIREFRAME')"
Then "scene.BIMProjectProperties.links['{cwd}/test/files/basic.ifc'].is_wireframe" is "True" Then "scene.BIMProjectProperties.links['{cwd}/test/files/basic.ifc'].is_wireframe" is "True"
And the object "Chunk" should display as "WIRE" And the object "Chunk" should display as "WIRE"
When I press "bim.toggle_link_visibility(link='{cwd}/test/files/basic.ifc', mode='WIREFRAME')" When I press "bim.toggle_link_visibility(link_index=0, mode='WIREFRAME')"
Then "scene.BIMProjectProperties.links['{cwd}/test/files/basic.ifc'].is_wireframe" is "False" Then "scene.BIMProjectProperties.links['{cwd}/test/files/basic.ifc'].is_wireframe" is "False"
And the object "Chunk" should display as "TEXTURED" And the object "Chunk" should display as "TEXTURED"
Scenario: Toggle link selectability Scenario: Toggle link selectability
Given an empty IFC project Given an empty IFC project
And I link IFC project from "{cwd}/test/files/basic.ifc" And I press "bim.link_ifc(filepath='{cwd}/test/files/basic.ifc')"
When I press "bim.toggle_link_selectability(link='{cwd}/test/files/basic.ifc')" When I press "bim.toggle_link_selectability(link_index=0)"
Then "scene.BIMProjectProperties.links['{cwd}/test/files/basic.ifc'].is_selectable" is "False" Then "scene.BIMProjectProperties.links['{cwd}/test/files/basic.ifc'].is_selectable" is "False"
And the collection "IfcProject/basic.ifc" is unselectable And the collection "IfcProject/basic.ifc" is unselectable
When I press "bim.toggle_link_selectability(link='{cwd}/test/files/basic.ifc')" When I press "bim.toggle_link_selectability(link_index=0)"
Then "scene.BIMProjectProperties.links['{cwd}/test/files/basic.ifc'].is_selectable" is "True" Then "scene.BIMProjectProperties.links['{cwd}/test/files/basic.ifc'].is_selectable" is "True"
And the collection "IfcProject/basic.ifc" is selectable And the collection "IfcProject/basic.ifc" is selectable
Scenario: Toggle link visibility - visible mode Scenario: Toggle link visibility - visible mode
Given an empty IFC project Given an empty IFC project
And I link IFC project from "{cwd}/test/files/basic.ifc" And I press "bim.link_ifc(filepath='{cwd}/test/files/basic.ifc')"
When I press "bim.toggle_link_visibility(link='{cwd}/test/files/basic.ifc', mode='VISIBLE')" When I press "bim.toggle_link_visibility(link_index=0, mode='VISIBLE')"
Then "scene.BIMProjectProperties.links['{cwd}/test/files/basic.ifc'].is_hidden" is "True" Then "scene.BIMProjectProperties.links['{cwd}/test/files/basic.ifc'].is_hidden" is "True"
And the object "IfcProject/basic.ifc" is not visible And the object "IfcProject/basic.ifc" is not visible
When I press "bim.toggle_link_visibility(link='{cwd}/test/files/basic.ifc', mode='VISIBLE')" When I press "bim.toggle_link_visibility(link_index=0, mode='VISIBLE')"
Then "scene.BIMProjectProperties.links['{cwd}/test/files/basic.ifc'].is_hidden" is "False" Then "scene.BIMProjectProperties.links['{cwd}/test/files/basic.ifc'].is_hidden" is "False"
And the object "IfcProject/basic.ifc" is visible And the object "IfcProject/basic.ifc" is visible
Scenario: Unload link Scenario: Unload link
Given an empty Blender session Given an empty Blender session
And I link IFC project from "{cwd}/test/files/basic.ifc" And I press "bim.link_ifc(filepath='{cwd}/test/files/basic.ifc')"
When I press "bim.unload_link(filepath='{cwd}/test/files/basic.ifc')" When I press "bim.unload_link(link_index=0)"
Then "scene.BIMProjectProperties.links['{cwd}/test/files/basic.ifc'].is_loaded" is "False" Then "scene.BIMProjectProperties.links['{cwd}/test/files/basic.ifc'].is_loaded" is "False"
And the collection "IfcProject/basic.ifc" does not exist And the collection "IfcProject/basic.ifc" does not exist
Scenario: Load link Scenario: Load link
Given an empty Blender session Given an empty Blender session
And I link IFC project from "{cwd}/test/files/basic.ifc" And I press "bim.link_ifc(filepath='{cwd}/test/files/basic.ifc')"
And I press "bim.unload_link(filepath='{cwd}/test/files/basic.ifc')" And I press "bim.unload_link(link_index=0)"
When I press "bim.load_link(filepath='{cwd}/test/files/basic.ifc')" When I press "bim.load_link(link_index=0)"
Then "scene.BIMProjectProperties.links['{cwd}/test/files/basic.ifc'].is_loaded" is "True" Then "scene.BIMProjectProperties.links['{cwd}/test/files/basic.ifc'].is_loaded" is "True"
And the object "IfcProject/basic.ifc" exists And the object "IfcProject/basic.ifc" exists
Scenario: Unlink IFC Scenario: Unlink IFC
Given an empty Blender session Given an empty Blender session
And I link IFC project from "{cwd}/test/files/basic.ifc" And I press "bim.link_ifc(filepath='{cwd}/test/files/basic.ifc')"
And I press "bim.unload_link(filepath='{cwd}/test/files/basic.ifc')" And I press "bim.unload_link(link_index=0)"
When I press "bim.unlink_ifc(filepath='{cwd}/test/files/basic.ifc')" When I press "bim.unlink_ifc(link_index=0)"
Then "scene.BIMProjectProperties.links.get('{cwd}/test/files/basic.ifc')" is "None" Then "scene.BIMProjectProperties.links.get('{cwd}/test/files/basic.ifc')" is "None"
And "scene.collection.children.get('IfcProject/basic.ifc')" is "None" And "scene.collection.children.get('IfcProject/basic.ifc')" is "None"
And the object "Chunk" does not exist And the object "Chunk" does not exist
+16 -37
View File
@@ -70,10 +70,7 @@ Can be useful for debugging, but has caveats - can't use ``wm.read_homefile``
as resets the ``bpy.context`` and some it's members become `None`. as resets the ``bpy.context`` and some it's members become `None`.
""" """
TMP = Path.cwd() / "test/files/temp" TMP = Path(f"{variables['cwd']}/test/files/temp")
TEST_FILES_DIR = Path.cwd() / "test/files"
CLEAN_LINKED_FILES_CACHE = False
EPSET_DRAWING = Path.cwd() / "bonsai/bim/data/pset/EPset_Drawing.ifc" EPSET_DRAWING = Path.cwd() / "bonsai/bim/data/pset/EPset_Drawing.ifc"
EPSET_DRAWING_BYTES = EPSET_DRAWING.read_bytes() EPSET_DRAWING_BYTES = EPSET_DRAWING.read_bytes()
@@ -283,6 +280,8 @@ def create_ui_name_cache():
try: try:
panel_type = getattr(bpy.types, bl_idname) panel_type = getattr(bpy.types, bl_idname)
if panel_type.bl_rna.base.name == "Panel": 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 ui_name_cache[panel_type.bl_label] = panel_type.bl_idname
elif panel_type.bl_rna.base.name == "Operator": elif panel_type.bl_rna.base.name == "Operator":
ui_name_cache[panel_type.bl_label] = bl_idname 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))) return all(is_x(v1[i], v2[i]) for i in range(len(v1)))
@pytest.fixture(scope="function", autouse=True)
def run_for_each_test() -> Generator[None]:
# Code before this runs before each test
yield
# Code after this runs after each test
global CLEAN_LINKED_FILES_CACHE
if CLEAN_LINKED_FILES_CACHE:
for filepath in TEST_FILES_DIR.glob("*.ifc.cache.*"):
filepath.unlink()
CLEAN_LINKED_FILES_CACHE = False
# pset_template tests are editing EPset_Drawing.ifc, so we need to restore it.
global RELOAD_EPSET_DRAWING
if RELOAD_EPSET_DRAWING:
EPSET_DRAWING.write_bytes(EPSET_DRAWING_BYTES)
RELOAD_EPSET_DRAWING = False
@given("an untestable scenario") @given("an untestable scenario")
def an_untestable_scenario(): def an_untestable_scenario():
pass pass
@@ -384,16 +364,6 @@ def saving_ifc_project() -> None:
tool.Project.save_test_project() tool.Project.save_test_project()
@given(parsers.parse('I link IFC project from "{filepath}"'))
@when(parsers.parse('I link IFC project from "{filepath}"'))
@then(parsers.parse('I link IFC project from "{filepath}"'))
def i_link_ifc_project_from_filepath(filepath: str) -> None:
global CLEAN_LINKED_FILES_CACHE
filepath = replace_variables(filepath)
CLEAN_LINKED_FILES_CACHE = True
bpy.ops.bim.link_ifc(filepath=filepath, use_cache=False)
@given("the Brickschema is stubbed") @given("the Brickschema is stubbed")
def the_brickschema_is_stubbed(): def the_brickschema_is_stubbed():
# This makes things run faster since we don't need to load the entire brick schema # This makes things run faster since we don't need to load the entire brick schema
@@ -1587,10 +1557,19 @@ def the_object_name_has_a_vertex_at_location(name, location):
is_pass = False is_pass = False
target = Vector([float(co) for co in location.split(",")]) target = Vector([float(co) for co in location.split(",")])
verts = [] verts = []
for v in obj.data.vertices: depsgraph = bpy.context.evaluated_depsgraph_get()
verts.append(obj.matrix_world @ v.co) obj_eval = obj.evaluated_get(depsgraph)
if (verts[-1] - target).length < 0.001: mesh = obj_eval.to_mesh(preserve_all_data_layers=False, depsgraph=depsgraph)
is_pass = True 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}" assert is_pass, f"No verts found at {location}: {verts}"
+34
View File
@@ -24,8 +24,10 @@ import pytest
import bonsai.core.tool import bonsai.core.tool
import bonsai.tool as tool import bonsai.tool as tool
import tempfile
from bonsai.tool.blender import Blender as subject from bonsai.tool.blender import Blender as subject
from test.bim.bootstrap import NewFile from test.bim.bootstrap import NewFile
from pathlib import Path
if TYPE_CHECKING: if TYPE_CHECKING:
import bpy.stub_internal.rna_enums as rna_enums import bpy.stub_internal.rna_enums as rna_enums
@@ -110,3 +112,35 @@ class TestBlenderErrorMessageExtraction(NewFile):
assert error_reports == [] assert error_reports == []
bpy.utils.unregister_class(OBJECT_OT_test_fail_operator) 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
]
+31 -69
View File
@@ -21,6 +21,7 @@ import xml.etree.ElementTree as ET
from pathlib import Path from pathlib import Path
import bpy import bpy
import pytest
import ifcopenshell import ifcopenshell
import ifcopenshell.api.drawing import ifcopenshell.api.drawing
import ifcopenshell.api.group import ifcopenshell.api.group
@@ -31,6 +32,7 @@ import ifcopenshell.util.element
import mathutils import mathutils
import numpy as np import numpy as np
from mathutils import Vector from mathutils import Vector
from ifcopenshell.util.shape_builder import ShapeBuilder
import bonsai.core.tool import bonsai.core.tool
import bonsai.tool as tool import bonsai.tool as tool
@@ -150,6 +152,34 @@ class TestDisableEditingSheets(NewFile):
assert props.is_editing_sheets == False 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): class TestDisableEditingText(NewFile):
def test_run(self): def test_run(self):
obj = bpy.data.objects.new("Object", None) obj = bpy.data.objects.new("Object", None)
@@ -908,7 +938,7 @@ class TestAddReferenceImage(NewFile):
obj = bpy.data.objects["IfcAnnotation/image"] obj = bpy.data.objects["IfcAnnotation/image"]
assert obj is not None 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 material = obj.active_material
assert material assert material
@@ -928,71 +958,3 @@ class TestAddReferenceImage(NewFile):
uv_node = material_nodes["Texture Coordinate"] uv_node = material_nodes["Texture Coordinate"]
assert len(uv_node.outputs["Generated"].links[:]) == 1 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('<svg xmlns="http://www.w3.org/2000/svg"></svg>')
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 xmlns="http://www.w3.org/2000/svg"></svg>')
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()
+70 -72
View File
@@ -16,9 +16,12 @@
# You should have received a copy of the GNU General Public License # You should have received a copy of the GNU General Public License
# along with Bonsai. If not, see <http://www.gnu.org/licenses/>. # along with Bonsai. If not, see <http://www.gnu.org/licenses/>.
import json
import contextlib import contextlib
import tempfile import tempfile
import numpy as np
from pathlib import Path from pathlib import Path
from tempfile import NamedTemporaryFile
import bpy import bpy
import ifcopenshell import ifcopenshell
@@ -263,7 +266,7 @@ class TestLoadLinkedModels(NewFile):
props = tool.Project.get_project_props() props = tool.Project.get_project_props()
ifcopenshell.api.root.create_entity(ifc, "IfcProject") ifcopenshell.api.root.create_entity(ifc, "IfcProject")
document = ifcopenshell.api.document.add_information(ifc) document = ifcopenshell.api.document.add_information(ifc)
document.Name = "BBIM_Linked_Models" document.Name = "X"
tool.Ifc.set(ifc) tool.Ifc.set(ifc)
subject.load_linked_models_from_ifc() subject.load_linked_models_from_ifc()
assert len(props.links) == 0 assert len(props.links) == 0
@@ -273,86 +276,81 @@ class TestLoadLinkedModels(NewFile):
props = tool.Project.get_project_props() props = tool.Project.get_project_props()
ifcopenshell.api.root.create_entity(ifc, "IfcProject") ifcopenshell.api.root.create_entity(ifc, "IfcProject")
document = ifcopenshell.api.document.add_information(ifc) document = ifcopenshell.api.document.add_information(ifc)
document.Name = "BBIM_Linked_Models" document.Scope = "LINKED_MODEL"
reference = ifcopenshell.api.document.add_reference(ifc, document) reference = ifcopenshell.api.document.add_reference(ifc, document)
linked_model_path = "test.ifc" reference.Location = "test.ifc"
reference.Location = linked_model_path reference.Identification = ""
reference2 = ifcopenshell.api.document.add_reference(ifc, document)
reference2.Location = "test2.ifc"
reference2.Identification = "1,2,3,4,5,6,7,8,9,10,11,12,13,14,15,16"
tool.Ifc.set(ifc) tool.Ifc.set(ifc)
subject.load_linked_models_from_ifc() subject.load_linked_models_from_ifc()
assert len(props.links) == 1 assert len(props.links) == 2
assert props.links[0].name == linked_model_path assert props.links[0].name == "test.ifc"
assert props.links[0].ifc_definition_id == reference.id()
assert props.links[0].has_transformation is False
assert props.links[1].name == "test2.ifc"
assert props.links[1].ifc_definition_id == reference2.id()
assert props.links[1].has_transformation is True
class TestSaveLinkedModelsToIfc(NewFile): class TestCalculateLinkMatrix(NewFile):
def test_save_linked_models_to_ifc_no_links(self): def test_linking_a_model_without_an_offset_to_our_session_with_no_offset(self):
ifc = ifcopenshell.file()
tool.Ifc.set(ifc)
subject.save_linked_models_to_ifc()
assert len(ifc.by_type("IfcDocumentInformation")) == 0
assert len(ifc.by_type("IfcDocumentReference")) == 0
def test_save_linked_models_to_ifc_paths_to_add(self):
ifc = ifcopenshell.file()
ifcopenshell.api.root.create_entity(ifc, "IfcProject")
props = tool.Project.get_project_props() props = tool.Project.get_project_props()
link = props.links.add() gprops = tool.Georeference.get_georeference_props()
linked_model_path = "test.ifc" with NamedTemporaryFile(suffix=".ifc.cache.json", mode="w", delete=True) as tmp:
link.name = linked_model_path link = props.links.add()
tool.Ifc.set(ifc) link.filepath = tmp.name.replace(".ifc.cache.json", ".ifc")
subject.save_linked_models_to_ifc() json.dump({"model_project_north": "0", "model_origin_si": "0,0,0"}, tmp)
assert len(documents := ifc.by_type("IfcDocumentInformation")) == 1 tmp.flush()
assert documents[0].Name == "BBIM_Linked_Models" gprops.model_project_north = "0"
assert len(references := ifc.by_type("IfcDocumentReference")) == 1 gprops.model_origin_si = "0,0,0"
assert references[0].Location == linked_model_path assert np.allclose(subject.calculate_link_matrix(link), np.eye(4))
def test_save_linked_models_to_ifc_already_created_references(self): def test_linking_an_offset_model_to_our_session_with_no_offset(self):
ifc = ifcopenshell.file() props = tool.Project.get_project_props()
links = tool.Project.get_project_props().links gprops = tool.Georeference.get_georeference_props()
ifcopenshell.api.root.create_entity(ifc, "IfcProject") with NamedTemporaryFile(suffix=".ifc.cache.json", mode="w", delete=True) as tmp:
link = props.links.add()
link.filepath = tmp.name.replace(".ifc.cache.json", ".ifc")
json.dump({"model_project_north": "0", "model_origin_si": "5,0,0"}, tmp)
tmp.flush()
gprops.model_project_north = "0"
gprops.model_origin_si = "0,0,0"
m = np.eye(4)
m[0][3] = 5
assert np.allclose(subject.calculate_link_matrix(link), m)
document = ifcopenshell.api.document.add_information(ifc) def test_linking_an_offset_model_to_our_session_with_offset(self):
document.Name = "BBIM_Linked_Models" props = tool.Project.get_project_props()
document_id = document.id() gprops = tool.Georeference.get_georeference_props()
reference = ifcopenshell.api.document.add_reference(ifc, document) with NamedTemporaryFile(suffix=".ifc.cache.json", mode="w", delete=True) as tmp:
linked_model_path = "test.ifc" link = props.links.add()
reference.Location = linked_model_path link.filepath = tmp.name.replace(".ifc.cache.json", ".ifc")
reference_id = reference.id() json.dump({"model_project_north": "0", "model_origin_si": "5,0,0"}, tmp)
tmp.flush()
gprops.model_project_north = "0"
gprops.model_origin_si = "2,0,0"
m = np.eye(4)
m[0][3] = 3
assert np.allclose(subject.calculate_link_matrix(link), m)
link = links.add() def test_linking_an_offset_model_to_our_session_with_offset_and_transformation(self):
linked_model_path = "test.ifc" props = tool.Project.get_project_props()
link.name = linked_model_path gprops = tool.Georeference.get_georeference_props()
tool.Ifc.set(ifc) with NamedTemporaryFile(suffix=".ifc.cache.json", mode="w", delete=True) as tmp:
subject.save_linked_models_to_ifc() link = props.links.add()
link.filepath = tmp.name.replace(".ifc.cache.json", ".ifc")
# Information and references to stay intact. transformation = np.eye(4)
assert len(documents := ifc.by_type("IfcDocumentInformation")) == 1 transformation[0][3] = 4
assert documents[0].id() == document_id link.transformation = ",".join(map(str, transformation.reshape(-1)))
assert documents[0].Name == "BBIM_Linked_Models" json.dump({"model_project_north": "0", "model_origin_si": "5,0,0"}, tmp)
assert len(references := ifc.by_type("IfcDocumentReference")) == 1 tmp.flush()
assert references[0].id() == reference_id gprops.model_project_north = "0"
assert references[0].Location == linked_model_path gprops.model_origin_si = "2,0,0"
m = np.eye(4)
def test_save_linked_models_to_ifc_references_to_remove(self): m[0][3] = 7
ifc = ifcopenshell.file() assert np.allclose(subject.calculate_link_matrix(link), m)
links = tool.Project.get_project_props().links
ifcopenshell.api.root.create_entity(ifc, "IfcProject")
document = ifcopenshell.api.document.add_information(ifc)
document.Name = "BBIM_Linked_Models"
document_id = document.id()
reference = ifcopenshell.api.document.add_reference(ifc, document)
linked_model_path = "test.ifc"
reference.Location = linked_model_path
tool.Ifc.set(ifc)
subject.save_linked_models_to_ifc()
links.clear()
# Remove reference for removed link.
assert len(documents := ifc.by_type("IfcDocumentInformation")) == 1
assert documents[0].id() == document_id
assert documents[0].Name == "BBIM_Linked_Models"
assert len(ifc.by_type("IfcDocumentReference")) == 0
class TestLoadingIfcSqlite(NewFile): class TestLoadingIfcSqlite(NewFile):
@@ -61,33 +61,9 @@ taxonomy::ptr mapping::map_impl(const IfcSchema::IfcSectionedSolidHorizontal* in
longitudes.push_back(*pbde->DistanceAlong()->as<IfcSchema::IfcLengthMeasure>(true) * length_unit_); longitudes.push_back(*pbde->DistanceAlong()->as<IfcSchema::IfcLengthMeasure>(true) * length_unit_);
// Corresponds to the profile X, Y directions (hopefully). auto linear_placement = taxonomy::cast<taxonomy::matrix4>(map(csp));
Eigen::Vector3d po( profile_offsets.push_back(linear_placement->ccomponents().block<3, 1>(0, 3));
pbde->OffsetLateral().get_value_or(0.), boost::optional<Eigen::Matrix3d> rot(linear_placement->ccomponents().block<3,3>(0,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<Eigen::Matrix3d> rot;
if (csp->Axis() && csp->RefDirection()) {
rot = taxonomy::matrix4(
Eigen::Vector3d(0, 0, 0),
taxonomy::cast<taxonomy::direction3>(map(csp->Axis()))->ccomponents(),
taxonomy::cast<taxonomy::direction3>(map(csp->RefDirection()))->ccomponents()).ccomponents().block<3,3>(0,0);
} else if (csp->Axis()) {
rot = taxonomy::matrix4(
Eigen::Vector3d(0, 0, 0),
taxonomy::cast<taxonomy::direction3>(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<taxonomy::direction3>(map(csp->RefDirection()))->ccomponents()
).ccomponents().block<3, 3>(0, 0);
}
profile_rotations.push_back(rot); profile_rotations.push_back(rot);
} }
if (faces.size() != profile_offsets.size()) { if (faces.size() != profile_offsets.size()) {
+4 -29
View File
@@ -63,35 +63,10 @@ taxonomy::ptr mapping::map_impl(const IfcSchema::IfcSectionedSurface* inst) {
longitudes.push_back(*pbde->DistanceAlong()->as<IfcSchema::IfcLengthMeasure>(true) * length_unit_); longitudes.push_back(*pbde->DistanceAlong()->as<IfcSchema::IfcLengthMeasure>(true) * length_unit_);
// Corresponds to the profile X, Y directions (hopefully). auto linear_placement = taxonomy::cast<taxonomy::matrix4>(map(csp));
Eigen::Vector3d po( profile_offsets.push_back(linear_placement->ccomponents().block<3, 1>(0, 3));
pbde->OffsetLateral().get_value_or(0.), boost::optional<Eigen::Matrix3d> rot(linear_placement->ccomponents().block<3, 3>(0, 0));
// @todo I don't understand whether vertical is an offset relative to the tangent plane or to the global XY plane profile_rotations.push_back(rot);
pbde->OffsetVertical().get_value_or(0.),
0.
);
profile_offsets.push_back(po);
boost::optional<Eigen::Matrix3d> rot;
if (csp->Axis() && csp->RefDirection()) {
rot = taxonomy::matrix4(
Eigen::Vector3d(0, 0, 0),
taxonomy::cast<taxonomy::direction3>(map(csp->Axis()))->ccomponents(),
taxonomy::cast<taxonomy::direction3>(map(csp->RefDirection()))->ccomponents()).ccomponents().block<3, 3>(0, 0);
} else if (csp->Axis()) {
rot = taxonomy::matrix4(
Eigen::Vector3d(0, 0, 0),
taxonomy::cast<taxonomy::direction3>(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<taxonomy::direction3>(map(csp->RefDirection()))->ccomponents())
.ccomponents()
.block<3, 3>(0, 0);
}
profile_rotations.push_back(rot);
} }
#else #else
return nullptr; return nullptr;
@@ -18,7 +18,7 @@
import math import math
from decimal import ROUND_HALF_UP, Decimal from decimal import ROUND_HALF_UP, Decimal
from typing import NamedTuple, Optional, Union from typing import NamedTuple, Optional, Union, Any
import numpy as np import numpy as np
@@ -268,6 +268,15 @@ def get_helmert_transformation_parameters(ifc_file: ifcopenshell.file) -> Option
return HelmertTransformation(e, n, h, xaa, xao, scale, factor_x, factor_y, factor_z) return HelmertTransformation(e, n, h, xaa, xao, scale, factor_x, factor_y, factor_z)
def get_crs(ifc_file: ifcopenshell.file) -> dict[str, Any]:
"""Get CRS information from an IFC file."""
if ifc_file.schema == "IFC2X3":
return ifcopenshell.util.element.get_pset(ifc_file.by_type("IfcProject")[0], "ePSet_ProjectedCRS")
for context in ifc_file.by_type("IfcGeometricRepresentationContext", include_subtypes=False):
if operation := context.HasCoordinateOperation:
return operation[0].TargetCRS.get_info()
def auto_z2e(ifc_file: ifcopenshell.file, z: float, should_return_in_map_units: bool = True) -> float: def auto_z2e(ifc_file: ifcopenshell.file, z: float, should_return_in_map_units: bool = True) -> float:
"""Convert a Z coordinate to an elevation using model georeferencing data """Convert a Z coordinate to an elevation using model georeferencing data