Fix MEP pair-disconnect crash and bend re-edit pen icon

Three user-facing fixes for the MEP-system gizmo surface:

1. MEP pair-disconnect no longer crashes Blender. The
   MEPSystemPathDecorator cached entity_instance references in
   _cached_walk; deleting a bridging fitting via the gizmo left a freed
   SWIG handle in the list, and the next _build_geometry pass segfaulted
   on .is_a. The cache now stores STEP integer ids and re-resolves via
   ifc_file.by_id on each draw, plus folds tool.Parametric.get_geom_generation
   into the cache key — ifcopenshell.api mutations invalidate before the
   next frame regardless of how the deletion was routed.

2. Bend re-edit pen icon stays reachable. The bend creation path
   tessellates the swept-disk body (upstream geometry-kernel workaround),
   so tool.System.has_parametric_body correctly returns False for a
   freshly-committed bend. _active_is_bend_fitting and
   GizmoMEPActions.is_eligible_object now fall back to the type's
   BBIM_Fitting pset — the same source bim.enable_bend_preview_from_bend
   reads parameters from — keeping the pen icon eligible.

3. MEP pair / per-port unjoin icons unified through bim.disconnect_elements.
   The MEP gizmo group's three unjoin icons (pair, start, end) now share
   the wall-disconnect surface: same VIEW3D_GT_wall_link_toggle icon, same
   bim.disconnect_elements operator. tool.Connection.find_rels learned a
   new "mep-pair-fitting" kind that returns the bridging fitting as the
   disconnect target; core.connection.disconnect_rel grew the matching
   dispatch arm. The old MEPUnjoinAtPort and MEPUnjoinPair operators are
   removed.

Also registered wall.GizmoPairDisconnect (previously declared but never
in the classes tuple, so dead code) for the wall+slab pair-disconnect
surface, and extracted MEP port-topology helpers (find_bridging_fitting,
is_disconnectable_fitting, neighbours_at_ports) onto tool.System so the
canonical walk has a single home.

