mirror of
https://github.com/IfcOpenShell/IfcOpenShell.git
synced 2026-09-23 12:32:39 +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.EnableBendPreview,
|
||||||
mep.FinishBendPreview,
|
mep.FinishBendPreview,
|
||||||
mep.CancelBendPreview,
|
mep.CancelBendPreview,
|
||||||
|
mep.EnableBendPreviewFromBend,
|
||||||
mep.GizmoBendPreview,
|
mep.GizmoBendPreview,
|
||||||
mep.EnableEditingPipeSegment,
|
mep.EnableEditingPipeSegment,
|
||||||
mep.FinishEditingPipeSegment,
|
mep.FinishEditingPipeSegment,
|
||||||
|
|||||||
@@ -1243,12 +1243,35 @@ class MEPAddBend(bpy.types.Operator, tool.Ifc.Operator):
|
|||||||
radius: bpy.props.FloatProperty(
|
radius: bpy.props.FloatProperty(
|
||||||
name="Bend Inner Radius", description="Bend inner radius in SI units", default=0.2, subtype="DISTANCE", min=0
|
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):
|
def _execute(self, context):
|
||||||
start_element, end_element = None, None
|
start_element, end_element = None, None
|
||||||
ifc_file = tool.Ifc.get()
|
ifc_file = tool.Ifc.get()
|
||||||
si_conversion = ifcopenshell.util.unit.calculate_unit_scale(ifc_file)
|
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:
|
if self.start_segment_id and self.end_segment_id:
|
||||||
start_element = ifc_file.by_id(self.start_segment_id)
|
start_element = ifc_file.by_id(self.start_segment_id)
|
||||||
end_element = ifc_file.by_id(self.end_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,
|
start_length=props.start_length,
|
||||||
end_length=props.end_length,
|
end_length=props.end_length,
|
||||||
radius=props.radius,
|
radius=props.radius,
|
||||||
|
editing_bend_id=props.editing_bend_id,
|
||||||
)
|
)
|
||||||
except RuntimeError as exc:
|
except RuntimeError as exc:
|
||||||
self.report({"ERROR"}, str(exc))
|
self.report({"ERROR"}, str(exc))
|
||||||
@@ -1938,6 +1962,103 @@ class CancelBendPreview(bpy.types.Operator):
|
|||||||
return {"FINISHED"}
|
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:
|
def _intersection_past_near(intersection: Vector, near: Vector, far: Vector) -> bool:
|
||||||
"""True iff ``intersection`` lies past ``near`` away from ``far`` — i.e.
|
"""True iff ``intersection`` lies past ``near`` away from ``far`` — i.e.
|
||||||
on the bend-corner side of the segment. Used to reject configurations
|
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
|
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):
|
class GizmoMEPActions(bpy.types.GizmoGroup, gizmo.BaseIconActionGroup):
|
||||||
"""Icon-action gizmos for the MEP one-shot operators.
|
"""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",
|
operator="bim.select_mep_path_members",
|
||||||
visibility_condition=lambda obj: _selection_size() == 1 and _active_mep_has_connected_neighbor(obj),
|
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(
|
IconActionConfig(
|
||||||
name="lock_start_open",
|
name="lock_start_open",
|
||||||
icon="VIEW3D_GT_lock_open",
|
icon="VIEW3D_GT_lock_open",
|
||||||
|
|||||||
@@ -2066,6 +2066,16 @@ class BIMBendPreviewProperties(PropertyGroup):
|
|||||||
subtype="DISTANCE",
|
subtype="DISTANCE",
|
||||||
description="Inner radius of the bend curve",
|
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:
|
if TYPE_CHECKING:
|
||||||
is_active: bool
|
is_active: bool
|
||||||
@@ -2074,6 +2084,7 @@ class BIMBendPreviewProperties(PropertyGroup):
|
|||||||
start_length: float
|
start_length: float
|
||||||
end_length: float
|
end_length: float
|
||||||
radius: float
|
radius: float
|
||||||
|
editing_bend_id: int
|
||||||
|
|
||||||
|
|
||||||
class BIMWallFilletPreviewProperties(PropertyGroup):
|
class BIMWallFilletPreviewProperties(PropertyGroup):
|
||||||
|
|||||||
@@ -266,6 +266,62 @@ def test_bend_preview_decorator_class_present():
|
|||||||
assert hasattr(BendPreviewDecorator, "uninstall")
|
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
|
# Finish-catches-RuntimeError contract
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
@@ -294,6 +350,7 @@ def test_finish_bend_preview_catches_runtime_error_from_dispatch():
|
|||||||
start_length=0.1,
|
start_length=0.1,
|
||||||
end_length=0.1,
|
end_length=0.1,
|
||||||
radius=0.2,
|
radius=0.2,
|
||||||
|
editing_bend_id=0,
|
||||||
)
|
)
|
||||||
context = SimpleNamespace(
|
context = SimpleNamespace(
|
||||||
screen=MagicMock(),
|
screen=MagicMock(),
|
||||||
|
|||||||
Reference in New Issue
Block a user