Add GizmoMEPActions + bend precondition + obstruction modes

The MEP one-shot operators (join, unjoin variants, terminal removal,
path-select, obstruction add/remove) had no viewport surface. This
commit adds GizmoMEPActions — the icon-action gizmo group that
surfaces them as billboarded icons around selected MEP elements.
Three anchor regions: a horizontal row above the bbox top
(selection-cardinality icons), per-port endpoints for the three-state
lock / unjoin icons (open lock for PORT_FREE, closed for
PORT_TERMINAL, unjoin for PORT_JOINED — resolved per-frame from
port_connection_state), and the predicted join location
(compute_mep_join_location, shared with the bend preview) for the
join / unjoin_pair pair. Unjoin icons render at full
DEFAULT_BILLBOARD_SCALE with warning-red hover; endpoint lock icons
shrink so the lock row stays subordinate to the row icons. The
group hides itself entirely while a bend preview is active.

MEPAddObstruction grew a position enum (CURSOR / START / END) and a
mode enum (ADD / REMOVE / TOGGLE) so the gizmo can target a specific
port without touching the cursor and dispatch ADD or REMOVE based on
the click target — the lock_open icons drive ADD with position
pinned, the lock_closed icons drive bim.mep_remove_terminal_fitting.
Without the new fields the gizmo wiring (op_props.position = ...)
crashed at setup() with AttributeError on the obstruction operator.

validate_bend_preconditions extracts the type-match and profile-kind
checks MEPAddBend enforces so EnableBendPreview surfaces the
rejection immediately — the user no longer tunes a preview only to
learn at commit time that the segments use an unsupported profile
(e.g. IfcArbitraryClosedProfileDef).