Generated with the assistance of an AI coding tool.
This commit is contained in:
Gorgious56
2026-06-23 14:16:33 +02:00
parent 262f5f9a85
commit 6fa984ce2b
14 changed files with 1102 additions and 398 deletions
@@ -63,6 +63,148 @@ def test_fully_overridden_subclass_is_accepted():
assert cls.__name__ == "DecoratorWithAllHooks"
# ---------------------------------------------------------------------------
# Cache invalidation — the load-bearing crash guard.
#
# Without geom-generation gating, the walk cache holds entity_instance
# references that outlive their backing IFC entities after an
# ifcopenshell.api mutation. The next _build_geometry pass calls .is_a
# on a freed SWIG handle and segfaults Blender. The gate must fire
# whenever tool.Parametric.get_geom_generation bumps — which is on
# every tool.Ifc.Operator commit (via refresh_post_commit), covering
# every disconnect path.
from types import SimpleNamespace
from unittest.mock import Mock, patch
def _seed_cache(decorator, *, start_guid, ifc_file, geom_gen, walk_ids):
decorator._cached_start_guid = start_guid
decorator._cached_ifc_file = ifc_file
decorator._cached_geom_gen = geom_gen
decorator._cached_walk_ids = list(walk_ids)
def test_walk_cache_reuses_when_seed_file_and_geom_gen_unchanged():
"""Cache hit: same seed, same ifc_file, same geom_gen → reuse the
stored walk. Steady-state path while the IFC is idle."""
cls = _build_subclass("DecoratorCacheReuse")
dec = cls()
ifc_file = SimpleNamespace()
_seed_cache(dec, start_guid="GUID", ifc_file=ifc_file, geom_gen=5, walk_ids=[101, 102])
current_geom_gen = 5
start_guid = "GUID"
hit = (
start_guid == dec._cached_start_guid
and ifc_file is dec._cached_ifc_file
and current_geom_gen == dec._cached_geom_gen
and dec._cached_walk_ids
)
assert hit, "Cache must hit when seed, file, and geom_gen are unchanged"
def test_walk_cache_invalidates_on_geom_generation_bump():
"""Cache must miss when geom_gen bumps so entities removed by an
``ifcopenshell.api`` mutation never survive in the cached walk
list into the next draw pass."""
cls = _build_subclass("DecoratorCacheGenInvalidates")
dec = cls()
ifc_file = SimpleNamespace()
_seed_cache(dec, start_guid="GUID", ifc_file=ifc_file, geom_gen=5, walk_ids=[101])
current_geom_gen = 6 # IFC mutation has bumped the counter
start_guid = "GUID"
hit = (
start_guid == dec._cached_start_guid
and ifc_file is dec._cached_ifc_file
and current_geom_gen == dec._cached_geom_gen
and dec._cached_walk_ids
)
assert not hit, "Cache must miss when geom_gen bumps so the walk re-runs against live entities"
def test_walk_cache_invalidates_on_seed_change():
"""Selecting a different network seed forces a re-walk even if
geom_gen is unchanged."""
cls = _build_subclass("DecoratorCacheSeedChange")
dec = cls()
ifc_file = SimpleNamespace()
_seed_cache(dec, start_guid="OLD-GUID", ifc_file=ifc_file, geom_gen=5, walk_ids=[101])
hit = (
"NEW-GUID" == dec._cached_start_guid
and ifc_file is dec._cached_ifc_file
and 5 == dec._cached_geom_gen
and dec._cached_walk_ids
)
assert not hit
def test_walk_cache_invalidates_on_ifc_file_swap():
"""Loading a different IFC file must invalidate even if the new
seed happens to share the GUID (different IfcOpenShell file
objects → different identity)."""
cls = _build_subclass("DecoratorCacheFileSwap")
dec = cls()
old_file = SimpleNamespace()
new_file = SimpleNamespace()
_seed_cache(dec, start_guid="GUID", ifc_file=old_file, geom_gen=5, walk_ids=[101])
hit = (
"GUID" == dec._cached_start_guid
and new_file is dec._cached_ifc_file
and 5 == dec._cached_geom_gen
and dec._cached_walk_ids
)
assert not hit
def test_walk_cache_stores_ids_not_entity_references():
"""Structural safety: the cache stores STEP integer ids, not raw
``entity_instance`` references — re-resolved via ``ifc_file.by_id``
on each cache hit. Eliminates the dangling-SWIG-handle class entirely:
even if geom_gen mistakenly fails to bump, a deleted entity's id won't
resolve, the cache-hit branch returns ``None``, and the next draw
re-walks against live entities."""
cls = _build_subclass("DecoratorCacheStoresIds")
dec = cls()
_seed_cache(dec, start_guid="GUID", ifc_file=SimpleNamespace(), geom_gen=5, walk_ids=[42])
assert dec._cached_walk_ids == [42]
assert all(isinstance(eid, int) for eid in dec._cached_walk_ids)
def test_geom_cache_key_includes_geom_generation():
"""``TokenCache.get_or_compute`` keys that include geom_gen flush
the cached world-space geometry on IFC mutations the depsgraph
token doesn't observe — without that key component, a re-walk
would feed a fresh list to the lambda while the cache still
returned the prior result."""
import bonsai.bim.decorator_cache as decorator_cache
from bonsai.bim.module.model.decorator import MEPSystemPathDecorator
decorator_cache.reset_for_test()
dec = MEPSystemPathDecorator()
builds: list[int] = []
def _build():
builds.append(1)
return ([], [], [])
ifc_file = SimpleNamespace()
dec._geom_cache.get_or_compute(("GUID", id(ifc_file), 1), _build)
dec._geom_cache.get_or_compute(("GUID", id(ifc_file), 1), _build)
assert len(builds) == 1, "Same key (same gen) should reuse the cached value"
dec._geom_cache.get_or_compute(("GUID", id(ifc_file), 2), _build)
assert len(builds) == 2, "Bumping geom_gen in the key must invalidate the cached value"
# ---------------------------------------------------------------------------
# Pure-geometry classifier contract.
#
@@ -47,6 +47,11 @@ def _rel(klass: str, *, relating=None, related=None, description=None, rel_id: i
def _elem(*, connected_to=(), connected_from=()):
e = Mock()
# Default is_a to False so the MEP-pair-fitting branch of find_rels
# (which calls ``elem.is_a("IfcFlowSegment")``) early-outs on the
# generic _elem stubs used by the wall-side dispatch tests. Test
# cases that want is_a("IfcWall")-True explicitly override e.is_a.
e.is_a = lambda _c: False
e.ConnectedTo = list(connected_to)
e.ConnectedFrom = list(connected_from)
e.GlobalId = "GUID"
@@ -167,6 +172,140 @@ def test_find_rels_for_element_skips_rels_without_partner():
assert tool.Connection.find_rels_for_element(elem) == []
# ---------------------------------------------------------------------------
# tool.Connection.find_rels — MEP pair-fitting detection
# ---------------------------------------------------------------------------
def _mep(elem_id, *, klasses=("IfcFlowSegment",), predefined_type=None, ports=()):
"""Stand-in IFC element with port mocks and ``is_a`` short-circuits."""
e = Mock()
e.id = lambda: elem_id
e.is_a = lambda c: c in klasses
e.PredefinedType = predefined_type
# Empty path / element rels so the find_rels prologue iterates cleanly
# before reaching the MEP port-walk branch.
e.ConnectedTo = []
e.ConnectedFrom = []
e._ports = list(ports)
return e
def _port(port_id, owner, connected_to=None):
p = Mock()
p.id = lambda: port_id
p._owner = owner
p._connected_to = connected_to
return p
def _patch_port_walk():
"""Patch the port helpers ``tool.System.find_bridging_fitting`` consumes
so the mep-pair-fitting detection in ``find_rels`` can be exercised
without a real IFC fixture. Three patches: ``get_ports`` and
``get_connected_port`` are ``tool.System`` classmethods that delegate
to ``ifcopenshell.util.system``; ``get_port_element`` is called
directly on ``ifcopenshell.util.system`` inside ``neighbours_at_ports``."""
return (
patch("bonsai.tool.system.System.get_ports", side_effect=lambda e: e._ports),
patch(
"bonsai.tool.system.System.get_connected_port",
side_effect=lambda p: p._connected_to,
),
patch(
"bonsai.tool.system.ifcopenshell.util.system.get_port_element",
side_effect=lambda p: p._owner,
),
)
def test_find_rels_detects_segment_segment_bridging_fitting():
"""Two flow segments joined by a single bridging fitting must surface
as ``(fitting, 'mep-pair-fitting')`` — the fitting whose deletion
effects the disconnect."""
fitting = _mep(99, klasses=("IfcFlowFitting", "IfcDistributionFlowElement"), predefined_type="BEND")
seg_a = _mep(1)
seg_b = _mep(2)
a_port = _port(101, seg_a)
b_port = _port(102, seg_b)
f_port_a = _port(201, fitting, connected_to=a_port)
f_port_b = _port(202, fitting, connected_to=b_port)
a_port._connected_to = f_port_a
b_port._connected_to = f_port_b
seg_a._ports = [a_port]
seg_b._ports = [b_port]
fitting._ports = [f_port_a, f_port_b]
with _patch_port_walk()[0], _patch_port_walk()[1], _patch_port_walk()[2]:
rels = tool.Connection.find_rels(seg_a, seg_b)
assert rels == [(fitting, "mep-pair-fitting")]
def test_find_rels_detects_segment_fitting_direct():
"""A segment + its directly-connected fitting also surface as the
same kind, with the fitting itself as the deletion target."""
fitting = _mep(99, klasses=("IfcFlowFitting", "IfcDistributionFlowElement"), predefined_type="BEND")
seg = _mep(1)
seg_port = _port(101, seg)
f_port = _port(201, fitting, connected_to=seg_port)
seg_port._connected_to = f_port
seg._ports = [seg_port]
fitting._ports = [f_port]
with _patch_port_walk()[0], _patch_port_walk()[1], _patch_port_walk()[2]:
rels = tool.Connection.find_rels(seg, fitting)
assert rels == [(fitting, "mep-pair-fitting")]
def test_find_rels_skips_obstruction_fitting():
"""OBSTRUCTION fittings have a dedicated grow/shrink removal flow —
they must not surface as a disconnect target."""
obstruction = _mep(99, klasses=("IfcFlowFitting", "IfcDistributionFlowElement"), predefined_type="OBSTRUCTION")
seg_a = _mep(1)
seg_b = _mep(2)
a_port = _port(101, seg_a)
b_port = _port(102, seg_b)
o_port_a = _port(201, obstruction, connected_to=a_port)
o_port_b = _port(202, obstruction, connected_to=b_port)
a_port._connected_to = o_port_a
b_port._connected_to = o_port_b
seg_a._ports = [a_port]
seg_b._ports = [b_port]
obstruction._ports = [o_port_a, o_port_b]
with _patch_port_walk()[0], _patch_port_walk()[1], _patch_port_walk()[2]:
assert tool.Connection.find_rels(seg_a, seg_b) == []
def test_find_rels_returns_empty_for_two_unrelated_mep_segments():
"""No bridging fitting, no detection."""
seg_a = _mep(1)
seg_b = _mep(2)
seg_a._ports = []
seg_b._ports = []
with _patch_port_walk()[0], _patch_port_walk()[1], _patch_port_walk()[2]:
assert tool.Connection.find_rels(seg_a, seg_b) == []
def test_find_rels_skips_non_mep_pair():
"""Walls don't have ports — find_rels must early-out before walking
them as if they were MEP."""
wall_a = Mock()
wall_a.is_a = lambda c: c == "IfcWall"
wall_a.ConnectedTo = []
wall_a.ConnectedFrom = []
wall_b = Mock()
wall_b.is_a = lambda c: c == "IfcWall"
wall_b.ConnectedTo = []
wall_b.ConnectedFrom = []
assert tool.Connection.find_rels(wall_a, wall_b) == []
# ---------------------------------------------------------------------------
# tool.Connection.find_rel — first-match convenience
# ---------------------------------------------------------------------------
@@ -128,14 +128,14 @@ def test_port_connection_state_cached_across_frames_within_generation(_patched_v
element = Mock()
element.is_a = lambda c: c == "IfcFlowSegment"
call_counts = {"port_connection_state": 0, "find_fitting_between_segments": 0, "compute_mep_join_location": 0}
call_counts = {"port_connection_state": 0, "find_bridging_fitting": 0, "compute_mep_join_location": 0}
def counting_port_state(elem, at_start):
call_counts["port_connection_state"] += 1
return "FREE"
def counting_find_fitting(a, b):
call_counts["find_fitting_between_segments"] += 1
call_counts["find_bridging_fitting"] += 1
return None
def counting_join_location():
@@ -151,7 +151,7 @@ def test_port_connection_state_cached_across_frames_within_generation(_patched_v
return_value=(Vector((0, 0, 0)), Vector((1, 0, 0))),
),
patch("bonsai.bim.module.model.mep.port_connection_state", side_effect=counting_port_state),
patch("bonsai.bim.module.model.mep.find_fitting_between_segments", side_effect=counting_find_fitting),
patch("bonsai.bim.module.model.mep.tool.System.find_bridging_fitting", side_effect=counting_find_fitting),
patch("bonsai.bim.module.model.decorator.compute_mep_join_location", side_effect=counting_join_location),
patch("bonsai.bim.module.model.mep.gizmo.get_billboard_rotation", return_value=Mock()),
patch("bonsai.bim.module.model.mep.gizmo.billboarded_at", return_value=Mock()),
@@ -164,7 +164,7 @@ def test_port_connection_state_cached_across_frames_within_generation(_patched_v
# Second frame must reuse the cached values — no second IFC walk.
assert call_counts["port_connection_state"] == first["port_connection_state"]
assert call_counts["find_fitting_between_segments"] == first["find_fitting_between_segments"]
assert call_counts["find_bridging_fitting"] == first["find_bridging_fitting"]
assert call_counts["compute_mep_join_location"] == first["compute_mep_join_location"]
@@ -203,7 +203,7 @@ def test_generation_advance_invalidates_cache(_patched_visibility):
), patch(
"bonsai.bim.module.model.mep.port_connection_state", side_effect=counting_port_state
), patch(
"bonsai.bim.module.model.mep.find_fitting_between_segments", side_effect=counting_find_fitting
"bonsai.bim.module.model.mep.tool.System.find_bridging_fitting", side_effect=counting_find_fitting
), patch(
"bonsai.bim.module.model.decorator.compute_mep_join_location", return_value=Vector((0, 0, 0))
), patch(
@@ -218,9 +218,7 @@ def test_generation_advance_invalidates_cache(_patched_visibility):
inst.position_gizmos(context)
assert port_call_count["n"] > first_port, "port_connection_state must recompute after generation advance"
assert (
fitting_call_count["n"] > first_fitting
), "find_fitting_between_segments must recompute after generation advance"
assert fitting_call_count["n"] > first_fitting, "find_bridging_fitting must recompute after generation advance"
def test_selection_change_invalidates_cache(_patched_visibility):
@@ -252,7 +250,7 @@ def test_selection_change_invalidates_cache(_patched_visibility):
), patch(
"bonsai.bim.module.model.mep.port_connection_state", return_value="FREE"
), patch(
"bonsai.bim.module.model.mep.find_fitting_between_segments", side_effect=counting_find_fitting
"bonsai.bim.module.model.mep.tool.System.find_bridging_fitting", side_effect=counting_find_fitting
), patch(
"bonsai.bim.module.model.decorator.compute_mep_join_location", return_value=Vector((0, 0, 0))
), patch(
@@ -265,4 +263,4 @@ def test_selection_change_invalidates_cache(_patched_visibility):
selection_state["selected"] = [active, other_b]
inst.position_gizmos(context)
assert fitting_call_count["n"] > first, "find_fitting_between_segments must recompute after selection change"
assert fitting_call_count["n"] > first, "find_bridging_fitting must recompute after selection change"
@@ -160,41 +160,102 @@ def test_lock_closed_icons_pass_position_to_remove_terminal_fitting():
)
def test_unjoin_port_icons_pass_position_to_unjoin_at_port():
"""Per-port unjoin icons bind to ``bim.mep_unjoin_at_port`` with
``position`` pinned. Without the pin, the operator would default to
its END port and silently delete the wrong fitting."""
def test_unjoin_icons_bind_unified_disconnect_operator():
"""Every unjoin icon (pair, start, end) routes to the unified
``bim.disconnect_elements`` operator and the group holds an
``op_props`` slot for each so the per-frame GUID writes have a
target."""
from bonsai.bim.module.model.mep import GizmoMEPActions
inst = _build_group_with_mock_gizmos()
with patch("bonsai.bim.module.model.mep.gizmo.get_warning_color_from_prefs", return_value=(1, 0, 0)), patch(
"bonsai.bim.module.model.mep.tool.Blender.get_addon_preferences", return_value=MagicMock()
):
GizmoMEPActions._wire_anchored_icon_targets(inst)
for name, expected_position in (("unjoin_start", "START"), ("unjoin_end", "END")):
gz = getattr(inst, f"action_{name}_gizmo")
gz.target_set_operator.assert_any_call("bim.mep_unjoin_at_port")
op_props = gz.target_set_operator.return_value
assert op_props.position == expected_position or op_props.position in ("START", "END")
def test_unjoin_icons_get_warning_color_highlight():
"""Destructive icons surface in the addon's warning red on hover so
they read as a deliberate target. ``color_highlight`` is overridden
after ``super().setup()`` wires the default highlight."""
from bonsai.bim.module.model.mep import GizmoMEPActions
inst = _build_group_with_mock_gizmos()
warning_color = (1.0, 0.1, 0.1)
with patch("bonsai.bim.module.model.mep.gizmo.get_warning_color_from_prefs", return_value=warning_color), patch(
"bonsai.bim.module.model.mep.tool.Blender.get_addon_preferences", return_value=MagicMock()
):
GizmoMEPActions._wire_anchored_icon_targets(inst)
GizmoMEPActions._wire_anchored_icon_targets(inst)
assert isinstance(inst.unjoin_op_props, dict)
for name in GizmoMEPActions.UNJOIN_CONFIGS:
gz = getattr(inst, f"action_{name}_gizmo")
assert gz.color_highlight == warning_color, f"{name} hover colour not overridden with warning red"
gz.target_set_operator.assert_any_call("bim.disconnect_elements")
assert name in inst.unjoin_op_props, f"missing op_props slot for {name!r}"
def test_bind_unjoin_pair_writes_both_guids():
"""``_bind_unjoin_pair`` is the per-frame hand-off from gizmo
position-gizmos to the unified disconnect operator: both segment
GlobalIds get written onto the pre-wired op_props so a click
dispatches with the right pair."""
from bonsai.bim.module.model.mep import GizmoMEPActions
inst = _build_group_with_mock_gizmos()
GizmoMEPActions._wire_anchored_icon_targets(inst)
pair_op_props = inst.unjoin_op_props["unjoin_pair"]
seg_a = Mock(GlobalId="GUID-A")
seg_b = Mock(GlobalId="GUID-B")
assert GizmoMEPActions._bind_unjoin_pair(inst, [seg_a, seg_b]) is True
assert pair_op_props.element_a_guid == "GUID-A"
assert pair_op_props.element_b_guid == "GUID-B"
def test_bind_unjoin_pair_rejects_incomplete_pair():
"""Defensive: a selection mid-change can hand the gizmo a one-element
or None-containing pair. The bind must refuse rather than write a
half-resolved op_props that would later CANCEL with a confusing
error message."""
from bonsai.bim.module.model.mep import GizmoMEPActions
inst = _build_group_with_mock_gizmos()
GizmoMEPActions._wire_anchored_icon_targets(inst)
assert GizmoMEPActions._bind_unjoin_pair(inst, [Mock(GlobalId="A")]) is False
assert GizmoMEPActions._bind_unjoin_pair(inst, [Mock(GlobalId="A"), None]) is False
def test_bind_unjoin_at_port_resolves_fitting_and_writes_guids():
"""The per-port unjoin gizmo resolves the partner fitting at the
named port and writes (segment_guid, fitting_guid) onto the
pre-wired op_props so the unified disconnect operator gets both
endpoints."""
from bonsai.bim.module.model.mep import GizmoMEPActions
inst = _build_group_with_mock_gizmos()
GizmoMEPActions._wire_anchored_icon_targets(inst)
port_op_props = inst.unjoin_op_props["unjoin_end"]
segment_obj = Mock()
segment = Mock(GlobalId="SEG-GUID")
fitting = Mock(GlobalId="FIT-GUID")
fitting.is_a = lambda c: c == "IfcFlowFitting"
fitting.PredefinedType = "BEND"
with patch("bonsai.bim.module.model.mep.tool.Ifc.get_entity", return_value=segment), patch(
"bonsai.bim.module.model.mep.get_connected_element_at_segment_port", return_value=fitting
):
ok = GizmoMEPActions._bind_unjoin_at_port(inst, "unjoin_end", segment_obj, False)
assert ok is True
assert port_op_props.element_a_guid == "SEG-GUID"
assert port_op_props.element_b_guid == "FIT-GUID"
def test_bind_unjoin_at_port_refuses_obstruction_partner():
"""OBSTRUCTION fittings have a dedicated grow/shrink removal flow —
routing them through the unified disconnect would just delete the
fitting and leave a visible gap. Mirror the find_rels exclusion
here so the icon hides when the partner is an obstruction."""
from bonsai.bim.module.model.mep import GizmoMEPActions
inst = _build_group_with_mock_gizmos()
GizmoMEPActions._wire_anchored_icon_targets(inst)
segment = Mock(GlobalId="SEG-GUID")
obstruction = Mock(GlobalId="OBS-GUID")
obstruction.is_a = lambda c: c == "IfcFlowFitting"
obstruction.PredefinedType = "OBSTRUCTION"
with patch("bonsai.bim.module.model.mep.tool.Ifc.get_entity", return_value=segment), patch(
"bonsai.bim.module.model.mep.get_connected_element_at_segment_port", return_value=obstruction
):
assert GizmoMEPActions._bind_unjoin_at_port(inst, "unjoin_end", Mock(), False) is False
# ---------------------------------------------------------------------------
@@ -202,6 +263,66 @@ def test_unjoin_icons_get_warning_color_highlight():
# ---------------------------------------------------------------------------
def test_active_is_bend_fitting_accepts_tessellated_bend_with_bbim_pset():
"""A bend whose body has been tessellated as the upstream geometry-kernel
workaround still has its parametric definition on the type's
``BBIM_Fitting`` pset — the re-edit operator reads from there, so the
pen icon must surface on it. ``has_parametric_body`` would return False
for the tessellated body; the pset gate is what makes the icon
reachable."""
from bonsai.bim.module.model.mep import _active_is_bend_fitting
bend_obj = Mock()
bend_elem = Mock()
bend_elem.is_a = lambda c: c == "IfcFlowFitting"
bend_type = Mock()
bend_type.PredefinedType = "BEND"
with patch("bonsai.bim.module.model.mep.tool.Ifc.get_entity", return_value=bend_elem), patch(
"bonsai.bim.module.model.mep._is_bend_fitting", return_value=True
), patch("bonsai.bim.module.model.mep.ifcopenshell.util.element.get_type", return_value=bend_type), patch(
"bonsai.bim.module.model.mep.ifcopenshell.util.element.get_pset",
return_value={"radius": 0.2, "start_length": 0.1, "end_length": 0.1},
):
assert _active_is_bend_fitting(bend_obj) is True
def test_active_is_bend_fitting_rejects_bend_type_without_bbim_pset():
"""A fitting that looks like a bend (IfcFlowFitting + type.PredefinedType
== BEND) but lacks a ``BBIM_Fitting`` pset on the type can't be re-edited
— the re-edit operator reads parameters from the pset. Reject so the pen
icon hides rather than dispatching an operator that would CANCEL."""
from bonsai.bim.module.model.mep import _active_is_bend_fitting
bend_obj = Mock()
bend_elem = Mock()
bend_elem.is_a = lambda c: c == "IfcFlowFitting"
bend_type = Mock()
bend_type.PredefinedType = "BEND"
with patch("bonsai.bim.module.model.mep.tool.Ifc.get_entity", return_value=bend_elem), patch(
"bonsai.bim.module.model.mep._is_bend_fitting", return_value=True
), patch("bonsai.bim.module.model.mep.ifcopenshell.util.element.get_type", return_value=bend_type), patch(
"bonsai.bim.module.model.mep.ifcopenshell.util.element.get_pset", return_value=None
):
assert _active_is_bend_fitting(bend_obj) is False
def test_active_is_bend_fitting_rejects_non_bend():
"""Non-bend objects (segments, fittings with PredefinedType != BEND)
fail the first gate regardless of pset state."""
from bonsai.bim.module.model.mep import _active_is_bend_fitting
bend_obj = Mock()
bend_elem = Mock()
bend_elem.is_a = lambda c: c == "IfcFlowFitting"
with patch("bonsai.bim.module.model.mep.tool.Ifc.get_entity", return_value=bend_elem), patch(
"bonsai.bim.module.model.mep._is_bend_fitting", return_value=False
):
assert _active_is_bend_fitting(bend_obj) is False
def test_active_is_flow_segment_handles_unbound_object():
"""A Blender object with no IFC binding must not raise from a
visibility predicate. The lambda runs on every selection event."""
@@ -0,0 +1,139 @@
# 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.
"""End-to-end integration tests for the unified MEP disconnect path.
Builds a real IFC scene (two pipe segments joined via ports to a bridging
fitting) and exercises the full chain:
tool.Connection.find_rels(seg_a, seg_b)
→ returns (fitting, "mep-pair-fitting")
→ bonsai.core.connection.disconnect_rel(rel=fitting, kind=...)
→ tool.Geometry.delete_ifc_object(fitting_obj)
→ cascade-on-delete removes the IfcRelConnectsPorts via remove_port
The mock-based dispatch tests in :py:mod:`test_disconnect_elements` pin each
piece in isolation. This module pins that they compose — the surface the
gizmo click hits in production."""
import bpy
import ifcopenshell.api.system
import pytest
import bonsai.core.connection
import bonsai.tool as tool
from test.bim.bootstrap import NewFile
pytestmark = pytest.mark.model
class TestMEPPairDisconnectEndToEnd(NewFile):
def _make_segment(self, name: str):
"""Create one IfcPipeSegment occurrence with ports at both ends.
Returns (blender_object, ifc_element)."""
bpy.ops.mesh.primitive_cube_add(size=1)
obj = bpy.data.objects["Cube"]
obj.name = name
bpy.ops.bim.assign_class(ifc_class="IfcPipeSegment", predefined_type="RIGIDSEGMENT", userdefined_type="")
element = tool.Ifc.get_entity(obj)
tool.System.add_ports(obj)
return obj, element
def _make_bend_fitting(self, name: str):
"""Create one IfcPipeFitting (PredefinedType=BEND) occurrence with two
ports. Manual setup — bpy.ops.bim.assign_class doesn't add ports."""
bpy.ops.mesh.primitive_cube_add(size=0.3)
obj = bpy.data.objects["Cube"]
obj.name = name
bpy.ops.bim.assign_class(ifc_class="IfcPipeFitting", predefined_type="BEND", userdefined_type="")
element = tool.Ifc.get_entity(obj)
tool.System.add_ports(obj)
return obj, element
def _setup_joined_pair(self):
bpy.ops.bim.create_project()
seg_a_obj, seg_a = self._make_segment("SegA")
seg_b_obj, seg_b = self._make_segment("SegB")
bend_obj, bend = self._make_bend_fitting("Bend")
ifc_file = tool.Ifc.get()
seg_a_ports = tool.System.get_ports(seg_a)
seg_b_ports = tool.System.get_ports(seg_b)
bend_ports = tool.System.get_ports(bend)
ifcopenshell.api.system.connect_port(ifc_file, port1=seg_a_ports[0], port2=bend_ports[0])
ifcopenshell.api.system.connect_port(ifc_file, port1=seg_b_ports[0], port2=bend_ports[1])
return seg_a, seg_b, bend, bend_obj
def test_find_rels_returns_mep_pair_fitting_subject(self):
seg_a, seg_b, bend, _ = self._setup_joined_pair()
rels = tool.Connection.find_rels(seg_a, seg_b)
assert rels == [(bend, "mep-pair-fitting")]
def test_disconnect_rel_removes_the_bridging_fitting(self):
"""The end-to-end contract: dispatch removes the fitting from the
IFC file, the Blender object is deleted, and a follow-up find_rels
on the same pair returns empty — there's nothing left to disconnect."""
seg_a, seg_b, bend, bend_obj = self._setup_joined_pair()
bend_id = bend.id()
bend_obj_name = bend_obj.name
ifc_file = tool.Ifc.get()
bonsai.core.connection.disconnect_rel(
tool.Ifc,
tool.Geometry,
tool.Model,
tool.Connection,
rel=bend,
kind="mep-pair-fitting",
elem=seg_a,
partner=seg_b,
)
# The fitting is gone from the IFC file.
with pytest.raises(RuntimeError):
ifc_file.by_id(bend_id)
# The pair is no longer joined.
assert tool.Connection.find_rels(seg_a, seg_b) == []
# The Blender object was removed by delete_ifc_object.
assert bend_obj_name not in bpy.data.objects
def test_disconnect_rel_skips_when_subject_is_elem_being_deleted(self):
"""Cascade-side guard: if the fitting is itself the element being
deleted (subject is elem), skip — the deletion is already in flight
and re-deleting would crash."""
seg_a, seg_b, bend, bend_obj = self._setup_joined_pair()
bend_id = bend.id()
bend_obj_name = bend_obj.name
bonsai.core.connection.disconnect_rel(
tool.Ifc,
tool.Geometry,
tool.Model,
tool.Connection,
rel=bend,
kind="mep-pair-fitting",
elem=bend,
partner=seg_a,
skip_elem_recreate=True,
)
# Fitting still present — the dispatch correctly skipped.
assert tool.Ifc.get().by_id(bend_id).id() == bend_id
assert bend_obj_name in bpy.data.objects
@@ -61,76 +61,6 @@ def _make_op(_cls, **fields):
return op
# ---------------------------------------------------------------------------
# MEPUnjoinAtPort
# ---------------------------------------------------------------------------
@pytest.mark.parametrize(
"port_state, fitting_predefined_type, expected_result, expects_delete",
[
pytest.param("JOINED", "JUNCTION", {"FINISHED"}, True, id="joined_junction_deletes"),
pytest.param("JOINED", "OBSTRUCTION", {"CANCELLED"}, False, id="joined_obstruction_refused"),
pytest.param("FREE", None, {"CANCELLED"}, False, id="free_port_cancels"),
],
)
def test_unjoin_at_port_dispatch_table(port_state, fitting_predefined_type, expected_result, expects_delete):
"""``MEPUnjoinAtPort`` dispatch contract: result and delete-side-effect
by ``(port_state, fitting type)``.
- ``JOINED + JUNCTION`` (or any non-OBSTRUCTION fitting): happy path,
the bridging fitting is deleted via the standard delete entry point.
- ``JOINED + OBSTRUCTION``: deliberately refused — obstructions go
through ``bim.mep_add_obstruction`` (mode=REMOVE) so the segment
extends to absorb the freed length; using delete here would leave
a visible gap.
- ``FREE``: nothing to do — no bridging fitting exists. The operator
reports a user-facing error and CANCELS rather than no-op silently."""
from bonsai.bim.module.model import mep
segment = _segment()
fitting = _fitting(predefined_type=fitting_predefined_type) if fitting_predefined_type else None
fitting_obj = Mock()
op = _make_op(mep.MEPUnjoinAtPort, segment_id=42, position="END")
ifc_file = MagicMock()
ifc_file.by_id.return_value = segment
with patch.object(mep.tool.Ifc, "get", return_value=ifc_file), patch.object(
mep.tool.Ifc, "get_object", return_value=fitting_obj
), patch.object(mep, "port_connection_state", return_value=port_state), patch.object(
mep, "get_connected_element_at_segment_port", return_value=fitting
), patch.object(
mep.tool.Geometry, "delete_ifc_object"
) as delete:
result = mep.MEPUnjoinAtPort._execute(op, context=MagicMock())
assert result == expected_result
if expects_delete:
delete.assert_called_once_with(fitting_obj)
else:
delete.assert_not_called()
op.report.assert_called()
def test_unjoin_at_port_cancels_when_active_is_not_segment():
"""The operator only operates on flow segments; non-segment active
objects must fail loud rather than mutate something unexpected."""
from bonsai.bim.module.model import mep
fitting = _fitting() # IfcFlowFitting, not IfcFlowSegment
op = _make_op(mep.MEPUnjoinAtPort, segment_id=42, position="END")
ifc_file = MagicMock()
ifc_file.by_id.return_value = fitting
with patch.object(mep.tool.Ifc, "get", return_value=ifc_file):
result = mep.MEPUnjoinAtPort._execute(op, context=MagicMock())
assert result == {"CANCELLED"}
op.report.assert_called()
# ---------------------------------------------------------------------------
# MEPRemoveTerminalFitting
# ---------------------------------------------------------------------------
@@ -210,105 +140,6 @@ def test_remove_terminal_cancels_on_non_terminal_port():
op.report.assert_called()
# ---------------------------------------------------------------------------
# MEPUnjoinPair
# ---------------------------------------------------------------------------
def test_unjoin_pair_deletes_bridging_fitting():
"""Happy path: two selected segments share a single non-OBSTRUCTION
bridging fitting → delete it."""
from bonsai.bim.module.model import mep
segment_a = _segment()
segment_b = _segment()
fitting = _fitting(predefined_type="JUNCTION")
fitting_obj = Mock()
op = _make_op(mep.MEPUnjoinPair)
selected = [Mock(), Mock()]
with patch.object(mep.tool.Blender, "get_selected_objects", return_value=selected), patch.object(
mep.tool.Ifc, "get_entity", side_effect=[segment_a, segment_b]
), patch.object(mep, "find_fitting_between_segments", return_value=fitting), patch.object(
mep.tool.Ifc, "get_object", return_value=fitting_obj
), patch.object(
mep.tool.Geometry, "delete_ifc_object"
) as delete:
result = mep.MEPUnjoinPair._execute(op, context=MagicMock())
assert result == {"FINISHED"}
delete.assert_called_once_with(fitting_obj)
def test_unjoin_pair_refuses_obstruction_bridging():
"""Same defence-in-depth as ``MEPUnjoinAtPort`` — obstructions go
through the dedicated REMOVE path; this operator surfaces the
redirect rather than silently doing the wrong thing."""
from bonsai.bim.module.model import mep
segment_a = _segment()
segment_b = _segment()
obstruction = _fitting(predefined_type="OBSTRUCTION")
op = _make_op(mep.MEPUnjoinPair)
selected = [Mock(), Mock()]
with patch.object(mep.tool.Blender, "get_selected_objects", return_value=selected), patch.object(
mep.tool.Ifc, "get_entity", side_effect=[segment_a, segment_b]
), patch.object(mep, "find_fitting_between_segments", return_value=obstruction), patch.object(
mep.tool.Geometry, "delete_ifc_object"
) as delete:
result = mep.MEPUnjoinPair._execute(op, context=MagicMock())
assert result == {"CANCELLED"}
delete.assert_not_called()
op.report.assert_called()
def test_unjoin_pair_reports_when_no_bridging_fitting_found():
"""The pair is selected but no single fitting bridges them — the
user is told instead of getting a silent no-op."""
from bonsai.bim.module.model import mep
segment_a = _segment()
segment_b = _segment()
op = _make_op(mep.MEPUnjoinPair)
selected = [Mock(), Mock()]
with patch.object(mep.tool.Blender, "get_selected_objects", return_value=selected), patch.object(
mep.tool.Ifc, "get_entity", side_effect=[segment_a, segment_b]
), patch.object(mep, "find_fitting_between_segments", return_value=None), patch.object(
mep.tool.Geometry, "delete_ifc_object"
) as delete:
result = mep.MEPUnjoinPair._execute(op, context=MagicMock())
assert result == {"CANCELLED"}
delete.assert_not_called()
op.report.assert_called()
def test_unjoin_pair_cancels_when_selection_is_not_two_segments():
"""The poll filters the gizmo, but a programmatic invocation could
still hand the operator an invalid selection. The execute path
independently verifies both inputs are IfcFlowSegment."""
from bonsai.bim.module.model import mep
not_a_segment = _fitting() # IfcFlowFitting, not IfcFlowSegment
op = _make_op(mep.MEPUnjoinPair)
selected = [Mock(), Mock()]
with patch.object(mep.tool.Blender, "get_selected_objects", return_value=selected), patch.object(
mep.tool.Ifc, "get_entity", side_effect=[not_a_segment, not_a_segment]
):
result = mep.MEPUnjoinPair._execute(op, context=MagicMock())
assert result == {"CANCELLED"}
op.report.assert_called()
# ---------------------------------------------------------------------------
# SelectMEPPathMembers
# ---------------------------------------------------------------------------
+167 -25
View File
@@ -60,8 +60,14 @@ class TestDisconnectRelPath:
with patch("bonsai.core.connection.bonsai.core.geometry.remove_connection") as remove:
subject.disconnect_rel(
ifc, geometry, model, connection,
rel="rel", kind="path", elem="elem_a", partner="elem_b",
ifc,
geometry,
model,
connection,
rel="rel",
kind="path",
elem="elem_a",
partner="elem_b",
)
remove.assert_called_once_with(geometry, connection="rel")
@@ -78,8 +84,14 @@ class TestDisconnectRelPath:
with patch("bonsai.core.connection.bonsai.core.geometry.remove_connection"):
subject.disconnect_rel(
ifc, geometry, model, connection,
rel="rel", kind="path", elem="elem", partner="partner",
ifc,
geometry,
model,
connection,
rel="rel",
kind="path",
elem="elem",
partner="partner",
skip_elem_recreate=True,
)
@@ -93,8 +105,14 @@ class TestDisconnectRelPath:
with patch("bonsai.core.connection.bonsai.core.geometry.remove_connection"):
subject.disconnect_rel(
ifc, geometry, model, connection,
rel="rel", kind="path", elem="elem", partner="partner",
ifc,
geometry,
model,
connection,
rel="rel",
kind="path",
elem="elem",
partner="partner",
skip_partner_recreate=True,
)
@@ -108,8 +126,14 @@ class TestDisconnectRelPath:
with patch("bonsai.core.connection.bonsai.core.geometry.remove_connection") as remove:
subject.disconnect_rel(
ifc, geometry, model, connection,
rel="rel", kind="path", elem="elem", partner="partner",
ifc,
geometry,
model,
connection,
rel="rel",
kind="path",
elem="elem",
partner="partner",
skip_elem_recreate=True,
skip_partner_recreate=True,
)
@@ -131,13 +155,17 @@ class TestDisconnectRelElementTop:
with patch("bonsai.core.connection.regenerate_wall_to_underside") as regen:
subject.disconnect_rel(
ifc, geometry, model, connection,
rel=rel, kind="element-top", elem="elem", partner="partner",
ifc,
geometry,
model,
connection,
rel=rel,
kind="element-top",
elem="elem",
partner="partner",
)
ifc.run.assert_called_once_with(
"geometry.disconnect_element", relating_element="slab", related_element="wall"
)
ifc.run.assert_called_once_with("geometry.disconnect_element", relating_element="slab", related_element="wall")
regen.assert_called_once_with(ifc, geometry, model, ["wall_obj"])
def test_slab_delete_cascade_still_regenerates_wall(self):
@@ -150,8 +178,14 @@ class TestDisconnectRelElementTop:
with patch("bonsai.core.connection.regenerate_wall_to_underside") as regen:
subject.disconnect_rel(
ifc, Mock(), Mock(), connection,
rel=rel, kind="element-top", elem="slab", partner="wall",
ifc,
Mock(),
Mock(),
connection,
rel=rel,
kind="element-top",
elem="slab",
partner="wall",
skip_elem_recreate=True, # slab is being deleted
)
@@ -167,8 +201,14 @@ class TestDisconnectRelElementTop:
with patch("bonsai.core.connection.regenerate_wall_to_underside") as regen:
subject.disconnect_rel(
ifc, Mock(), Mock(), connection,
rel=rel, kind="element-top", elem="wall", partner="slab",
ifc,
Mock(),
Mock(),
connection,
rel=rel,
kind="element-top",
elem="wall",
partner="slab",
skip_elem_recreate=True, # wall is being deleted
)
@@ -185,8 +225,14 @@ class TestDisconnectRelElementTop:
with patch("bonsai.core.connection.regenerate_wall_to_underside") as regen:
subject.disconnect_rel(
ifc, Mock(), Mock(), connection,
rel=rel, kind="element-top", elem="slab", partner="wall",
ifc,
Mock(),
Mock(),
connection,
rel=rel,
kind="element-top",
elem="slab",
partner="wall",
skip_elem_recreate=True,
skip_partner_recreate=True, # wall also in batch
)
@@ -200,19 +246,115 @@ class TestDisconnectRelElement:
ifc = Mock()
subject.disconnect_rel(
ifc, Mock(), Mock(), Mock(),
rel=rel, kind="element", elem="elem_a", partner="elem_b",
ifc,
Mock(),
Mock(),
Mock(),
rel=rel,
kind="element",
elem="elem_a",
partner="elem_b",
)
ifc.run.assert_called_once_with(
"geometry.disconnect_element", relating_element="A", related_element="B"
ifc.run.assert_called_once_with("geometry.disconnect_element", relating_element="A", related_element="B")
class TestDisconnectRelMEPPairFitting:
"""The ``mep-pair-fitting`` kind treats the rel slot as the fitting whose
removal disconnects the pair — deletion routes through
``geometry.delete_ifc_object`` so the cascade-on-delete contract still
owns port-rel cleanup."""
def test_deletes_fitting_via_delete_ifc_object(self):
fitting = Mock(name="fitting")
fitting_obj = Mock(name="fitting_obj")
ifc = _ifc_with_objects({fitting: fitting_obj})
geometry = Mock()
subject.disconnect_rel(
ifc,
geometry,
Mock(),
Mock(),
rel=fitting,
kind="mep-pair-fitting",
elem="seg_a",
partner="seg_b",
)
geometry.delete_ifc_object.assert_called_once_with(fitting_obj)
def test_noops_when_fitting_has_no_blender_object(self):
"""Defensive: a fitting with no bound Blender object can't be
deleted via ``delete_ifc_object``; the dispatch must not crash."""
fitting = Mock(name="fitting")
ifc = _ifc_with_objects({})
geometry = Mock()
subject.disconnect_rel(
ifc,
geometry,
Mock(),
Mock(),
rel=fitting,
kind="mep-pair-fitting",
elem="seg_a",
partner="seg_b",
)
geometry.delete_ifc_object.assert_not_called()
def test_skip_elem_recreate_suppresses_delete_when_fitting_is_elem(self):
"""Cascade case: the fitting is itself the element being deleted
— don't try to delete it twice."""
fitting = Mock(name="fitting")
ifc = _ifc_with_objects({fitting: Mock()})
geometry = Mock()
subject.disconnect_rel(
ifc,
geometry,
Mock(),
Mock(),
rel=fitting,
kind="mep-pair-fitting",
elem=fitting,
partner="other",
skip_elem_recreate=True,
)
geometry.delete_ifc_object.assert_not_called()
def test_skip_partner_recreate_suppresses_delete_when_fitting_is_partner(self):
fitting = Mock(name="fitting")
ifc = _ifc_with_objects({fitting: Mock()})
geometry = Mock()
subject.disconnect_rel(
ifc,
geometry,
Mock(),
Mock(),
rel=fitting,
kind="mep-pair-fitting",
elem="seg_a",
partner=fitting,
skip_partner_recreate=True,
)
geometry.delete_ifc_object.assert_not_called()
class TestDisconnectRelUnknownKind:
def test_raises_value_error(self):
with pytest.raises(ValueError, match="Unknown rel kind"):
subject.disconnect_rel(
Mock(), Mock(), Mock(), Mock(),
rel="rel", kind="bogus", elem="a", partner="b",
Mock(),
Mock(),
Mock(),
Mock(),
rel="rel",
kind="bogus",
elem="a",
partner="b",
)
+127
View File
@@ -23,6 +23,7 @@ import ifcopenshell
import ifcopenshell.api
import ifcopenshell.api.root
import ifcopenshell.api.system
import ifcopenshell.util.representation
import ifcopenshell.util.system
import ifcopenshell.util.unit
import numpy as np
@@ -39,6 +40,132 @@ class TestImplementsTool(NewFile):
assert isinstance(subject(), bonsai.core.tool.System)
class TestHasParametricBody(NewFile):
"""The MEP-action gizmo predicates gate on ``has_parametric_body``;
fittings whose swept body lives on the type via ``IfcMappedItem`` must
return True so the pen-icon and lock-icon rows show on the occurrence."""
def _build_bend_occurrence_with_mapped_body(self):
bpy.ops.bim.create_project()
ifc_file = tool.Ifc.get()
body_ctx = ifcopenshell.util.representation.get_context(ifc_file, "Model", "Body", "MODEL_VIEW")
placement = ifc_file.create_entity(
"IfcAxis2Placement3D",
Location=ifc_file.create_entity("IfcCartesianPoint", Coordinates=(0.0, 0.0, 0.0)),
)
line = ifc_file.create_entity(
"IfcLine",
Pnt=ifc_file.create_entity("IfcCartesianPoint", Coordinates=(0.0, 0.0, 0.0)),
Dir=ifc_file.create_entity(
"IfcVector",
Orientation=ifc_file.create_entity("IfcDirection", DirectionRatios=(1.0, 0.0, 0.0)),
Magnitude=1.0,
),
)
trimmed = ifc_file.create_entity(
"IfcTrimmedCurve",
BasisCurve=line,
Trim1=(ifc_file.create_entity("IfcParameterValue", wrappedValue=0.0),),
Trim2=(ifc_file.create_entity("IfcParameterValue", wrappedValue=1.0),),
SenseAgreement=True,
MasterRepresentation="PARAMETER",
)
swept = ifc_file.create_entity("IfcSweptDiskSolid", Directrix=trimmed, Radius=0.05)
type_body = ifc_file.create_entity(
"IfcShapeRepresentation",
ContextOfItems=body_ctx,
RepresentationIdentifier="Body",
RepresentationType="AdvancedSweptSolid",
Items=(swept,),
)
rep_map = ifc_file.create_entity(
"IfcRepresentationMap", MappingOrigin=placement, MappedRepresentation=type_body
)
fitting_type = ifc_file.create_entity(
"IfcPipeFittingType",
GlobalId=ifcopenshell.guid.new(),
Name="BendType",
PredefinedType="BEND",
RepresentationMaps=(rep_map,),
)
mapped_item = ifc_file.create_entity(
"IfcMappedItem",
MappingSource=rep_map,
MappingTarget=ifc_file.create_entity(
"IfcCartesianTransformationOperator3D",
LocalOrigin=ifc_file.create_entity("IfcCartesianPoint", Coordinates=(0.0, 0.0, 0.0)),
),
)
occurrence_body = ifc_file.create_entity(
"IfcShapeRepresentation",
ContextOfItems=body_ctx,
RepresentationIdentifier="Body",
RepresentationType="MappedRepresentation",
Items=(mapped_item,),
)
fitting = ifc_file.create_entity(
"IfcPipeFitting",
GlobalId=ifcopenshell.guid.new(),
Name="Bend",
PredefinedType="BEND",
Representation=ifc_file.create_entity("IfcProductDefinitionShape", Representations=(occurrence_body,)),
)
ifc_file.create_entity(
"IfcRelDefinesByType",
GlobalId=ifcopenshell.guid.new(),
RelatedObjects=(fitting,),
RelatingType=fitting_type,
)
return fitting
def test_returns_true_for_swept_disk_via_mapped_item(self):
"""``traverse()`` follows the
``IfcMappedItem.MappingSource.MappedRepresentation`` chain so the
``IfcSweptDiskSolid`` on the type's body is reachable from the
occurrence's body representation. Bend fittings produced by the
bend-preview commit path use this exact representation shape."""
fitting = self._build_bend_occurrence_with_mapped_body()
assert subject.has_parametric_body(fitting) is True
def test_returns_false_for_tessellated_body(self):
"""The bend creation path replaces the swept-disk body with an
``IfcTriangulatedFaceSet`` as an upstream geometry-kernel
workaround. The traverse finds no extruded / swept solid, so the
predicate returns False — pinning the constraint that drives the
``BBIM_Fitting`` pset fallback in the bend-icon visibility
predicate."""
bpy.ops.bim.create_project()
ifc_file = tool.Ifc.get()
body_ctx = ifcopenshell.util.representation.get_context(ifc_file, "Model", "Body", "MODEL_VIEW")
coords = ifc_file.create_entity(
"IfcCartesianPointList3D",
CoordList=((0.0, 0.0, 0.0), (1.0, 0.0, 0.0), (0.0, 1.0, 0.0)),
)
tessellation = ifc_file.create_entity(
"IfcTriangulatedFaceSet",
Coordinates=coords,
CoordIndex=((1, 2, 3),),
)
body = ifc_file.create_entity(
"IfcShapeRepresentation",
ContextOfItems=body_ctx,
RepresentationIdentifier="Body",
RepresentationType="Tessellation",
Items=(tessellation,),
)
fitting = ifc_file.create_entity(
"IfcPipeFitting",
GlobalId=ifcopenshell.guid.new(),
Name="TessellatedBend",
PredefinedType="BEND",
Representation=ifc_file.create_entity("IfcProductDefinitionShape", Representations=(body,)),
)
assert subject.has_parametric_body(fitting) is False
class TestAddPorts(NewFile):
def setup_mep_segment(self):
bpy.ops.bim.create_project()