mirror of
https://github.com/IfcOpenShell/IfcOpenShell.git
synced 2026-08-10 17:58:20 +00:00
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:
@@ -275,6 +275,7 @@ classes = (
|
||||
mep.EnableBendPreview,
|
||||
mep.FinishBendPreview,
|
||||
mep.CancelBendPreview,
|
||||
mep.EnableBendPreviewFromBend,
|
||||
mep.GizmoBendPreview,
|
||||
mep.EnableEditingPipeSegment,
|
||||
mep.FinishEditingPipeSegment,
|
||||
|
||||
@@ -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(),
|
||||
|
||||
Reference in New Issue
Block a user