mirror of
https://github.com/IfcOpenShell/IfcOpenShell.git
synced 2026-08-10 01:41:57 +00:00
Bonsai: refuse class-mismatched type assignment
Schema-illegal IfcDoor->IfcWallType pairings parse cleanly but propagate into operators that fan out by type and eventually crash the wrapper. Block the pairing at its source: API guard in ifcopenshell.api.type. assign_type, per-object partition in BIM_OT_assign_type + DuplicateType, new tool.Type.is_relating_type_compatible helper, AST forward-compat guard. Files in the wild are still loaded unchanged. Generated with the assistance of an AI coding tool.
This commit is contained in:
@@ -65,10 +65,28 @@ class AssignType(bpy.types.Operator, tool.Ifc.Operator):
|
|||||||
if active_drawing:
|
if active_drawing:
|
||||||
active_target_view = tool.Drawing.get_drawing_target_view(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:
|
for obj in related_objects:
|
||||||
element = tool.Ifc.get_entity(obj)
|
element = tool.Ifc.get_entity(obj)
|
||||||
if not element or not element.is_a("IfcObject"):
|
if not element or not element.is_a("IfcObject"):
|
||||||
continue
|
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)
|
core.assign_type(tool.Ifc, tool.Model, tool.Type, element=element, type=relating_type)
|
||||||
|
|
||||||
# Switch to the drawing's target view if available
|
# 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:
|
if self.assign_selected_objects:
|
||||||
selected_objects = tool.Blender.get_selected_objects()
|
selected_objects = tool.Blender.get_selected_objects()
|
||||||
prefs = tool.Blender.get_addon_preferences()
|
prefs = tool.Blender.get_addon_preferences()
|
||||||
|
skipped_classes: set[str] = set()
|
||||||
for selected_obj in selected_objects:
|
for selected_obj in selected_objects:
|
||||||
selected_element = tool.Ifc.get_entity(selected_obj)
|
selected_element = tool.Ifc.get_entity(selected_obj)
|
||||||
if selected_element and selected_element.is_a("IfcObject"):
|
if not selected_element or not selected_element.is_a("IfcObject"):
|
||||||
core.assign_type(tool.Ifc, tool.Model, tool.Type, element=selected_element, type=new)
|
continue
|
||||||
if prefs.occurrence_name_style == "TYPE":
|
if not tool.Type.is_relating_type_compatible(selected_element, new):
|
||||||
selected_obj.name = tool.Model.generate_occurrence_name(new, selected_element.is_a())
|
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:
|
if obj in context.selectable_objects:
|
||||||
tool.Blender.select_and_activate_single_object(context, new_obj)
|
tool.Blender.select_and_activate_single_object(context, new_obj)
|
||||||
|
|||||||
@@ -1208,6 +1208,7 @@ class Type:
|
|||||||
def get_representation_context(cls, representation): pass
|
def get_representation_context(cls, representation): pass
|
||||||
def get_type_occurrences(cls, element_type): pass
|
def get_type_occurrences(cls, element_type): pass
|
||||||
def has_material_usage(cls, element): 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 record_material_usage_attributes(cls, element): pass
|
||||||
def restore_material_usage_attributes(cls, element, usage_attributes): 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
|
def run_geometry_add_representation(cls, obj=None, context=None, ifc_representation_class=None, profile_set_usage=None): pass
|
||||||
|
|||||||
@@ -24,6 +24,7 @@ import bpy
|
|||||||
import ifcopenshell
|
import ifcopenshell
|
||||||
import ifcopenshell.util.element
|
import ifcopenshell.util.element
|
||||||
import ifcopenshell.util.representation
|
import ifcopenshell.util.representation
|
||||||
|
import ifcopenshell.util.type
|
||||||
|
|
||||||
import bonsai.core.geometry
|
import bonsai.core.geometry
|
||||||
import bonsai.core.tool
|
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]:
|
def get_type_occurrences(cls, element_type: ifcopenshell.entity_instance) -> list[ifcopenshell.entity_instance]:
|
||||||
return ifcopenshell.util.element.get_types(element_type)
|
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
|
@classmethod
|
||||||
def has_material_usage(cls, element: ifcopenshell.entity_instance) -> bool:
|
def has_material_usage(cls, element: ifcopenshell.entity_instance) -> bool:
|
||||||
material = ifcopenshell.util.element.get_material(element)
|
material = ifcopenshell.util.element.get_material(element)
|
||||||
|
|||||||
@@ -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 <http://www.gnu.org/licenses/>.
|
||||||
|
#
|
||||||
|
# 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."
|
||||||
|
)
|
||||||
@@ -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 <http://www.gnu.org/licenses/>.
|
||||||
|
#
|
||||||
|
# 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
|
||||||
@@ -172,6 +172,48 @@ class TestHasMaterialUsage(NewFile):
|
|||||||
assert subject.has_material_usage(element) is True
|
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):
|
class TestRunGeometryAddRepresentation(NewFile):
|
||||||
def test_nothing(self):
|
def test_nothing(self):
|
||||||
pass
|
pass
|
||||||
|
|||||||
@@ -24,6 +24,7 @@ import ifcopenshell.api.owner
|
|||||||
import ifcopenshell.api.type
|
import ifcopenshell.api.type
|
||||||
import ifcopenshell.guid
|
import ifcopenshell.guid
|
||||||
import ifcopenshell.util.element
|
import ifcopenshell.util.element
|
||||||
|
import ifcopenshell.util.type
|
||||||
|
|
||||||
|
|
||||||
def assign_type(
|
def assign_type(
|
||||||
@@ -189,6 +190,20 @@ class Usecase:
|
|||||||
if not related_objects:
|
if not related_objects:
|
||||||
return
|
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 '<none>'})"
|
||||||
|
)
|
||||||
|
|
||||||
ifc2x3 = self.file.schema == "IFC2X3"
|
ifc2x3 = self.file.schema == "IFC2X3"
|
||||||
related_objects_set = set(related_objects)
|
related_objects_set = set(related_objects)
|
||||||
if ifc2x3:
|
if ifc2x3:
|
||||||
|
|||||||
@@ -183,6 +183,39 @@ class TestAssignType(test.bootstrap.IFC4):
|
|||||||
assert element.PredefinedType == "USERDEFINED"
|
assert element.PredefinedType == "USERDEFINED"
|
||||||
assert element.ObjectType == "Test"
|
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):
|
class TestAssignTypeIFC2X3(test.bootstrap.IFC2X3, TestAssignType):
|
||||||
pass
|
pass
|
||||||
|
|||||||
Reference in New Issue
Block a user