mirror of
https://github.com/IfcOpenShell/IfcOpenShell.git
synced 2026-08-20 04:04:00 +00:00
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:
@@ -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
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@@ -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",
|
||||
)
|
||||
|
||||
@@ -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()
|
||||
|
||||
Reference in New Issue
Block a user