Generated with the assistance of an AI coding tool.
This commit is contained in:
Gorgious56
2026-06-09 12:44:19 +02:00
parent 0df1f0cf49
commit ad672d0edb
3 changed files with 343 additions and 9 deletions
@@ -288,6 +288,7 @@ classes = (
mep.SplitDuctSegmentAtCursor,
mep.GizmoPipeSegmentEdition,
mep.GizmoDuctSegmentEdition,
mep.GizmoMEPActions,
external.ApplyExternalParametricGeometry,
)
@@ -2108,6 +2108,25 @@ def _fill_quads_alpha(
gpu.state.blend_set("NONE")
def compute_mep_join_location():
"""Midpoint between the closest endpoint pair of two selected MEP
segments — the world location where a connecting fitting (bend /
transition) would land. Returns ``None`` when prerequisites aren't met
(wrong cardinality, mixed non-MEP)."""
selected = list(tool.Blender.get_selected_objects())
if len(selected) != 2:
return None
for obj in selected:
element = tool.Ifc.get_entity(obj)
if element is None or not tool.System.is_mep_element(element):
return None
a_start, a_end = tool.Model.get_flow_segment_axis(selected[0])
b_start, b_end = tool.Model.get_flow_segment_axis(selected[1])
pairs = [(a_start, b_start), (a_start, b_end), (a_end, b_start), (a_end, b_end)]
closest = min(pairs, key=lambda p: (p[0] - p[1]).length)
return (closest[0] + closest[1]) * 0.5
class MEPSegmentExtendPreviewDecorator(tool.Blender.ViewportDecorator):
"""Preview line for the MEP segment extend-to-cursor gizmo. Renders one
line from the segment's current end to the cursor's projection on the
+323 -9
View File
@@ -41,7 +41,7 @@ from mathutils import Matrix, Vector
import bonsai.core.root
import bonsai.tool as tool
from bonsai.bim.module.drawing import gizmos as gizmo
from bonsai.bim.module.drawing.gizmos import DimensionGizmoConfig
from bonsai.bim.module.drawing.gizmos import DimensionGizmoConfig, IconActionConfig
from bonsai.bim.module.model import preview_base
from bonsai.bim.module.model.profile import DumbProfileJoiner
from bonsai.bim.parametric_lifecycle import ParametricEditMixinBase
@@ -716,12 +716,30 @@ def find_fitting_between_segments(segment_a, segment_b):
class MEPAddObstruction(bpy.types.Operator, tool.Ifc.Operator):
bl_idname = "bim.mep_add_obstruction"
bl_label = "Add Obstruction"
bl_description = "Adds obstruction to the MEP segment"
bl_description = "Add, remove, or toggle an obstruction on the MEP segment"
bl_options = {"REGISTER", "UNDO"}
length: bpy.props.FloatProperty(
name="Obstruction Length", description="Obstruction length in SI units", default=0.1, subtype="DISTANCE"
)
segment_id: bpy.props.IntProperty(name="Segment Element ID", default=0)
position: bpy.props.EnumProperty(
name="Obstruction Position",
items=[
("CURSOR", "At Cursor", "Choose start/end automatically from the 3D cursor position"),
("START", "At Start", "Pin the obstruction to the segment's start port"),
("END", "At End", "Pin the obstruction to the segment's end port"),
],
default="CURSOR",
)
mode: bpy.props.EnumProperty(
name="Mode",
items=[
("ADD", "Add", "Create a new obstruction at the named port"),
("REMOVE", "Remove", "Remove the obstruction at the named port"),
("TOGGLE", "Toggle", "Add if no obstruction is present; remove if one is"),
],
default="ADD",
)
def _execute(self, context):
if self.segment_id:
@@ -735,14 +753,25 @@ class MEPAddObstruction(bpy.types.Operator, tool.Ifc.Operator):
self.report({"ERROR"}, f"Failed to add obstruction - object is not a MEP segment: {element.is_a()}.")
return {"CANCELLED"}
# derive obstruction position from the cursor
cursor_location = bpy.context.scene.cursor.location
obj = tool.Ifc.get_object(element)
axis = tool.Model.get_flow_segment_axis(obj)
# check if cursor is closer to the segment start
at_segment_start = tool.Cad.edge_percent(cursor_location, axis) < 0.5
if self.position == "CURSOR":
cursor_location = bpy.context.scene.cursor.location
obj = tool.Ifc.get_object(element)
axis = tool.Model.get_flow_segment_axis(obj)
at_segment_start = tool.Cad.edge_percent(cursor_location, axis) < 0.5
else:
at_segment_start = self.position == "START"
obstruction, error_msg = MEPGenerator().add_obstruction(element, self.length, at_segment_start)
# TOGGLE resolves to ADD / REMOVE based on the current port state so the
# generator dispatch below only handles the two terminal modes.
effective_mode = self.mode
if effective_mode == "TOGGLE":
effective_mode = "REMOVE" if find_obstruction_at_port(element, at_segment_start) is not None else "ADD"
generator = MEPGenerator()
if effective_mode == "REMOVE":
_removed, error_msg = generator.remove_obstruction(element, at_segment_start)
else:
_obstruction, error_msg = generator.add_obstruction(element, self.length, at_segment_start)
if error_msg:
self.report({"ERROR"}, error_msg)
return {"CANCELLED"}
@@ -1592,6 +1621,27 @@ def segments_are_parallel(start_object, end_object) -> bool:
return tool.Cad.are_edges_parallel(start_axis, end_axis)
def validate_bend_preconditions(start_element, end_element) -> str | None:
"""Return a user-facing error string when ``MEPAddBend`` would reject the
two segments, or ``None`` when the bend is supported. Mirrors the early
checks in ``MEPAddBend._execute`` so callers (preview enable, gizmo
poll, dispatcher) can surface the same diagnostic immediately instead
of after the user tunes a preview that cannot commit."""
start_type = ifcopenshell.util.element.get_type(start_element)
end_type = ifcopenshell.util.element.get_type(end_element)
if not start_type or not end_type or start_type != end_type:
return "Segments types do not match or one of the segments doesn't have type which is required for a bend."
profile = tool.Model.get_flow_segment_profile(start_element)
if profile is None:
return "Segment profile could not be resolved."
if not profile.is_a("IfcRectangleProfileDef") and not profile.is_a("IfcCircleProfileDef"):
return (
"For now Only IfcRectangleProfileDef/IfcCircleProfileDef profiles supported for a bend, "
f"the segments are {profile.is_a()}"
)
return None
class MEPJoinSegments(bpy.types.Operator):
"""Dispatcher: join two MEP segments via transition (parallel) or bend
(non-parallel).
@@ -1664,6 +1714,13 @@ class EnableBendPreview(bpy.types.Operator):
self.report({"ERROR"}, "Bend preview is for non-parallel segments only.")
return {"CANCELLED"}
# Pre-check the same preconditions MEPAddBend enforces so the user
# sees the rejection here rather than after tuning a doomed preview.
precondition_error = validate_bend_preconditions(active_element, other_element)
if precondition_error is not None:
self.report({"ERROR"}, precondition_error)
return {"CANCELLED"}
preview_base.sync_uncommitted_moves([active, other])
props = preview_base.get_preview_props(context, "bend")
@@ -2601,3 +2658,260 @@ class GizmoDuctSegmentEdition(bpy.types.GizmoGroup, _MEPSegmentEditionMixin, giz
@classmethod
def is_element_type(cls, element):
return tool.Parametric.is_duct_segment(element)
# --- GizmoMEPActions group + visibility helpers ----------------------------
def _selection_size() -> int:
return len(tool.Blender.get_selected_objects())
def _active_is_flow_segment(obj: bpy.types.Object) -> bool:
element = tool.Ifc.get_entity(obj)
if element is None:
return False
return element.is_a("IfcFlowSegment")
def _active_mep_has_connected_neighbor(obj: bpy.types.Object) -> bool:
"""True iff the active MEP element has at least one port connected to
another element. Hides the path-select icon when clicking would yield
the same single-member selection."""
element = tool.Ifc.get_entity(obj)
if element is None or not tool.System.is_mep_element(element):
return False
for port in tool.System.get_ports(element):
if tool.System.get_connected_port(port) is not None:
return True
return False
class GizmoMEPActions(bpy.types.GizmoGroup, gizmo.BaseIconActionGroup):
"""Icon-action gizmos for the MEP one-shot operators.
Most icons sit in a horizontal row above the active object's bbox top.
Lock icons are anchored at the segment's start / end ports and rendered
at half scale as secondary affordances. Visibility predicates gate each
icon on selection cardinality and IFC class; ``position_gizmos`` resolves
the open-vs-closed-vs-unjoin three-state at each port from
``port_connection_state``."""
bl_idname = "OBJECT_GGT_bim_mep_actions"
bl_label = "MEP Actions Gizmo"
bl_space_type = "VIEW_3D"
bl_region_type = "WINDOW"
bl_options = {"3D", "PERSISTENT"}
ENDPOINT_CONFIGS: ClassVar[dict[str, str]] = {
"lock_start_open": "START",
"lock_start_closed": "START",
"lock_end_open": "END",
"lock_end_closed": "END",
"unjoin_start": "START",
"unjoin_end": "END",
} # fmt: skip
BEND_ANCHOR_CONFIGS: ClassVar[set[str]] = {"join", "unjoin_pair"}
UNJOIN_CONFIGS: ClassVar[set[str]] = {"unjoin_start", "unjoin_end", "unjoin_pair"}
ENDPOINT_SCALE_RATIO: ClassVar[float] = 0.5
LOCK_ICON_CONFIGS: ClassVar[dict[str, tuple[str, str]]] = {
"lock_start_open": ("VIEW3D_GT_lock_open", "START"),
"lock_start_closed": ("VIEW3D_GT_lock_closed", "START"),
"lock_end_open": ("VIEW3D_GT_lock_open", "END"),
"lock_end_closed": ("VIEW3D_GT_lock_closed", "END"),
} # fmt: skip
action_configs = [
IconActionConfig(
name="join",
icon="VIEW3D_GT_merge",
operator="bim.mep_join_segments",
visibility_condition=lambda _active: _n_mep_selected(2),
),
IconActionConfig(
name="select_path",
icon="VIEW3D_GT_array_all",
operator="bim.select_mep_path_members",
visibility_condition=lambda obj: _selection_size() == 1 and _active_mep_has_connected_neighbor(obj),
),
IconActionConfig(
name="lock_start_open",
icon="VIEW3D_GT_lock_open",
operator="bim.mep_add_obstruction",
visibility_condition=lambda obj: _selection_size() == 1 and _active_is_flow_segment(obj),
),
IconActionConfig(
name="lock_start_closed",
icon="VIEW3D_GT_lock_closed",
operator="bim.mep_remove_terminal_fitting",
visibility_condition=lambda obj: _selection_size() == 1 and _active_is_flow_segment(obj),
),
IconActionConfig(
name="lock_end_open",
icon="VIEW3D_GT_lock_open",
operator="bim.mep_add_obstruction",
visibility_condition=lambda obj: _selection_size() == 1 and _active_is_flow_segment(obj),
),
IconActionConfig(
name="lock_end_closed",
icon="VIEW3D_GT_lock_closed",
operator="bim.mep_remove_terminal_fitting",
visibility_condition=lambda obj: _selection_size() == 1 and _active_is_flow_segment(obj),
),
IconActionConfig(
name="unjoin_start",
icon="VIEW3D_GT_unjoin",
operator="bim.mep_unjoin_at_port",
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",
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",
visibility_condition=lambda _active: _n_mep_selected(2),
),
]
@classmethod
def is_eligible_object(cls, obj: bpy.types.Object) -> bool:
# Bend preview takes over the viewport for a focused edit flow —
# hide the whole action group while it's active so the validate /
# cancel buttons don't compete with these icons.
scene = bpy.context.scene
preview = getattr(scene, "BIMPreviewProperties", None) if scene else None
bend_props = preview.bend if preview is not None else None
if bend_props is not None and bend_props.is_active:
return False
element = tool.Ifc.get_entity(obj)
if element is None:
return False
return tool.System.is_mep_element(element)
def setup(self, context: bpy.types.Context) -> None:
super().setup(context)
# Pre-fill ``position`` (and ``mode`` for the open-lock obstruction
# add) on each anchored icon so the click goes to the right end
# without a per-frame property write.
for config_name, (_icon, position_arg) in self.LOCK_ICON_CONFIGS.items():
gz = getattr(self, f"action_{config_name}_gizmo", None)
if gz is None:
continue
is_open = config_name.endswith("_open")
if is_open:
op_props = gz.target_set_operator("bim.mep_add_obstruction")
op_props.position = position_arg
op_props.mode = "ADD"
else:
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(self, 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())
for config_name in self.UNJOIN_CONFIGS:
gz = getattr(self, f"action_{config_name}_gizmo", None)
if gz is None:
continue
gz.color_highlight = warning_color
def position_gizmos(self, context: bpy.types.Context) -> None:
"""Lay out icons across three regions: row above bbox top, segment
port endpoints (``ENDPOINT_CONFIGS``), and predicted bend / transition
location (``BEND_ANCHOR_CONFIGS``)."""
from bonsai.bim.module.model.decorator import compute_mep_join_location
obj = context.active_object
if obj is None:
return
billboard_rot = gizmo.get_billboard_rotation(context)
z_top = max((c[2] for c in obj.bound_box), default=0.0)
z_anchor = z_top + self.ICON_ROW_Z_OFFSET
segment_endpoints: tuple[Vector, Vector] | None = None
bend_anchor: Vector | None = None
port_state_at: dict[str, str] = {}
# pair_fitting tri-state: None = not computed; False = computed, no
# fitting joins the pair; <entity> = the joining fitting.
pair_fitting: object = None
row_index = 0
for config in self.action_configs:
gz = getattr(self, f"action_{config.name}_gizmo", None)
if gz is None:
continue
if config.visibility_condition is not None and not config.visibility_condition(obj):
gz.hide = True
continue
gz.hide = False
scale = self._scale_for_config(config.name)
endpoint_kind = self.ENDPOINT_CONFIGS.get(config.name)
if endpoint_kind is not None:
if endpoint_kind not in port_state_at:
element = tool.Ifc.get_entity(obj)
port_state_at[endpoint_kind] = (
port_connection_state(element, endpoint_kind == "START") if element else PORT_FREE
)
state = port_state_at[endpoint_kind]
if config.name.startswith("unjoin_"):
visible = state == PORT_JOINED
else:
is_closed_icon = config.name.endswith("_closed")
visible = (state == PORT_TERMINAL) if is_closed_icon else (state == PORT_FREE)
if not visible:
gz.hide = True
continue
if segment_endpoints is None:
segment_endpoints = tool.Model.get_flow_segment_axis(obj)
start_world, end_world = segment_endpoints
anchor = start_world if endpoint_kind == "START" else end_world
gz.matrix_basis = gizmo.billboarded_at(anchor, billboard_rot, scale=scale)
elif config.name in self.BEND_ANCHOR_CONFIGS:
if pair_fitting is None:
selected = tool.Blender.get_selected_objects()
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
else:
pair_fitting = False
else:
pair_fitting = False
wants_fitting = config.name == "unjoin_pair"
fitting_present = bool(pair_fitting)
if wants_fitting != fitting_present:
gz.hide = True
continue
if bend_anchor is None:
bend_anchor = compute_mep_join_location()
if bend_anchor is None:
gz.hide = True
continue
gz.matrix_basis = gizmo.billboarded_at(bend_anchor, billboard_rot, scale=scale)
else:
local_pos = Vector((row_index * self.ICON_SPACING_X, 0.0, z_anchor))
world_pos = obj.matrix_world @ local_pos
gz.matrix_basis = gizmo.billboarded_at(world_pos, billboard_rot, scale=scale)
row_index += 1
def _scale_for_config(self, name: str) -> float:
if name in self.UNJOIN_CONFIGS:
return gizmo.DEFAULT_BILLBOARD_SCALE
if name in self.ENDPOINT_CONFIGS:
return self.ICON_SCALE * self.ENDPOINT_SCALE_RATIO
return self.ICON_SCALE