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>;