Add bend re-edit gizmo

Once a bend was created, the only way to retune start_length /
end_length / radius was to delete and recreate from scratch.
EnableBendPreviewFromBend re-opens the preview on an existing
parametric bend: it walks the bend's ports to resolve the two
connected segments, reads start / end length and radius from the
bend type's BBIM_Fitting pset, and sets editing_bend_id on the
preview props. MEPAddBend then deletes the old bend + its port
connections (single undo step) before the recreate path runs, so
finish replaces the bend in place and cancel discards the edit
without touching the original.

GizmoMEPActions surfaces a pen icon on single bend-fitting
selections via the new _active_is_bend_fitting predicate; the icon
dispatches the new operator. Mirror of the wall fillet re-edit
flow (EnableWallFilletPreviewFromCorner + editing_corner_id in
CreateWallFillet).

Test coverage: registration probe for the new operator, an attached
editing_bend_id field probe on the preview umbrella, and a
parametrized truth-table for the _is_bend_fitting predicate
(IfcFlowFitting with BEND PredefinedType, with other PredefinedType,
with no type, IfcFlowSegment, IfcWall, None).

Generated with the assistance of an AI coding tool.
This commit is contained in:
Gorgious56
2026-06-09 16:37:16 +02:00
parent e0ceda6856
commit 784f0b1fe2
4 changed files with 200 additions and 0 deletions
@@ -275,6 +275,7 @@ classes = (
mep.EnableBendPreview,
mep.FinishBendPreview,
mep.CancelBendPreview,
mep.EnableBendPreviewFromBend,
mep.GizmoBendPreview,
mep.EnableEditingPipeSegment,
mep.FinishEditingPipeSegment,
+131
View File
@@ -1243,12 +1243,35 @@ class MEPAddBend(bpy.types.Operator, tool.Ifc.Operator):
radius: bpy.props.FloatProperty(
name="Bend Inner Radius", description="Bend inner radius in SI units", default=0.2, subtype="DISTANCE", min=0
)
editing_bend_id: bpy.props.IntProperty(
name="Existing Bend Element ID",
default=0,
description="When non-zero, delete this bend fitting + its port connections before creating the new bend.",
)
def _execute(self, context):
start_element, end_element = None, None
ifc_file = tool.Ifc.get()
si_conversion = ifcopenshell.util.unit.calculate_unit_scale(ifc_file)
# Re-edit path: delete the old bend fitting + its port connections so
# the segments are free to be re-joined by a fresh bend below. Runs
# inside the same operator transaction as the recreate so a single
# Ctrl+Z rewinds both.
if self.editing_bend_id:
try:
old_bend = ifc_file.by_id(self.editing_bend_id)
except RuntimeError:
old_bend = None
if old_bend is not None:
for port in tool.System.get_ports(old_bend):
rel = next(iter(port.ConnectedFrom + port.ConnectedTo), None)
if rel is not None and rel.is_a("IfcRelConnectsPorts"):
bonsai.core.geometry.remove_connection(tool.Geometry, connection=rel)
old_bend_obj = tool.Ifc.get_object(old_bend)
if old_bend_obj is not None:
tool.Geometry.delete_ifc_object(old_bend_obj)
if self.start_segment_id and self.end_segment_id:
start_element = ifc_file.by_id(self.start_segment_id)
end_element = ifc_file.by_id(self.end_segment_id)
@@ -1911,6 +1934,7 @@ class FinishBendPreview(bpy.types.Operator):
start_length=props.start_length,
end_length=props.end_length,
radius=props.radius,
editing_bend_id=props.editing_bend_id,
)
except RuntimeError as exc:
self.report({"ERROR"}, str(exc))
@@ -1938,6 +1962,103 @@ class CancelBendPreview(bpy.types.Operator):
return {"FINISHED"}
class EnableBendPreviewFromBend(bpy.types.Operator):
"""Re-open the bend preview on an existing bend fitting.
Resolves the two connected segments via the bend's ports +
``IfcRelConnectsPorts``, reads parametric values back from the bend's
``BBIM_Fitting`` pset, and flags the preview so committing replaces
the existing bend in place."""
bl_idname = "bim.enable_bend_preview_from_bend"
bl_label = "Edit Bend"
bl_description = "Re-open the bend preview to retune an existing bend"
bl_options = {"REGISTER", "UNDO"}
@classmethod
def poll(cls, context):
active = context.active_object
if active is None:
cls.poll_message_set("No active object.")
return False
element = tool.Ifc.get_entity(active)
if element is None or not _is_bend_fitting(element):
cls.poll_message_set("Active object must be a bend fitting.")
return False
return True
def execute(self, context):
active = context.active_object
bend_element = tool.Ifc.get_entity(active)
if bend_element is None or not _is_bend_fitting(bend_element):
self.report({"ERROR"}, "Active object is not a bend fitting.")
return {"CANCELLED"}
connected_segments: list = []
for port in tool.System.get_ports(bend_element):
connected_port = tool.System.get_connected_port(port)
if connected_port is None:
continue
related = tool.System.get_port_relating_element(connected_port)
if related is not None and related.is_a("IfcFlowSegment") and related not in connected_segments:
connected_segments.append(related)
if len(connected_segments) != 2:
self.report(
{"ERROR"},
f"Bend has {len(connected_segments)} connected segments; need exactly 2 to re-edit.",
)
return {"CANCELLED"}
# Read parametric values from the bend type's BBIM_Fitting pset. The
# type carries the canonical parameters; querying the occurrence
# would force a get_type round-trip and miss user-edited types.
bend_type = ifcopenshell.util.element.get_type(bend_element)
if bend_type is None:
self.report({"ERROR"}, "Bend fitting has no type to read parameters from.")
return {"CANCELLED"}
bend_type_obj = tool.Ifc.get_object(bend_type)
if bend_type_obj is None:
self.report({"ERROR"}, "Bend type has no Blender object — cannot read pset.")
return {"CANCELLED"}
bbim = tool.Model.get_modeling_bbim_pset_data(bend_type_obj, "BBIM_Fitting")
if bbim is None:
self.report({"ERROR"}, "Bend fitting has no BBIM_Fitting pset — not a parametric bend.")
return {"CANCELLED"}
data = bbim.get("data_dict", {})
props = preview_base.get_preview_props(context, "bend")
if props is not None and props.is_active:
bpy.ops.bim.cancel_bend_preview()
# Segment order is load-bearing: the bend's lateral sign and z-axis
# flip are derived from which segment is "start" vs "end". Re-edit
# must reuse the same pairing as the original create so the recreate
# lands at the same orientation.
start_segment, end_segment = connected_segments
props.start_segment_id = start_segment.id()
props.end_segment_id = end_segment.id()
# Pset values are in IFC native units; scene units come from si_conversion.
si_conversion = ifcopenshell.util.unit.calculate_unit_scale(tool.Ifc.get())
props.start_length = float(data.get("start_length", 0.1)) * si_conversion
props.end_length = float(data.get("end_length", 0.1)) * si_conversion
props.radius = float(data.get("radius", 0.2)) * si_conversion
props.editing_bend_id = bend_element.id()
props.is_active = True
return {"FINISHED"}
def _is_bend_fitting(element) -> bool:
"""True iff ``element`` is an ``IfcFlowFitting`` whose type carries
``PredefinedType="BEND"``."""
if element is None or not element.is_a("IfcFlowFitting"):
return False
element_type = ifcopenshell.util.element.get_type(element)
if element_type is None:
return False
return getattr(element_type, "PredefinedType", None) == "BEND"
def _intersection_past_near(intersection: Vector, near: Vector, far: Vector) -> bool:
"""True iff ``intersection`` lies past ``near`` away from ``far`` — i.e.
on the bend-corner side of the segment. Used to reject configurations
@@ -2918,6 +3039,10 @@ def _active_mep_has_connected_neighbor(obj: bpy.types.Object) -> bool:
return False
def _active_is_bend_fitting(obj: bpy.types.Object) -> bool:
return _is_bend_fitting(tool.Ifc.get_entity(obj))
class GizmoMEPActions(bpy.types.GizmoGroup, gizmo.BaseIconActionGroup):
"""Icon-action gizmos for the MEP one-shot operators.
@@ -2966,6 +3091,12 @@ class GizmoMEPActions(bpy.types.GizmoGroup, gizmo.BaseIconActionGroup):
operator="bim.select_mep_path_members",
visibility_condition=lambda obj: _selection_size() == 1 and _active_mep_has_connected_neighbor(obj),
),
IconActionConfig(
name="re_edit_bend",
icon="VIEW3D_GT_pen",
operator="bim.enable_bend_preview_from_bend",
visibility_condition=lambda obj: _selection_size() == 1 and _active_is_bend_fitting(obj),
),
IconActionConfig(
name="lock_start_open",
icon="VIEW3D_GT_lock_open",
@@ -2066,6 +2066,16 @@ class BIMBendPreviewProperties(PropertyGroup):
subtype="DISTANCE",
description="Inner radius of the bend curve",
)
editing_bend_id: bpy.props.IntProperty(
default=0,
options={"SKIP_SAVE"},
description=(
"IFC element id of an existing bend fitting being re-edited "
"(non-zero only on the pen-icon re-edit flow). The create "
"operator deletes this bend + its port connections before "
"recreating with the new parameters."
),
)
if TYPE_CHECKING:
is_active: bool
@@ -2074,6 +2084,7 @@ class BIMBendPreviewProperties(PropertyGroup):
start_length: float
end_length: float
radius: float
editing_bend_id: int
class BIMWallFilletPreviewProperties(PropertyGroup):
@@ -266,6 +266,62 @@ def test_bend_preview_decorator_class_present():
assert hasattr(BendPreviewDecorator, "uninstall")
def test_enable_bend_preview_from_bend_is_registered():
"""The re-edit entry point is discoverable via ``bpy.ops.bim`` so the
pen-icon dispatch in ``GizmoMEPActions`` resolves at click time."""
assert hasattr(bpy.ops.bim, "enable_bend_preview_from_bend")
def test_bim_bend_preview_properties_has_editing_bend_id():
"""The re-edit dispatch flag rides on the same preview PropertyGroup as
the rest of the bend draft state. Without this field on the umbrella,
re-edit cancel / commit cleanup would not zero it via
``clear_preview_state`` (which iterates ``*_id`` IntProperty fields)."""
bend_props = bpy.context.scene.BIMPreviewProperties.bend
assert hasattr(bend_props, "editing_bend_id")
assert bend_props.editing_bend_id == 0
@pytest.mark.parametrize(
"ifc_class,predefined_type,expected",
[
("IfcFlowFitting", "BEND", True),
("IfcFlowFitting", "TRANSITION", False),
("IfcFlowFitting", "OBSTRUCTION", False),
("IfcFlowFitting", None, False),
("IfcFlowSegment", "BEND", False),
("IfcWall", "BEND", False),
],
)
def test_is_bend_fitting_predicate_truth_table(ifc_class, predefined_type, expected):
"""The predicate classifies each occurrence by walking up to its type's
``PredefinedType``. Pin the four-way branch: matching class + matching
type, matching class + other type, wrong class, no type at all."""
from unittest.mock import Mock
from bonsai.bim.module.model.mep import _is_bend_fitting
element = Mock()
element.is_a = Mock(side_effect=lambda c: c == ifc_class)
if predefined_type is None:
element_type = None
else:
element_type = Mock()
element_type.PredefinedType = predefined_type
with patch("ifcopenshell.util.element.get_type", return_value=element_type):
assert _is_bend_fitting(element) is expected
def test_is_bend_fitting_predicate_returns_false_on_none():
"""The predicate is total — callers pass it raw ``tool.Ifc.get_entity``
results which can be ``None`` for unbound Blender objects, and the
visibility-condition lambda must not raise from a gizmo poll."""
from bonsai.bim.module.model.mep import _is_bend_fitting
assert _is_bend_fitting(None) is False
# ---------------------------------------------------------------------------
# Finish-catches-RuntimeError contract
# ---------------------------------------------------------------------------
@@ -294,6 +350,7 @@ def test_finish_bend_preview_catches_runtime_error_from_dispatch():
start_length=0.1,
end_length=0.1,
radius=0.2,
editing_bend_id=0,
)
context = SimpleNamespace(
screen=MagicMock(),