diff --git a/src/bonsai/bonsai/bim/module/model/profile.py b/src/bonsai/bonsai/bim/module/model/profile.py
index d6fcb4c6fd..efbf41900d 100644
--- a/src/bonsai/bonsai/bim/module/model/profile.py
+++ b/src/bonsai/bonsai/bim/module/model/profile.py
@@ -1002,6 +1002,14 @@ class EnableEditingExtrusionAxis(bpy.types.Operator, tool.Ifc.Operator):
def _execute(self, context):
self.unit_scale = ifcopenshell.util.unit.calculate_unit_scale(tool.Ifc.get())
obj = context.active_object
+
+ # Commit any in-progress parametric (gizmo) draft on this object
+ # before switching to axis-edit. Otherwise the in-memory draft
+ # state is overwritten when the axis mesh is imported below,
+ # silently discarding the user's pending dimension edits.
+ if feature := tool.Parametric.is_object_editing(obj):
+ tool.Parametric.commit_object_draft(obj, feature.finish_op)
+
element = tool.Ifc.get_entity(obj)
axis = ifcopenshell.util.representation.get_representation(element, "Model", "Axis", "GRAPH_VIEW")
diff --git a/src/bonsai/bonsai/bim/module/model/slab.py b/src/bonsai/bonsai/bim/module/model/slab.py
index 9d7e88a23d..4f47740e6d 100644
--- a/src/bonsai/bonsai/bim/module/model/slab.py
+++ b/src/bonsai/bonsai/bim/module/model/slab.py
@@ -620,6 +620,14 @@ class EnableEditingExtrusionProfile(bpy.types.Operator, tool.Ifc.Operator):
def _execute(self, context):
self.unit_scale = ifcopenshell.util.unit.calculate_unit_scale(tool.Ifc.get())
obj = context.active_object
+
+ # Commit any in-progress parametric (gizmo) draft on this object
+ # before switching to profile-edit. Otherwise the in-memory draft
+ # state is overwritten when the profile mesh is imported below,
+ # silently discarding the user's pending dimension edits.
+ if feature := tool.Parametric.is_object_editing(obj):
+ tool.Parametric.commit_object_draft(obj, feature.finish_op)
+
element = tool.Ifc.get_entity(obj)
body = ifcopenshell.util.representation.get_representation(element, "Model", "Body", "MODEL_VIEW")
diff --git a/src/bonsai/bonsai/bim/module/project/ui.py b/src/bonsai/bonsai/bim/module/project/ui.py
index a793dbc455..5e828ddd9b 100644
--- a/src/bonsai/bonsai/bim/module/project/ui.py
+++ b/src/bonsai/bonsai/bim/module/project/ui.py
@@ -28,9 +28,8 @@ from bpy.types import Menu, Panel, UIList
import bonsai.bim
import bonsai.tool as tool
from bonsai.bim.helper import draw_attributes, prop_with_search
-from bonsai.bim.ifc import IfcStore, is_cache_locked_by_other_process
+from bonsai.bim.ifc import IfcStore
from bonsai.bim.module.project.data import LinksData, ProjectData
-from bonsai.bim.ui import draw_multiline_text
if TYPE_CHECKING:
from bonsai.bim.module.project.prop import (
@@ -167,20 +166,6 @@ class BIM_PT_project(Panel):
if pprops.is_loading:
self.draw_advanced_loading_ui(context)
elif self.file or props.ifc_file:
- if is_cache_locked_by_other_process():
- box = self.layout.box()
- box.alert = True
- row = box.row(align=True)
- row.label(text="IFC Already Open in Another Blender Instance", icon="ERROR")
- row.operator("bim.dismiss_multi_instance_warning", text="", icon="CANCEL")
- draw_multiline_text(
- box.column(align=True),
- "This file is open in another Blender instance. Editing the same "
- "IFC from two instances at once can lose your work or display "
- "outdated geometry. Close the other Blender instances to continue safely.",
- context=context,
- )
-
if props.has_blend_warning:
box = self.layout.box()
box.alert = True
@@ -190,35 +175,6 @@ class BIM_PT_project(Panel):
op.uri = "https://docs.bonsaibim.org/guides/troubleshooting.html#saving-and-loading-blend-files"
row.operator("bim.close_blend_warning", text="", icon="CANCEL")
- if pending := pprops.pending_opening_recut:
- box = self.layout.box()
- box.alert = True
- box.label(text="Opening Cuts Skipped", icon="ERROR")
- draw_multiline_text(
- box.column(align=True),
- f"{len(pending)} element(s) had too many openings to cut during load. "
- f"Apply to recompute their meshes, or dismiss to leave them as they are.",
- context=context,
- )
- row = box.row(align=True)
- row.operator("bim.select_pending_opening_cuts", text="Select Elements", icon="RESTRICT_SELECT_OFF")
- row.operator("bim.apply_pending_opening_cuts", text="Apply Openings", icon="PLAY")
- row.operator("bim.dismiss_pending_opening_cuts", text="", icon="CANCEL")
-
- if pending := pprops.pending_array_repair:
- box = self.layout.box()
- box.alert = True
- box.label(text="Arrays With Missing Children", icon="ERROR")
- draw_multiline_text(
- box.column(align=True),
- f"{len(pending)} array parent(s) reference child GUIDs that don't exist in this file. "
- f"The arrays loaded incomplete. Select to inspect, or dismiss.",
- context=context,
- )
- row = box.row(align=True)
- row.operator("bim.select_pending_array_repair", text="Select Elements", icon="RESTRICT_SELECT_OFF")
- row.operator("bim.dismiss_pending_array_repair", text="", icon="CANCEL")
-
if props.ifc_file:
self.draw_loaded_project_ui(context)
else:
diff --git a/src/bonsai/bonsai/bim/module/type/operator.py b/src/bonsai/bonsai/bim/module/type/operator.py
index 4a6ce053fc..7b306b1b31 100644
--- a/src/bonsai/bonsai/bim/module/type/operator.py
+++ b/src/bonsai/bonsai/bim/module/type/operator.py
@@ -65,10 +65,28 @@ class AssignType(bpy.types.Operator, tool.Ifc.Operator):
if active_drawing:
active_target_view = tool.Drawing.get_drawing_target_view(active_drawing)
+ compatible: list[tuple[bpy.types.Object, ifcopenshell.entity_instance]] = []
+ skipped_classes: set[str] = set()
for obj in related_objects:
element = tool.Ifc.get_entity(obj)
if not element or not element.is_a("IfcObject"):
continue
+ if not tool.Type.is_relating_type_compatible(element, relating_type):
+ skipped_classes.add(element.is_a())
+ continue
+ compatible.append((obj, element))
+
+ if skipped_classes:
+ self.report(
+ {"WARNING"},
+ f"Skipped {', '.join(sorted(skipped_classes))}: not a valid occurrence for " f"{relating_type.is_a()}.",
+ )
+
+ if not compatible:
+ self.report({"ERROR"}, f"No selected object can be typed by {relating_type.is_a()}.")
+ return {"CANCELLED"}
+
+ for obj, element in compatible:
core.assign_type(tool.Ifc, tool.Model, tool.Type, element=element, type=relating_type)
# Switch to the drawing's target view if available
@@ -376,12 +394,22 @@ class DuplicateType(bpy.types.Operator, tool.Ifc.Operator):
if self.assign_selected_objects:
selected_objects = tool.Blender.get_selected_objects()
prefs = tool.Blender.get_addon_preferences()
+ skipped_classes: set[str] = set()
for selected_obj in selected_objects:
selected_element = tool.Ifc.get_entity(selected_obj)
- if selected_element and selected_element.is_a("IfcObject"):
- core.assign_type(tool.Ifc, tool.Model, tool.Type, element=selected_element, type=new)
- if prefs.occurrence_name_style == "TYPE":
- selected_obj.name = tool.Model.generate_occurrence_name(new, selected_element.is_a())
+ if not selected_element or not selected_element.is_a("IfcObject"):
+ continue
+ if not tool.Type.is_relating_type_compatible(selected_element, new):
+ skipped_classes.add(selected_element.is_a())
+ continue
+ core.assign_type(tool.Ifc, tool.Model, tool.Type, element=selected_element, type=new)
+ if prefs.occurrence_name_style == "TYPE":
+ selected_obj.name = tool.Model.generate_occurrence_name(new, selected_element.is_a())
+ if skipped_classes:
+ self.report(
+ {"WARNING"},
+ f"Skipped {', '.join(sorted(skipped_classes))}: not a valid occurrence for " f"{new.is_a()}.",
+ )
if obj in context.selectable_objects:
tool.Blender.select_and_activate_single_object(context, new_obj)
diff --git a/src/bonsai/bonsai/bim/ui.py b/src/bonsai/bonsai/bim/ui.py
index 0212d82c38..facc68bb43 100644
--- a/src/bonsai/bonsai/bim/ui.py
+++ b/src/bonsai/bonsai/bim/ui.py
@@ -39,6 +39,7 @@ from natsort import natsorted
import bonsai.bim
import bonsai.bim.helper
import bonsai.tool as tool
+from bonsai.bim.ifc import is_cache_locked_by_other_process
from bonsai.bim.module.bsdd.prop import BIMBSDDProperties, BSDDProperty
from bonsai.bim.module.model.prop import (
BIMDoorProperties,
@@ -974,6 +975,50 @@ class BIM_PT_tabs(Panel):
op.uri = "https://docs.bonsaibim.org/guides/troubleshooting.html#saving-and-loading-blend-files"
row.operator("bim.close_blend_warning", text="", icon="CANCEL")
+ if is_cache_locked_by_other_process():
+ box = self.layout.box()
+ box.alert = True
+ row = box.row(align=True)
+ row.label(text="IFC Already Open in Another Blender Instance", icon="ERROR")
+ row.operator("bim.dismiss_multi_instance_warning", text="", icon="CANCEL")
+ draw_multiline_text(
+ box.column(align=True),
+ "This file is open in another Blender instance. Editing the same "
+ "IFC from two instances at once can lose your work or display "
+ "outdated geometry. Close the other Blender instances to continue safely.",
+ context=context,
+ )
+
+ pprops = tool.Project.get_project_props()
+ if pending := pprops.pending_opening_recut:
+ box = self.layout.box()
+ box.alert = True
+ box.label(text="Opening Cuts Skipped", icon="ERROR")
+ draw_multiline_text(
+ box.column(align=True),
+ f"{len(pending)} element(s) had too many openings to cut during load. "
+ f"Apply to recompute their meshes, or dismiss to leave them as they are.",
+ context=context,
+ )
+ row = box.row(align=True)
+ row.operator("bim.select_pending_opening_cuts", text="Select Elements", icon="RESTRICT_SELECT_OFF")
+ row.operator("bim.apply_pending_opening_cuts", text="Apply Openings", icon="PLAY")
+ row.operator("bim.dismiss_pending_opening_cuts", text="", icon="CANCEL")
+
+ if pending := pprops.pending_array_repair:
+ box = self.layout.box()
+ box.alert = True
+ box.label(text="Arrays With Missing Children", icon="ERROR")
+ draw_multiline_text(
+ box.column(align=True),
+ f"{len(pending)} array parent(s) reference child GUIDs that don't exist in this file. "
+ f"The arrays loaded incomplete. Select to inspect, or dismiss.",
+ context=context,
+ )
+ row = box.row(align=True)
+ row.operator("bim.select_pending_array_repair", text="Select Elements", icon="RESTRICT_SELECT_OFF")
+ row.operator("bim.dismiss_pending_array_repair", text="", icon="CANCEL")
+
gprops = tool.Geometry.get_geometry_props()
# Check that Blender mode and IFC Mode do match.
if context.mode == "OBJECT" and gprops.mode in ("OBJECT", "ITEM"):
diff --git a/src/bonsai/bonsai/core/tool.py b/src/bonsai/bonsai/core/tool.py
index 91003174ad..3aa8d7408f 100644
--- a/src/bonsai/bonsai/core/tool.py
+++ b/src/bonsai/bonsai/core/tool.py
@@ -1208,6 +1208,7 @@ class Type:
def get_representation_context(cls, representation): pass
def get_type_occurrences(cls, element_type): pass
def has_material_usage(cls, element): pass
+ def is_relating_type_compatible(cls, occurrence, relating_type): pass
def record_material_usage_attributes(cls, element): pass
def restore_material_usage_attributes(cls, element, usage_attributes): pass
def run_geometry_add_representation(cls, obj=None, context=None, ifc_representation_class=None, profile_set_usage=None): pass
diff --git a/src/bonsai/bonsai/tool/model.py b/src/bonsai/bonsai/tool/model.py
index 137bed3371..0028548e76 100644
--- a/src/bonsai/bonsai/tool/model.py
+++ b/src/bonsai/bonsai/tool/model.py
@@ -3058,6 +3058,9 @@ class Model(bonsai.core.tool.Model):
regenerate_fillet_corner_wall(element, obj)
return
rep = ifcopenshell.api.geometry.regenerate_wall_representation(tool.Ifc.get(), element)
+ if rep is None:
+ # Wall has no IfcMaterialLayerSet — layer-set rebuild not applicable.
+ return
bonsai.core.geometry.switch_representation(
tool.Ifc,
tool.Geometry,
diff --git a/src/bonsai/bonsai/tool/type.py b/src/bonsai/bonsai/tool/type.py
index 882c4b4618..f2617ad060 100644
--- a/src/bonsai/bonsai/tool/type.py
+++ b/src/bonsai/bonsai/tool/type.py
@@ -24,6 +24,7 @@ import bpy
import ifcopenshell
import ifcopenshell.util.element
import ifcopenshell.util.representation
+import ifcopenshell.util.type
import bonsai.core.geometry
import bonsai.core.tool
@@ -96,6 +97,18 @@ class Type(bonsai.core.tool.Type):
def get_type_occurrences(cls, element_type: ifcopenshell.entity_instance) -> list[ifcopenshell.entity_instance]:
return ifcopenshell.util.element.get_types(element_type)
+ @classmethod
+ def is_relating_type_compatible(
+ cls,
+ occurrence: ifcopenshell.entity_instance,
+ relating_type: ifcopenshell.entity_instance,
+ ) -> bool:
+ # IFC's EXPRESS schema has no WHERE rule pairing IfcRelDefinesByType's
+ # RelatingType / RelatedObjects classes; the one-to-one class pairing
+ # is a buildingSMART implementer agreement, not file-validation.
+ schema = occurrence.file.schema
+ return relating_type.is_a() in ifcopenshell.util.type.get_applicable_types(occurrence.is_a(), schema=schema)
+
@classmethod
def has_material_usage(cls, element: ifcopenshell.entity_instance) -> bool:
material = ifcopenshell.util.element.get_material(element)
diff --git a/src/bonsai/test/bim/module/model/test_regenerate_wall.py b/src/bonsai/test/bim/module/model/test_regenerate_wall.py
index 68ca4bcdf9..feaf858186 100644
--- a/src/bonsai/test/bim/module/model/test_regenerate_wall.py
+++ b/src/bonsai/test/bim/module/model/test_regenerate_wall.py
@@ -83,3 +83,25 @@ def test_regenerate_wall_noops_when_obj_has_no_ifc_entity():
recreate.assert_not_called()
has_top.assert_not_called()
regen.assert_not_called()
+
+
+def test_recreate_wall_noops_when_wall_has_no_layer_set():
+ """``recreate_wall`` must short-circuit when
+ ``regenerate_wall_representation`` returns ``None``. That API only knows
+ how to rebuild ``IfcMaterialLayerSet`` walls; for walls without one it
+ returns ``None``, and feeding ``None`` to ``switch_representation``
+ crashes deep inside ``resolve_representation`` on ``.Items``."""
+ element = Mock()
+ obj = Mock()
+
+ with patch("bonsai.tool.model.tool.Parametric.is_fillet_corner_wall", return_value=False), patch(
+ "bonsai.tool.model.tool.Ifc.get", return_value=Mock()
+ ), patch("bonsai.tool.model.ifcopenshell.api.geometry.regenerate_wall_representation", return_value=None), patch(
+ "bonsai.tool.model.bonsai.core.geometry.switch_representation"
+ ) as switch, patch.object(
+ tool.Geometry, "record_object_materials"
+ ) as record:
+ tool.Model.recreate_wall(element, obj)
+
+ switch.assert_not_called()
+ record.assert_not_called()
diff --git a/src/bonsai/test/bim/module/type/__init__.py b/src/bonsai/test/bim/module/type/__init__.py
new file mode 100644
index 0000000000..e69de29bb2
diff --git a/src/bonsai/test/bim/module/type/test_assign_type_forward_compat.py b/src/bonsai/test/bim/module/type/test_assign_type_forward_compat.py
new file mode 100644
index 0000000000..106c517565
--- /dev/null
+++ b/src/bonsai/test/bim/module/type/test_assign_type_forward_compat.py
@@ -0,0 +1,104 @@
+# Bonsai - OpenBIM Blender Add-on
+# Copyright (C) 2026
+#
+# This file is part of Bonsai.
+#
+# Bonsai is free software: you can redistribute it and/or modify
+# it under the terms of the GNU General Public License as published by
+# the Free Software Foundation, either version 3 of the License, or
+# (at your option) any later version.
+#
+# Bonsai is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+# GNU General Public License for more details.
+#
+# You should have received a copy of the GNU General Public License
+# along with Bonsai. If not, see .
+#
+# This file was generated with the assistance of an AI coding tool.
+
+"""Forward-compat AST contracts for the class-mismatched-type-assignment guard.
+
+Two structural invariants that no behavioural test can pin on its own:
+
+1. ``ifcopenshell.api.type.assign_type`` MUST reference
+ ``ifcopenshell.util.type.get_applicable_entities`` (or
+ ``get_applicable_types``) — the schema-aware applicability lookup that
+ produces the canonical class-pairing whitelist. A drift here means the
+ API stops rejecting class-mismatched pairs.
+
+2. ``bonsai.bim.module.type.operator`` MUST reference
+ ``tool.Type.is_relating_type_compatible`` — the single source of truth
+ for partition / WARNING / CANCELLED behaviour in the Bonsai operator
+ layer. A drift here re-opens the fan-out hole that silently writes
+ schema-corrupt typings into the selection when one (active) object's
+ class drove the picker but other selected objects don't match.
+"""
+
+import ast
+from pathlib import Path
+
+import pytest
+
+pytestmark = pytest.mark.type
+
+
+BONSAI_ROOT = Path(__file__).resolve().parents[4] / "bonsai"
+IFCOPENSHELL_API_ASSIGN_TYPE = (
+ Path(__file__).resolve().parents[5] / "ifcopenshell-python" / "ifcopenshell" / "api" / "type" / "assign_type.py"
+)
+BONSAI_TYPE_OPERATOR = BONSAI_ROOT / "bim" / "module" / "type" / "operator.py"
+
+
+def _attribute_chain(node: ast.AST) -> str:
+ """Render an ``ast.Attribute``/``ast.Name`` chain as a dotted string,
+ e.g. ``tool.Type.is_relating_type_compatible``. Returns ``""`` if the
+ chain bottoms out on something other than a Name (e.g. a subscript)."""
+ parts: list[str] = []
+ while isinstance(node, ast.Attribute):
+ parts.append(node.attr)
+ node = node.value
+ if isinstance(node, ast.Name):
+ parts.append(node.id)
+ return ".".join(reversed(parts))
+ return ""
+
+
+def _all_attribute_chains(tree: ast.Module) -> set[str]:
+ chains: set[str] = set()
+ for node in ast.walk(tree):
+ if isinstance(node, ast.Attribute):
+ chain = _attribute_chain(node)
+ if chain:
+ chains.add(chain)
+ return chains
+
+
+def test_api_assign_type_calls_applicability_lookup() -> None:
+ """Pin Layer B: ``ifcopenshell.api.type.assign_type`` references
+ ``ifcopenshell.util.type.get_applicable_entities`` (the source of truth
+ for which occurrence classes a given type class may type)."""
+ tree = ast.parse(IFCOPENSHELL_API_ASSIGN_TYPE.read_text(encoding="utf-8"))
+ chains = _all_attribute_chains(tree)
+ sentinel = "ifcopenshell.util.type.get_applicable_entities"
+ assert sentinel in chains, (
+ f"{IFCOPENSHELL_API_ASSIGN_TYPE.name} no longer references {sentinel}. "
+ "The API-layer guard against class-mismatched type assignment is gone."
+ )
+
+
+def test_bonsai_type_operator_module_references_compatibility_helper() -> None:
+ """Pin Layer C: the Bonsai type operator module references
+ ``tool.Type.is_relating_type_compatible``. Every operator in this file
+ that fans assign_type calls across a multi-selection must filter
+ through this helper to avoid writing mismatched typings on objects the
+ panel picker didn't validate."""
+ tree = ast.parse(BONSAI_TYPE_OPERATOR.read_text(encoding="utf-8"))
+ chains = _all_attribute_chains(tree)
+ sentinel = "tool.Type.is_relating_type_compatible"
+ assert sentinel in chains, (
+ f"{BONSAI_TYPE_OPERATOR.name} no longer references {sentinel}. "
+ "Operator-layer partition that prevents schema-illegal type "
+ "assignment across multi-selection has been removed."
+ )
diff --git a/src/bonsai/test/bim/module/type/test_assign_type_partition.py b/src/bonsai/test/bim/module/type/test_assign_type_partition.py
new file mode 100644
index 0000000000..231f215135
--- /dev/null
+++ b/src/bonsai/test/bim/module/type/test_assign_type_partition.py
@@ -0,0 +1,176 @@
+# Bonsai - OpenBIM Blender Add-on
+# Copyright (C) 2026
+#
+# This file is part of Bonsai.
+#
+# Bonsai is free software: you can redistribute it and/or modify
+# it under the terms of the GNU General Public License as published by
+# the Free Software Foundation, either version 3 of the License, or
+# (at your option) any later version.
+#
+# Bonsai is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+# GNU Lesser General Public License for more details.
+#
+# You should have received a copy of the GNU General Public License
+# along with Bonsai. If not, see .
+#
+# This file was generated with the assistance of an AI coding tool.
+
+"""Contract test for BIM_OT_assign_type's per-object class-compatibility
+partition.
+
+When the user multi-selects mixed classes (e.g. a wall + a door), the panel
+picker filters the class dropdown by the active object's class only.
+Historically the operator then fanned out across the whole selection without
+re-checking each occurrence, producing schema-corrupt IFC files (IfcDoor
+typed by IfcWallType). The partition added in this change must:
+
+1. Assign the type only to compatible occurrences.
+2. Surface skipped classes through ``self.report({'WARNING'}, ...)``.
+3. ``return {'CANCELLED'}`` and emit an ERROR when nothing in the selection
+ is compatible — no mutation must reach ``core.assign_type``.
+"""
+
+from unittest import mock
+
+import pytest
+
+pytestmark = pytest.mark.type
+
+
+@pytest.fixture(autouse=True)
+def _require_real_bpy():
+ import types as _types
+
+ import bpy
+
+ if not isinstance(bpy, _types.ModuleType) or hasattr(bpy, "_mock_name"):
+ pytest.skip("requires real Blender (bpy is mocked or absent)")
+
+
+@pytest.fixture
+def fresh_ifc():
+ import ifcopenshell
+
+ from bonsai.bim.ifc import IfcStore
+
+ previous = IfcStore.file
+ IfcStore.file = ifcopenshell.file(schema="IFC4")
+ try:
+ yield IfcStore.file
+ finally:
+ IfcStore.file = previous
+
+
+def _make_object(name, element):
+ """Build a real bpy.types.Object linked to an IFC entity via
+ tool.Ifc.link, so tool.Ifc.get_entity(obj) resolves correctly."""
+ import bpy
+
+ import bonsai.tool as tool
+
+ obj = bpy.data.objects.new(name, None)
+ tool.Ifc.link(element, obj)
+ return obj
+
+
+def _execute_assign(op, context):
+ """Drive ``AssignType._execute`` directly. Bypasses the framework's
+ transaction wrapping so a unit test can observe the partition without
+ setting up the full Blender harness."""
+ return op._execute(context)
+
+
+@pytest.fixture
+def neutralised_side_effects():
+ """Patch the helpers ``AssignType._execute`` calls outside the partition
+ logic (addon prefs, drawing context lookup, drawing target-view branch),
+ so the test asserts only the partition / report / return-code contract."""
+ with mock.patch("bonsai.bim.module.type.operator.tool.Blender.get_addon_preferences") as prefs:
+ prefs.return_value = mock.Mock(occurrence_name_style="OCCURRENCE")
+ yield
+
+
+def _build_context_with_no_active_drawing():
+ """Return a Mock ``context`` whose ``scene.DocProperties.active_drawing_id``
+ is 0, skipping the drawing-target-view block in ``_execute``."""
+ context = mock.Mock()
+ context.scene.DocProperties.active_drawing_id = 0
+ return context
+
+
+def _fake_operator_with_report():
+ """Build a Mock that satisfies the attribute reads ``AssignType._execute``
+ makes on ``self`` (``relating_type``, ``related_object``, ``report``)."""
+ op = mock.MagicMock()
+ op.relating_type = 0
+ op.related_object = ""
+ op.report = mock.Mock()
+ return op
+
+
+def test_mixed_selection_assigns_only_compatible_objects(fresh_ifc, neutralised_side_effects):
+ """Wall + door selected, IfcWallType picked: wall gets typed, door is
+ skipped with a WARNING, and the operator returns success.
+
+ ``core.assign_type`` is mocked: it would otherwise run the
+ representation-switch and material plumbing on stub Blender objects.
+ The contract under test is the partition / report logic, not the
+ downstream representation pipeline."""
+ import ifcopenshell.api.root
+
+ from bonsai.bim.module.type.operator import AssignType
+
+ wall_elem = ifcopenshell.api.root.create_entity(fresh_ifc, ifc_class="IfcWall")
+ door_elem = ifcopenshell.api.root.create_entity(fresh_ifc, ifc_class="IfcDoor")
+ wall_type = ifcopenshell.api.root.create_entity(fresh_ifc, ifc_class="IfcWallType")
+
+ wall_obj = _make_object("Wall", wall_elem)
+ door_obj = _make_object("Door", door_elem)
+
+ op = _fake_operator_with_report()
+ op.relating_type = wall_type.id()
+
+ with mock.patch(
+ "bonsai.bim.module.type.operator.tool.Blender.get_selected_objects", return_value=[wall_obj, door_obj]
+ ), mock.patch("bonsai.bim.module.type.operator.core.assign_type") as mock_assign:
+ result = AssignType._execute(op, _build_context_with_no_active_drawing())
+
+ assert result != {"CANCELLED"}, "operator must succeed when at least one object is compatible"
+ typed_elements = {call.kwargs["element"] for call in mock_assign.call_args_list}
+ assert typed_elements == {
+ wall_elem
+ }, f"only the compatible wall element should reach core.assign_type, got {typed_elements}"
+
+ warning_calls = [c for c in op.report.call_args_list if c.args[0] == {"WARNING"}]
+ assert warning_calls, "skipped occurrence class must surface as a WARNING"
+ assert any("IfcDoor" in c.args[1] for c in warning_calls)
+
+
+def test_all_incompatible_selection_returns_cancelled_without_mutation(fresh_ifc, neutralised_side_effects):
+ """Door alone selected, IfcWallType picked: nothing to assign. Operator
+ must return CANCELLED, emit an ERROR, and never call core.assign_type."""
+ import ifcopenshell.api.root
+ import ifcopenshell.util.element
+
+ from bonsai.bim.module.type.operator import AssignType
+
+ door_elem = ifcopenshell.api.root.create_entity(fresh_ifc, ifc_class="IfcDoor")
+ wall_type = ifcopenshell.api.root.create_entity(fresh_ifc, ifc_class="IfcWallType")
+ door_obj = _make_object("Door", door_elem)
+
+ op = _fake_operator_with_report()
+ op.relating_type = wall_type.id()
+
+ with mock.patch(
+ "bonsai.bim.module.type.operator.tool.Blender.get_selected_objects", return_value=[door_obj]
+ ), mock.patch("bonsai.bim.module.type.operator.core.assign_type") as mock_assign:
+ result = AssignType._execute(op, _build_context_with_no_active_drawing())
+
+ assert result == {"CANCELLED"}
+ assert mock_assign.call_count == 0
+ error_calls = [c for c in op.report.call_args_list if c.args[0] == {"ERROR"}]
+ assert error_calls, "all-incompatible selection must surface as an ERROR"
+ assert ifcopenshell.util.element.get_type(door_elem) is None
diff --git a/src/bonsai/test/tool/test_type.py b/src/bonsai/test/tool/test_type.py
index 0d91b1f4f0..7ff096e0e8 100644
--- a/src/bonsai/test/tool/test_type.py
+++ b/src/bonsai/test/tool/test_type.py
@@ -172,6 +172,48 @@ class TestHasMaterialUsage(NewFile):
assert subject.has_material_usage(element) is True
+class TestIsRelatingTypeCompatible(NewFile):
+ def test_matched_pair_ifc4(self):
+ ifc = ifcopenshell.file()
+ door = ifcopenshell.api.root.create_entity(ifc, ifc_class="IfcDoor")
+ door_type = ifcopenshell.api.root.create_entity(ifc, ifc_class="IfcDoorType")
+ assert subject.is_relating_type_compatible(door, door_type) is True
+
+ def test_mismatched_pair_ifc4(self):
+ ifc = ifcopenshell.file()
+ door = ifcopenshell.api.root.create_entity(ifc, ifc_class="IfcDoor")
+ wall_type = ifcopenshell.api.root.create_entity(ifc, ifc_class="IfcWallType")
+ assert subject.is_relating_type_compatible(door, wall_type) is False
+
+ def test_legacy_style_pairing_allowed_in_ifc4(self):
+ ifc = ifcopenshell.file()
+ door = ifcopenshell.api.root.create_entity(ifc, ifc_class="IfcDoor")
+ door_style = ifcopenshell.api.root.create_entity(ifc, ifc_class="IfcDoorStyle")
+ assert subject.is_relating_type_compatible(door, door_style) is True
+
+ def test_legacy_style_pairing_refused_in_ifc4x3(self):
+ ifc = ifcopenshell.file(schema="IFC4X3")
+ door = ifcopenshell.api.root.create_entity(ifc, ifc_class="IfcDoor")
+ try:
+ door_style = ifcopenshell.api.root.create_entity(ifc, ifc_class="IfcDoorStyle")
+ except Exception:
+ # IfcDoorStyle was removed in IFC4X3 — exclusion holds trivially.
+ return
+ assert subject.is_relating_type_compatible(door, door_style) is False
+
+ def test_untypable_occurrence_returns_false(self):
+ ifc = ifcopenshell.file()
+ opening = ifcopenshell.api.root.create_entity(ifc, ifc_class="IfcOpeningElement")
+ any_type = ifcopenshell.api.root.create_entity(ifc, ifc_class="IfcDoorType")
+ assert subject.is_relating_type_compatible(opening, any_type) is False
+
+ def test_proxy_type_pairing(self):
+ ifc = ifcopenshell.file()
+ proxy = ifcopenshell.api.root.create_entity(ifc, ifc_class="IfcBuildingElementProxy")
+ proxy_type = ifcopenshell.api.root.create_entity(ifc, ifc_class="IfcBuildingElementProxyType")
+ assert subject.is_relating_type_compatible(proxy, proxy_type) is True
+
+
class TestRunGeometryAddRepresentation(NewFile):
def test_nothing(self):
pass
diff --git a/src/ifcgeom/ConversionResult.h b/src/ifcgeom/ConversionResult.h
index 7ce7eb46c7..773c296c5f 100644
--- a/src/ifcgeom/ConversionResult.h
+++ b/src/ifcgeom/ConversionResult.h
@@ -95,7 +95,8 @@ namespace IfcGeom {
virtual std::shared_ptr divide(const NumberConcept& other) const = 0;
virtual std::shared_ptr negate() const = 0;
virtual std::shared_ptr from_double(double value) const = 0;
- virtual bool equals(const NumberConcept& other) const = 0;
+ virtual std::shared_ptr from_int(int value) const = 0;
+ virtual bool equals(const NumberConcept& other) const = 0;
virtual bool less_than(const NumberConcept& other) const = 0;
virtual const std::type_info& type() const = 0;
virtual const void* value_ptr() const = 0;
@@ -165,6 +166,10 @@ namespace IfcGeom {
return std::make_shared(T(v));
}
+ virtual std::shared_ptr from_int(int v) const {
+ return std::make_shared(T(v));
+ }
+
virtual bool equals(const NumberConcept& other) const {
return value == as_same(other).value;
}
@@ -255,10 +260,19 @@ namespace IfcGeom {
return OpaqueNumber(data().negate());
}
+ OpaqueNumber abs() const {
+ auto zero = data().from_int(0);
+ return OpaqueNumber(data().less_than(*zero) ? data().negate() : *this);
+ }
+
OpaqueNumber same_type(double value) const {
return OpaqueNumber(data().from_double(value));
}
+ OpaqueNumber same_type(int value) const {
+ return OpaqueNumber(data().from_int(value));
+ }
+
bool equals(const OpaqueNumber& other) const {
return data().equals(other.data());
}
@@ -336,7 +350,7 @@ namespace IfcGeom {
}
}
- std::vector to_doubles() const {
+ std::vector to_double() const {
std::vector result;
result.reserve(N);
for (const auto& value : values_) {
@@ -436,6 +450,8 @@ namespace IfcGeom {
class IFC_GEOM_API ConversionResultShape {
public:
+ virtual std::string type() const = 0;
+
virtual void Triangulate(ifcopenshell::geometry::Settings settings, const ifcopenshell::geometry::taxonomy::matrix4& place, Representation::Triangulation* t, int item_id, int surface_style_id, Logger& logger = Logger::Root()) const = 0;
IfcGeom::Representation::Triangulation* Triangulate(const ifcopenshell::geometry::Settings& settings, Logger& logger = Logger::Root()) const;
virtual void Serialize(const ifcopenshell::geometry::taxonomy::matrix4& place, std::string&) const = 0;
diff --git a/src/ifcgeom/kernels/cgal/CgalConversionResult.h b/src/ifcgeom/kernels/cgal/CgalConversionResult.h
index f54854ac30..22cb9c2d44 100644
--- a/src/ifcgeom/kernels/cgal/CgalConversionResult.h
+++ b/src/ifcgeom/kernels/cgal/CgalConversionResult.h
@@ -146,6 +146,10 @@ namespace ifcopenshell { namespace geometry {
return std::make_shared(CGAL::Epeck::FT(v));
}
+ virtual std::shared_ptr from_int(int v) const {
+ return std::make_shared(CGAL::Epeck::FT(v));
+ }
+
virtual bool equals(const NumberConcept& other) const {
return value == as_same(other).value;
}
@@ -182,7 +186,13 @@ namespace ifcopenshell { namespace geometry {
#ifndef IFOPSH_SIMPLE_KERNEL
mutable boost::optional> nef_;
#endif
- public:
+ public:
+#ifdef IFOPSH_SIMPLE_KERNEL
+ std::string type() const override { return "CgalSimpleShape"; }
+#else
+ std::string type() const override { return "CgalShape"; }
+#endif
+
CgalShape(const cgal_shape_t& shape, bool convex = false, Logger& logger = Logger::Root());
CgalShape(const cgal_point_t& point, bool convex = false);
CgalShape(const cgal_wire_t& wire, bool convex = false);
@@ -287,6 +297,8 @@ namespace ifcopenshell { namespace geometry {
std::list> planes_;
public:
+ std::string type() const override { return "CgalShapeHalfSpaceDecomposition"; }
+
CgalShapeHalfSpaceDecomposition(const CGAL::Nef_polyhedron_3& shape, bool is_convex) {
if (is_convex) {
shape_ = std::move(build_halfspace_tree_is_decomposed(shape, planes_));
diff --git a/src/ifcgeom/kernels/opencascade/OpenCascadeConversionResult.h b/src/ifcgeom/kernels/opencascade/OpenCascadeConversionResult.h
index 70b612feba..5c25addc3b 100644
--- a/src/ifcgeom/kernels/opencascade/OpenCascadeConversionResult.h
+++ b/src/ifcgeom/kernels/opencascade/OpenCascadeConversionResult.h
@@ -45,6 +45,8 @@ namespace ifcopenshell {
class IFC_GEOMLIBRARY_API OpenCascadeShape : public IfcGeom::ConversionResultShape {
public:
+ std::string type() const override { return "OpenCascadeShape"; }
+
OpenCascadeShape(const TopoDS_Shape& shape)
: shape_(shape) {}
OpenCascadeShape(TopoDS_Shape&& shape)
diff --git a/src/ifcopenshell-python/ifcopenshell/api/geometry/regenerate_wall_representation.py b/src/ifcopenshell-python/ifcopenshell/api/geometry/regenerate_wall_representation.py
index e59c6e1efa..080d7da99f 100644
--- a/src/ifcopenshell-python/ifcopenshell/api/geometry/regenerate_wall_representation.py
+++ b/src/ifcopenshell-python/ifcopenshell/api/geometry/regenerate_wall_representation.py
@@ -44,7 +44,7 @@ def regenerate_wall_representation(
length: float = 1.0,
height: float = 1.0,
angle: Optional[float] = None,
-) -> ifcopenshell.entity_instance:
+) -> Optional[ifcopenshell.entity_instance]:
"""
Regenerate the body representation of a wall taking into account connections.
@@ -94,7 +94,10 @@ def regenerate_wall_representation(
default height in SI units.
:param angle: If the wall doesn't already have a slope, this is the default
angle in radians. Left as none or 0 defines no slope.
- :return: The newly generated body IfcShapeRepresentation
+ :return: The newly generated body IfcShapeRepresentation, or ``None`` if
+ the wall has no ``IfcMaterialLayerSet`` (the layer-set rebuild is the
+ only mode this function knows; without layers there is nothing to
+ regenerate and callers should leave the existing representation alone).
"""
return Regenerator(file).regenerate(wall, length=length, height=height, angle=angle)
diff --git a/src/ifcopenshell-python/ifcopenshell/api/type/assign_type.py b/src/ifcopenshell-python/ifcopenshell/api/type/assign_type.py
index 2c7abcd169..9dd01ee97e 100644
--- a/src/ifcopenshell-python/ifcopenshell/api/type/assign_type.py
+++ b/src/ifcopenshell-python/ifcopenshell/api/type/assign_type.py
@@ -24,6 +24,7 @@ import ifcopenshell.api.owner
import ifcopenshell.api.type
import ifcopenshell.guid
import ifcopenshell.util.element
+import ifcopenshell.util.type
def assign_type(
@@ -189,6 +190,20 @@ class Usecase:
if not related_objects:
return
+ # The EXPRESS schema has no WHERE rule pairing RelatingType /
+ # RelatedObjects classes; the canonical class pairing per schema
+ # is a buildingSMART implementer agreement, enforced here.
+ allowed_occurrences = ifcopenshell.util.type.get_applicable_entities(
+ relating_type.is_a(), schema=self.file.schema
+ )
+ mismatched_classes = sorted({o.is_a() for o in related_objects if o.is_a() not in allowed_occurrences})
+ if mismatched_classes:
+ raise TypeError(
+ f"{relating_type.is_a()} cannot type {', '.join(mismatched_classes)} "
+ f"in schema {self.file.schema} (allowed occurrence classes: "
+ f"{allowed_occurrences or ''})"
+ )
+
ifc2x3 = self.file.schema == "IFC2X3"
related_objects_set = set(related_objects)
if ifc2x3:
diff --git a/src/ifcopenshell-python/test/api/type/test_assign_type.py b/src/ifcopenshell-python/test/api/type/test_assign_type.py
index 009d4efedf..1125fd3888 100644
--- a/src/ifcopenshell-python/test/api/type/test_assign_type.py
+++ b/src/ifcopenshell-python/test/api/type/test_assign_type.py
@@ -183,6 +183,39 @@ class TestAssignType(test.bootstrap.IFC4):
assert element.PredefinedType == "USERDEFINED"
assert element.ObjectType == "Test"
+ def test_class_mismatched_pair_raises(self):
+ door = ifcopenshell.api.root.create_entity(self.file, ifc_class="IfcDoor")
+ wall_type = ifcopenshell.api.root.create_entity(self.file, ifc_class="IfcWallType")
+ with pytest.raises(TypeError, match=r"IfcWallType cannot type IfcDoor"):
+ ifcopenshell.api.type.assign_type(self.file, related_objects=[door], relating_type=wall_type)
+ assert ifcopenshell.util.element.get_type(door) is None
+
+ def test_class_mismatched_pair_does_not_mutate(self):
+ door = ifcopenshell.api.root.create_entity(self.file, ifc_class="IfcDoor")
+ wall_type = ifcopenshell.api.root.create_entity(self.file, ifc_class="IfcWallType")
+ rels_before = self.file.by_type("IfcRelDefinesByType")
+ with pytest.raises(TypeError):
+ ifcopenshell.api.type.assign_type(self.file, related_objects=[door], relating_type=wall_type)
+ rels_after = self.file.by_type("IfcRelDefinesByType")
+ assert rels_after == rels_before
+
+ def test_partial_mismatch_in_selection_rejects_whole_call(self):
+ door = ifcopenshell.api.root.create_entity(self.file, ifc_class="IfcDoor")
+ wall = ifcopenshell.api.root.create_entity(self.file, ifc_class="IfcWall")
+ wall_type = ifcopenshell.api.root.create_entity(self.file, ifc_class="IfcWallType")
+ with pytest.raises(TypeError):
+ ifcopenshell.api.type.assign_type(self.file, related_objects=[door, wall], relating_type=wall_type)
+ # The good occurrence must NOT have been typed — partial mutation is the
+ # bug class this guard exists to prevent.
+ assert ifcopenshell.util.element.get_type(wall) is None
+ assert ifcopenshell.util.element.get_type(door) is None
+
+ def test_untypable_occurrence_rejected(self):
+ opening = ifcopenshell.api.root.create_entity(self.file, ifc_class="IfcOpeningElement")
+ any_type = ifcopenshell.api.root.create_entity(self.file, ifc_class="IfcWallType")
+ with pytest.raises(TypeError):
+ ifcopenshell.api.type.assign_type(self.file, related_objects=[opening], relating_type=any_type)
+
class TestAssignTypeIFC2X3(test.bootstrap.IFC2X3, TestAssignType):
pass
diff --git a/src/ifcparse/IfcSpfHeader.cpp b/src/ifcparse/IfcSpfHeader.cpp
index efb30c90a2..f28c2ea2df 100644
--- a/src/ifcparse/IfcSpfHeader.cpp
+++ b/src/ifcparse/IfcSpfHeader.cpp
@@ -181,6 +181,33 @@ bool IfcSpfHeader::tryRead() {
}
}
+void IfcParse::IfcSpfHeader::assign(const IfcSpfHeader& other) {
+ if (this != &other) {
+ auto copy_inst = [](IfcUtil::IfcBaseEntity* new_entity, IfcUtil::IfcBaseEntity* entity, const IfcParse::entity* decl, IfcParse::impl::in_memory_file_storage* own_storage, IfcParse::impl::in_memory_file_storage* other_storage) {
+ if (!new_entity || !entity) {
+ return;
+ }
+ for (size_t i = 0; i < decl->attribute_count(); ++i) {
+ entity->data().apply_visitor(other_storage, decl, entity->identity(), [i, decl, new_entity, own_storage](const auto& v) {
+ using U = std::decay_t;
+ if constexpr (std::is_same_v) {
+ } else if constexpr (std::is_same_v) {
+ } else if constexpr (std::is_same_v) {
+ } else if constexpr (std::is_same_v) {
+ } else if constexpr (std::is_same_v) {
+ } else {
+ new_entity->set_attribute_value(i, v);
+ }
+ }, i);
+ }
+ };
+
+ copy_inst(file_description_, other.file_description_, &Header_section_schema::file_description::Class(), storage_, other.storage_);
+ copy_inst(file_name_, other.file_name_, &Header_section_schema::file_name::Class(), storage_, other.storage_);
+ copy_inst(file_schema_, other.file_schema_, &Header_section_schema::file_schema::Class(), storage_, other.storage_);
+ }
+}
+
void IfcSpfHeader::write(std::ostream& out) const {
out << ISO_10303_21 << ";"
<< "\n";
diff --git a/src/ifcparse/IfcSpfHeader.h b/src/ifcparse/IfcSpfHeader.h
index fb6438745b..040d83de34 100644
--- a/src/ifcparse/IfcSpfHeader.h
+++ b/src/ifcparse/IfcSpfHeader.h
@@ -60,6 +60,8 @@ class IFC_PARSE_API IfcSpfHeader {
void read();
bool tryRead();
+ void assign(const IfcSpfHeader& other);
+
void write(std::ostream& out) const;
const Header_section_schema::file_description* file_description() const;
diff --git a/src/ifcwrap/IfcGeomWrapper.i b/src/ifcwrap/IfcGeomWrapper.i
index 65655cd247..81c2a53795 100644
--- a/src/ifcwrap/IfcGeomWrapper.i
+++ b/src/ifcwrap/IfcGeomWrapper.i
@@ -1170,6 +1170,20 @@ ifcopenshell::geometry::taxonomy::item::ptr try_upcast(PyObject* obj0, swig_type
%template(svg_loop) std::vector>;
%template(svg_loops) std::vector>>;
+%extend IfcGeom::OpaqueCoordinate {
+ %pythoncode %{
+ __len__ = size
+ def __iter__(self):
+ yield from (self.get(i) for i in range(len(self)))
+ %}
+}
+
+%extend IfcGeom::OpaqueNumber {
+ %pythoncode %{
+ __abs__ = abs
+ %}
+}
+
%template(OpaqueCoordinate_3) IfcGeom::OpaqueCoordinate<3>;
%template(OpaqueCoordinate_4) IfcGeom::OpaqueCoordinate<4>;