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
@@ -110,6 +110,7 @@ classes = (
wall.GizmoWallFilletPreview,
wall.GizmoWallFilletReedit,
wall.GizmoWallFilletToggleOpenings,
wall.GizmoPairDisconnect,
wall.GizmoSlabEdition,
wall.GizmoSlabUnjoinWalls,
wall.GizmoWallJoinIntersection,
@@ -279,9 +280,7 @@ classes = (
mep.MEPAddObstruction,
mep.MEPAddTransition,
mep.MEPAddBend,
mep.MEPUnjoinAtPort,
mep.MEPRemoveTerminalFitting,
mep.MEPUnjoinPair,
mep.SelectMEPPathMembers,
mep.MEPJoinSegments,
mep_bend_preview.EnableBendPreview,
+31 -11
View File
@@ -2569,14 +2569,20 @@ class _ConnectedNetworkPathDecorator(tool.Blender.ViewportDecorator):
CONNECTION_EPS_SQ = 1e-4 * 1e-4
def __init__(self) -> None:
# Two-tier cache. Walk cache keyed on (start_guid, ifc_file): re-walk
# only on selection change or file reload. Compare ``ifc_file`` with
# Walk cache keyed on (start_guid, ifc_file, geom_gen). Stores STEP
# integer ids rather than ``entity_instance`` references — re-resolved
# via ``ifc_file.by_id`` on each cache hit. Structurally rules out
# the dangling-SWIG-handle class of bug: an entity removed between
# frames either bumps geom_gen (cache miss → re-walk) or fails to
# re-resolve (handled below by re-walking). Compare ``ifc_file`` with
# ``is`` (not id()) so a GC-recycled id() can't produce a false hit.
self._cached_start_guid: str | None = None
self._cached_ifc_file: Any = None
self._cached_walk: list[Any] = []
# Geometry cache: shared TokenCache so resolved world-space lines +
# dots re-build on every depsgraph / undo / redo / load.
self._cached_geom_gen: int = -1
self._cached_walk_ids: list[int] = []
# Geometry cache: shared TokenCache. Key folds in geom_gen so IFC
# mutations that don't surface via the depsgraph still flush the
# resolved world-space lines and dots.
self._geom_cache: TokenCache[
tuple[
list[tuple[tuple[float, float, float], tuple[float, float, float]]],
@@ -2739,9 +2745,22 @@ class _ConnectedNetworkPathDecorator(tool.Blender.ViewportDecorator):
start_guid = start_element.GlobalId
if start_guid == self._failed_seed_guid:
return
if start_guid == self._cached_start_guid and ifc_file is self._cached_ifc_file and self._cached_walk:
connected = self._cached_walk
else:
current_geom_gen = tool.Parametric.get_geom_generation()
connected: list[Any] | None = None
if (
start_guid == self._cached_start_guid
and ifc_file is self._cached_ifc_file
and current_geom_gen == self._cached_geom_gen
and self._cached_walk_ids
):
try:
connected = [ifc_file.by_id(eid) for eid in self._cached_walk_ids]
except RuntimeError:
# An entity was removed without bumping geom_gen — rare but
# possible from non-operator code paths. Force a re-walk
# rather than feeding a stale handle to _build_geometry.
connected = None
if connected is None:
try:
connected = self._walk(start_element)
except Exception:
@@ -2750,12 +2769,13 @@ class _ConnectedNetworkPathDecorator(tool.Blender.ViewportDecorator):
traceback.print_exc()
self._walk_failure_logged = True
self._cached_walk = []
self._cached_walk_ids = []
self._failed_seed_guid = start_guid
return
self._cached_start_guid = start_guid
self._cached_ifc_file = ifc_file
self._cached_walk = connected
self._cached_geom_gen = current_geom_gen
self._cached_walk_ids = [e.id() for e in connected]
if not connected:
return
@@ -2770,7 +2790,7 @@ class _ConnectedNetworkPathDecorator(tool.Blender.ViewportDecorator):
try:
lines, free_points, connection_points = self._geom_cache.get_or_compute(
(start_guid, id(ifc_file)),
(start_guid, id(ifc_file), current_geom_gen),
lambda: self._build_geometry(connected),
)
except Exception:
+81 -128
View File
@@ -693,27 +693,6 @@ def get_connected_element_at_segment_port(segment, at_segment_start):
return tool.System.get_port_relating_element(connected_port)
def find_fitting_between_segments(segment_a, segment_b):
"""Single IfcFlowFitting bridging segment_a and segment_b via ports, or
``None`` if no fitting (or multiple fittings — only direct one-fitting
joins handled)."""
if not (segment_a.is_a("IfcFlowSegment") and segment_b.is_a("IfcFlowSegment")):
return None
b_ports_set = set(tool.System.get_ports(segment_b))
for a_port in tool.System.get_ports(segment_a):
connected_port = tool.System.get_connected_port(a_port)
if connected_port is None:
continue
fitting = tool.System.get_port_relating_element(connected_port)
if fitting is None or not fitting.is_a("IfcFlowFitting"):
continue
for fitting_port in tool.System.get_ports(fitting):
other_port = tool.System.get_connected_port(fitting_port)
if other_port is not None and other_port in b_ports_set:
return fitting
return None
def _resolve_active_mep_segment(operator, context):
"""Return the operator's target ``IfcFlowSegment`` or ``None`` after reporting.
@@ -808,52 +787,6 @@ class MEPAddObstruction(bpy.types.Operator, tool.Ifc.Operator):
return {"FINISHED"}
class MEPUnjoinAtPort(bpy.types.Operator, tool.Ifc.Operator):
"""Delete the IfcFlowFitting that bridges a segment's port to a second element.
Used when the connection at the port is in the JOINED state (the fitting
has at least one other port connecting to a different element). The
segment isn't resized — only the bridging fitting is removed. Refuses
to act on an OBSTRUCTION fitting (those are routed through
``bim.mep_add_obstruction`` with mode=REMOVE which extends the segment
to absorb the freed length)."""
bl_idname = "bim.mep_unjoin_at_port"
bl_label = "Unjoin MEP Segment at Port"
bl_description = "Disconnect the segment from the fitting at the named port (deletes the fitting)"
bl_options = {"REGISTER", "UNDO"}
segment_id: bpy.props.IntProperty(name="Segment Element ID", default=0)
position: bpy.props.EnumProperty(
name="Port",
items=[
("START", "At Start", "Operate on the segment's start port"),
("END", "At End", "Operate on the segment's end port"),
],
default="END",
)
def _execute(self, context):
resolved = _require_port_state(self, context, PORT_JOINED, "joining")
if resolved is None:
return {"CANCELLED"}
element, at_segment_start = resolved
fitting = get_connected_element_at_segment_port(element, at_segment_start)
if fitting is None or not fitting.is_a("IfcFlowFitting"):
self.report({"ERROR"}, "Connected port does not lead to a fitting.")
return {"CANCELLED"}
if getattr(fitting, "PredefinedType", None) == "OBSTRUCTION":
self.report({"ERROR"}, "Obstruction fittings are removed via bim.mep_add_obstruction (mode=REMOVE).")
return {"CANCELLED"}
fitting_obj = tool.Ifc.get_object(fitting)
if fitting_obj is None:
self.report({"ERROR"}, "Fitting has no Blender object.")
return {"CANCELLED"}
tool.Geometry.delete_ifc_object(fitting_obj)
return {"FINISHED"}
class MEPRemoveTerminalFitting(bpy.types.Operator, tool.Ifc.Operator):
"""Remove the terminal fitting at a segment's named port.
@@ -906,44 +839,6 @@ class MEPRemoveTerminalFitting(bpy.types.Operator, tool.Ifc.Operator):
return {"FINISHED"}
class MEPUnjoinPair(bpy.types.Operator, tool.Ifc.Operator):
"""Delete the IfcFlowFitting joining two selected MEP segments.
Removes the fitting; segments are left in place for the user to reposition."""
bl_idname = "bim.mep_unjoin_pair"
bl_label = "Unjoin MEP Segments"
bl_description = "Delete the fitting joining the two selected MEP segments"
bl_options = {"REGISTER", "UNDO"}
@classmethod
def poll(cls, context):
if not _n_mep_selected(2):
cls.poll_message_set("Select exactly 2 MEP segments joined by a fitting.")
return False
return True
def _execute(self, context):
selected_objs = tool.Blender.get_selected_objects()
elements = [tool.Ifc.get_entity(o) for o in selected_objs]
if any(e is None or not e.is_a("IfcFlowSegment") for e in elements):
self.report({"ERROR"}, "Both selected objects must be MEP segments.")
return {"CANCELLED"}
fitting = find_fitting_between_segments(elements[0], elements[1])
if fitting is None:
self.report({"ERROR"}, "No single fitting joins the selected segments.")
return {"CANCELLED"}
if getattr(fitting, "PredefinedType", None) == "OBSTRUCTION":
self.report({"ERROR"}, "Obstruction fittings are removed via bim.mep_add_obstruction (mode=REMOVE).")
return {"CANCELLED"}
fitting_obj = tool.Ifc.get_object(fitting)
if fitting_obj is None:
self.report({"ERROR"}, "Fitting has no Blender object.")
return {"CANCELLED"}
tool.Geometry.delete_ifc_object(fitting_obj)
return {"FINISHED"}
class SelectMEPPathMembers(bpy.types.Operator):
"""Replace the selection with every MEP element reachable from the active
one via IfcRelConnectsPorts — the entire connected distribution network."""
@@ -2677,10 +2572,22 @@ def _active_mep_has_connected_neighbor(obj: bpy.types.Object) -> bool:
def _active_is_bend_fitting(obj: bpy.types.Object) -> bool:
"""True iff the active object is a parametric BEND fitting eligible for
the bend-preview re-edit path. Re-edit reads parameters from the type's
``BBIM_Fitting`` pset, so that pset's presence is the ground truth for
re-editability — not the body representation class. The bend creation
path tessellates the swept-disk body as an upstream-geometry-kernel
workaround, so a freshly-committed bend's body contains only an
``IfcTriangulatedFaceSet`` and ``has_parametric_body`` correctly
returns False for it; the pset gate is what keeps the pen icon
eligible."""
element = tool.Ifc.get_entity(obj)
if not _is_bend_fitting(element):
return False
return tool.System.has_parametric_body(element)
element_type = ifcopenshell.util.element.get_type(element)
if element_type is None:
return False
return ifcopenshell.util.element.get_pset(element_type, "BBIM_Fitting") is not None
class GizmoMEPActions(bpy.types.GizmoGroup, gizmo.BaseIconActionGroup):
@@ -2763,20 +2670,20 @@ class GizmoMEPActions(bpy.types.GizmoGroup, gizmo.BaseIconActionGroup):
),
IconActionConfig(
name="unjoin_start",
icon="VIEW3D_GT_unjoin",
operator="bim.mep_unjoin_at_port",
icon="VIEW3D_GT_wall_link_toggle",
operator="bim.disconnect_elements",
visibility_condition=lambda obj: _selection_size() == 1 and _active_is_flow_segment(obj),
),
IconActionConfig(
name="unjoin_end",
icon="VIEW3D_GT_unjoin",
operator="bim.mep_unjoin_at_port",
icon="VIEW3D_GT_wall_link_toggle",
operator="bim.disconnect_elements",
visibility_condition=lambda obj: _selection_size() == 1 and _active_is_flow_segment(obj),
),
IconActionConfig(
name="unjoin_pair",
icon="VIEW3D_GT_unjoin",
operator="bim.mep_unjoin_pair",
icon="VIEW3D_GT_wall_link_toggle",
operator="bim.disconnect_elements",
visibility_condition=lambda _active: _n_mep_selected(2),
),
]
@@ -2794,7 +2701,17 @@ class GizmoMEPActions(bpy.types.GizmoGroup, gizmo.BaseIconActionGroup):
element = tool.Ifc.get_entity(obj)
if element is None or not tool.System.is_mep_element(element):
return False
return tool.System.has_parametric_body(element)
if tool.System.has_parametric_body(element):
return True
# Bend fittings carry their parametric definition in the type's
# ``BBIM_Fitting`` pset because the bend creation path tessellates
# the swept-disk body (upstream geometry-kernel workaround), so
# ``has_parametric_body`` returns False for them. Fall back to the
# pset gate so the pen icon (re_edit_bend) stays reachable.
element_type = ifcopenshell.util.element.get_type(element)
if element_type is None:
return False
return ifcopenshell.util.element.get_pset(element_type, "BBIM_Fitting") is not None
def setup(self, context: bpy.types.Context) -> None:
super().setup(context)
@@ -2802,11 +2719,13 @@ class GizmoMEPActions(bpy.types.GizmoGroup, gizmo.BaseIconActionGroup):
@classmethod
def _wire_anchored_icon_targets(cls, group) -> None:
"""Pre-fill ``position`` (and ``mode`` for open-lock) on each anchored
icon so a click dispatches to the right port without a per-frame
property write; apply the warning-red hover colour to destructive
icons. Takes any object with ``action_<name>_gizmo`` attributes so
tests can exercise the wiring without instantiating the GizmoGroup."""
"""Pre-fill ``position`` (and ``mode`` for open-lock) on the lock
icons so a click dispatches to the right port without a per-frame
property write, and pre-bind the unified ``bim.disconnect_elements``
operator on each unjoin icon so :py:meth:`position_gizmos` only has
to update the two GUIDs per frame. Takes any object with
``action_<name>_gizmo`` attributes so tests can exercise the wiring
without instantiating the GizmoGroup."""
for config_name, (_icon, position_arg) in cls.LOCK_ICON_CONFIGS.items():
gz = getattr(group, f"action_{config_name}_gizmo", None)
if gz is None:
@@ -2820,19 +2739,12 @@ class GizmoMEPActions(bpy.types.GizmoGroup, gizmo.BaseIconActionGroup):
op_props = gz.target_set_operator("bim.mep_remove_terminal_fitting")
op_props.position = position_arg
for config_name, position_arg in (("unjoin_start", "START"), ("unjoin_end", "END")):
gz = getattr(group, f"action_{config_name}_gizmo", None)
if gz is None:
continue
op_props = gz.target_set_operator("bim.mep_unjoin_at_port")
op_props.position = position_arg
warning_color = gizmo.get_warning_color_from_prefs(tool.Blender.get_addon_preferences())
group.unjoin_op_props = {}
for config_name in cls.UNJOIN_CONFIGS:
gz = getattr(group, f"action_{config_name}_gizmo", None)
if gz is None:
continue
gz.color_highlight = warning_color
group.unjoin_op_props[config_name] = gz.target_set_operator("bim.disconnect_elements")
def position_gizmos(self, context: bpy.types.Context) -> None:
"""Lay out icons across three regions: row above bbox top, segment
@@ -2899,6 +2811,10 @@ class GizmoMEPActions(bpy.types.GizmoGroup, gizmo.BaseIconActionGroup):
if not visible:
gz.hide = True
continue
if config.name.startswith("unjoin_"):
if not self._bind_unjoin_at_port(config.name, obj, endpoint_kind == "START"):
gz.hide = True
continue
if segment_endpoints is None:
segment_endpoints = tool.Model.get_flow_segment_axis(obj)
start_world, end_world = segment_endpoints
@@ -2910,7 +2826,7 @@ class GizmoMEPActions(bpy.types.GizmoGroup, gizmo.BaseIconActionGroup):
if len(selected) == 2:
elements = [tool.Ifc.get_entity(o) for o in selected]
if all(e is not None and e.is_a("IfcFlowSegment") for e in elements):
pair_fitting = find_fitting_between_segments(elements[0], elements[1]) or False
pair_fitting = tool.System.find_bridging_fitting(elements[0], elements[1]) or False
else:
pair_fitting = False
else:
@@ -2922,6 +2838,13 @@ class GizmoMEPActions(bpy.types.GizmoGroup, gizmo.BaseIconActionGroup):
gz.hide = True
continue
if config.name == "unjoin_pair":
selected = tool.Blender.get_selected_objects()
pair_elements = [tool.Ifc.get_entity(o) for o in selected]
if not self._bind_unjoin_pair(pair_elements):
gz.hide = True
continue
if not bend_anchor_attempted:
bend_anchor = compute_mep_join_location()
bend_anchor_attempted = True
@@ -2950,3 +2873,33 @@ class GizmoMEPActions(bpy.types.GizmoGroup, gizmo.BaseIconActionGroup):
if name in self.ENDPOINT_CONFIGS:
return self.ICON_SCALE * self.ENDPOINT_SCALE_RATIO
return self.ICON_SCALE
def _bind_unjoin_at_port(self, config_name: str, segment_obj: bpy.types.Object, at_segment_start: bool) -> bool:
"""Resolve the fitting at the named port and bind both GUIDs on the
pre-wired ``bim.disconnect_elements`` op_props. Returns False when
the partner is unresolvable (port not joined to a disconnectable
fitting), and the caller hides the icon."""
element = tool.Ifc.get_entity(segment_obj)
if element is None:
return False
fitting = get_connected_element_at_segment_port(element, at_segment_start)
if fitting is None or not fitting.is_a("IfcFlowFitting"):
return False
if getattr(fitting, "PredefinedType", None) == "OBSTRUCTION":
return False
op_props = self.unjoin_op_props[config_name]
op_props.element_a_guid = element.GlobalId
op_props.element_b_guid = fitting.GlobalId
return True
def _bind_unjoin_pair(self, pair_elements: list[ifcopenshell.entity_instance | None]) -> bool:
"""Bind both segment GUIDs on the pair-disconnect icon's pre-wired
``bim.disconnect_elements`` op_props. Returns False when either side
is missing a GlobalId (e.g. selection lost an active object), and
the caller hides the icon."""
if len(pair_elements) != 2 or any(e is None for e in pair_elements):
return False
op_props = self.unjoin_op_props["unjoin_pair"]
op_props.element_a_guid = pair_elements[0].GlobalId
op_props.element_b_guid = pair_elements[1].GlobalId
return True
+22 -8
View File
@@ -27,6 +27,12 @@ disconnect-on-delete). Each rel kind returned by
:py:meth:`find_rels_for_element` maps to a single arm here, so adding a new
rel kind means extending one dispatch table both call sites benefit
automatically and the AST forward-compat guard enforces coverage.
For the ``"mep-pair-fitting"`` kind the ``rel`` slot carries the
``IfcFlowFitting`` itself rather than a relationship entity the subject
whose removal disconnects the pair. The dispatch treats the slot as the
deletion target and routes through ``geometry.delete_ifc_object`` so the
cascade-on-delete contract still owns port-rel cleanup.
"""
from __future__ import annotations
@@ -37,21 +43,20 @@ import bonsai.core.geometry
from bonsai.core.model import regenerate_wall_to_underside
if TYPE_CHECKING:
import bpy
import ifcopenshell
import bonsai.tool as tool
def disconnect_rel(
ifc: "type[tool.Ifc]",
geometry: "type[tool.Geometry]",
model: "type[tool.Model]",
connection: "type[tool.Connection]",
rel: "ifcopenshell.entity_instance",
ifc: type[tool.Ifc],
geometry: type[tool.Geometry],
model: type[tool.Model],
connection: type[tool.Connection],
rel: ifcopenshell.entity_instance,
kind: str,
elem: "ifcopenshell.entity_instance",
partner: "ifcopenshell.entity_instance",
elem: ifcopenshell.entity_instance,
partner: ifcopenshell.entity_instance,
skip_elem_recreate: bool = False,
skip_partner_recreate: bool = False,
) -> None:
@@ -95,5 +100,14 @@ def disconnect_rel(
relating_element=rel.RelatingElement,
related_element=rel.RelatedElement,
)
elif kind == "mep-pair-fitting":
fitting = rel # slot semantics: the fitting whose removal disconnects the pair.
if skip_elem_recreate and fitting is elem:
return
if skip_partner_recreate and fitting is partner:
return
fitting_obj = ifc.get_object(fitting)
if fitting_obj is not None:
geometry.delete_ifc_object(fitting_obj)
else:
raise ValueError(f"Unknown rel kind: {kind!r}")
+32 -16
View File
@@ -29,6 +29,11 @@ operator dispatch the right post-disconnect cleanup:
- ``"element-top"`` for ``IfcRelConnectsElements`` with ``Description=="TOP"``
(the rel kind ``extend_walls_to_underside`` creates)
- ``"element"`` for any other ``IfcRelConnectsElements``
- ``"mep-pair-fitting"`` for an MEP pair whose disconnect is effected by
removing a bridging ``IfcFlowFitting``. The ``rel`` slot for this kind
carries the fitting entity itself (not a relationship entity) the
subject whose removal disconnects the pair. ``OBSTRUCTION`` fittings are
excluded; those are removed via ``bim.mep_add_obstruction(mode=REMOVE)``.
Add new rel kinds by extending :py:meth:`Connection.find_rel`. The disconnect
operator's cleanup switch maps each kind to the right post-mutation calls."""
@@ -37,6 +42,8 @@ from __future__ import annotations
from typing import TYPE_CHECKING
import bonsai.tool as tool
if TYPE_CHECKING:
import ifcopenshell
@@ -45,9 +52,9 @@ class Connection:
@classmethod
def find_rels(
cls,
elem_a: "ifcopenshell.entity_instance",
elem_b: "ifcopenshell.entity_instance",
) -> "list[tuple[ifcopenshell.entity_instance, str]]":
elem_a: ifcopenshell.entity_instance,
elem_b: ifcopenshell.entity_instance,
) -> list[tuple[ifcopenshell.entity_instance, str]]:
"""Return every supported rel linking ``elem_a`` to ``elem_b`` as a
list of ``(rel, kind)`` tuples. Walks both ``ConnectedTo`` and
``ConnectedFrom`` because either side of the rel can be the relating
@@ -79,14 +86,18 @@ class Connection:
kind = "element-top" if getattr(rel, "Description", None) == "TOP" else "element"
_record(rel, kind)
fitting = tool.System.find_bridging_fitting(elem_a, elem_b)
if fitting is not None:
_record(fitting, "mep-pair-fitting")
return rels
@classmethod
def find_rel(
cls,
elem_a: "ifcopenshell.entity_instance",
elem_b: "ifcopenshell.entity_instance",
) -> "tuple[ifcopenshell.entity_instance | None, str | None]":
elem_a: ifcopenshell.entity_instance,
elem_b: ifcopenshell.entity_instance,
) -> tuple[ifcopenshell.entity_instance | None, str | None]:
"""Return the first ``(rel, kind)`` or ``(None, None)``. Cheaper than
``find_rels`` when callers only need to know whether a connection
exists or what kind it is."""
@@ -96,17 +107,22 @@ class Connection:
@classmethod
def find_rels_for_element(
cls,
elem: "ifcopenshell.entity_instance",
) -> "list[tuple[ifcopenshell.entity_instance, str, ifcopenshell.entity_instance]]":
elem: ifcopenshell.entity_instance,
) -> list[tuple[ifcopenshell.entity_instance, str, ifcopenshell.entity_instance]]:
"""Return every supported rel touching ``elem`` as ``(rel, kind, partner)``
triples. ``partner`` is the *other* element on the rel the side cascade
cleanup must operate on when ``elem`` is being deleted.
Mirrors :py:meth:`find_rels`'s kind taxonomy. The single-element entry
point lets the cascade-on-delete in ``tool.Geometry.delete_ifc_object``
enumerate everything the disconnect operator would handle pairwise.
Mirrors :py:meth:`find_rels`'s relationship-kind taxonomy. Notably
does NOT emit ``"mep-pair-fitting"`` triples: ``IfcRelConnectsPorts``
cleanup is owned by ``tool.Geometry.delete_ifc_object``'s
``remove_port`` loop, which runs unconditionally on any IFC root
deletion. Including MEP here would cause the cascade to also remove
the bridging fitting when one of its connected segments is deleted
a policy choice (fitting may still join other live segments) that's
better left to the user via the explicit disconnect operator.
"""
result: list[tuple["ifcopenshell.entity_instance", str, "ifcopenshell.entity_instance"]] = []
result: list[tuple[ifcopenshell.entity_instance, str, ifcopenshell.entity_instance]] = []
seen: set[int] = set()
def _record(rel, kind, partner):
@@ -133,10 +149,10 @@ class Connection:
@classmethod
def orient_element_top(
cls,
rel: "ifcopenshell.entity_instance",
elem_a: "ifcopenshell.entity_instance",
elem_b: "ifcopenshell.entity_instance",
) -> "tuple[ifcopenshell.entity_instance, ifcopenshell.entity_instance]":
rel: ifcopenshell.entity_instance,
elem_a: ifcopenshell.entity_instance,
elem_b: ifcopenshell.entity_instance,
) -> tuple[ifcopenshell.entity_instance, ifcopenshell.entity_instance]:
"""Return ``(wall, slab)`` for an ``IfcRelConnectsElements(TOP)`` rel.
The ``extend_walls_to_underside`` flow stores slab as the relating
+63
View File
@@ -488,6 +488,69 @@ class System(bonsai.core.tool.System):
def is_mep_element(cls, element: ifcopenshell.entity_instance) -> bool:
return element.is_a("IfcFlowSegment") or element.is_a("IfcFlowFitting")
@classmethod
def is_disconnectable_fitting(cls, element: ifcopenshell.entity_instance) -> bool:
"""A fitting whose deletion is the supported teardown for one of
its port connections. ``OBSTRUCTION`` fittings are excluded they
have a dedicated grow/shrink flow (``bim.mep_add_obstruction``
with ``mode=REMOVE``) that absorbs the freed segment length."""
if not element.is_a("IfcFlowFitting"):
return False
return getattr(element, "PredefinedType", None) != "OBSTRUCTION"
@classmethod
def neighbours_at_ports(cls, element: ifcopenshell.entity_instance) -> list[ifcopenshell.entity_instance]:
"""Entities reachable from ``element``'s ports via a single
``IfcRelConnectsPorts`` hop, deduped by IFC id."""
neighbours: list[ifcopenshell.entity_instance] = []
seen: set[int] = set()
for port in cls.get_ports(element):
connected_port = cls.get_connected_port(port)
if connected_port is None:
continue
neighbour = ifcopenshell.util.system.get_port_element(connected_port)
if neighbour is None or neighbour.id() in seen:
continue
seen.add(neighbour.id())
neighbours.append(neighbour)
return neighbours
@classmethod
def find_bridging_fitting(
cls,
elem_a: ifcopenshell.entity_instance,
elem_b: ifcopenshell.entity_instance,
) -> Union[ifcopenshell.entity_instance, None]:
"""Return the disconnectable ``IfcFlowFitting`` whose removal
disconnects ``elem_a`` from ``elem_b``, or ``None``.
Two topologies are handled. (1) Direct port-to-port between a
segment/fitting and a disconnectable fitting: the fitting endpoint
is returned. (2) Two segments joined by a single bridging
disconnectable fitting: the bridging fitting is returned.
``OBSTRUCTION`` fittings short-circuit to ``None``."""
if not (cls.is_mep_element(elem_a) and cls.is_mep_element(elem_b)):
return None
a_neighbours = cls.neighbours_at_ports(elem_a)
b_neighbours = cls.neighbours_at_ports(elem_b)
elem_a_id = elem_a.id()
elem_b_id = elem_b.id()
if cls.is_disconnectable_fitting(elem_a) and any(n.id() == elem_b_id for n in a_neighbours):
return elem_a
if cls.is_disconnectable_fitting(elem_b) and any(n.id() == elem_a_id for n in b_neighbours):
return elem_b
a_fittings = [n for n in a_neighbours if cls.is_disconnectable_fitting(n)]
if not a_fittings:
return None
b_fitting_ids = {n.id() for n in b_neighbours if cls.is_disconnectable_fitting(n)}
for fitting in a_fittings:
if fitting.id() in b_fitting_ids:
return fitting
return None
@classmethod
def has_parametric_body(cls, element: ifcopenshell.entity_instance) -> bool:
"""True when the MEP element's body representation is a profile sweep
@@ -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()