Add MEP bend preview decorator + join dispatcher

The bend preview gizmo group (commit 2) populated a Scene draft but
the user saw nothing in the viewport until they hit finish — they
had to commit blindly. This commit ports the BendPreviewDecorator
(centerline arc + two leg projections on valid geometry, warning-red
axes on invalid in-segment intersections) and the interactive
GizmoBendPreview group (three dimension widgets for start_length /
end_length / radius plus validate / cancel icons). The bend axis
math lives in a pure compute_bend_preview_polylines helper, fed
into both the gizmo group's per-frame positioning and the GPU
decorator's draw path. MEPSegmentExtendPreviewDecorator lands at
the same time because it shares the decorator install / uninstall
plumbing — renders the extend-to-cursor preview line for the
GizmoPipeSegmentEdition / GizmoDuctSegmentEdition extend icons
when hovered, clamping the projected endpoint to the operator's
minimum so the preview matches where the commit lands. The
MEPJoinSegments dispatcher routes two selected MEP segments to
mep_add_transition (parallel) or enable_bend_preview (non-parallel)
— the F3 search entry point that makes the bend preview testable
before the gizmo-icon dispatch lands.

11 new tests in test_mep_bend_preview.py cover the geometry helper
truth table (parallel rejection, right-angle happy path, near-
collinear rejection, in-segment invalid_axes), the
_intersection_past_near parametrized boundary, registration probes
for the lifecycle operators / join dispatcher / gizmo group /
decorator, and the FinishBendPreview RuntimeError catch contract.
6 extend-preview-line tests (deferred from commit 3) join the
existing 35 in test_mep_segment_edition.py.

Generated with the assistance of an AI coding tool.
This commit is contained in:
Gorgious56
2026-06-08 21:18:18 +02:00
parent 5b79cefee2
commit becbcfdfe7
6 changed files with 954 additions and 1 deletions
+9
View File
@@ -47,7 +47,9 @@ from bonsai.bim.module.model.array import (
)
from bonsai.bim.module.model.data import AuthoringData
from bonsai.bim.module.model.decorator import (
BendPreviewDecorator,
BoundingBoxDecorator,
MEPSegmentExtendPreviewDecorator,
SlabDirectionDecorator,
WallAxisDecorator,
WallFilletPreviewDecorator,
@@ -511,6 +513,8 @@ def _install_viewport_overlays() -> None:
WallAxisDecorator.uninstall()
SlabDirectionDecorator.uninstall()
WallFilletPreviewDecorator.uninstall()
BendPreviewDecorator.uninstall()
MEPSegmentExtendPreviewDecorator.uninstall()
WallGizmoPreviewDecorator.uninstall()
ArrayPreviewDecorator.uninstall()
ArraySelectionHighlightDecorator.uninstall()
@@ -532,6 +536,11 @@ def _install_viewport_overlays() -> None:
# wall_fillet.is_active, so installation has no cost when no preview
# is open. No corresponding addon-preference toggle.
WallFilletPreviewDecorator.install(bpy.context)
# Always-installed siblings of WallFilletPreviewDecorator: each
# self-polls on its own scene.BIMPreviewProperties subgroup or on
# selection + hover gizmo state — zero cost when nothing is active.
BendPreviewDecorator.install(bpy.context)
MEPSegmentExtendPreviewDecorator.install(bpy.context)
# Always-installed: draw_lines() self-polls on selection + hover state
# for join / extend-to-wall / cursor-extend / cursor-split previews.
# Free when no preview-eligible state is active.
@@ -266,9 +266,11 @@ classes = (
mep.MEPAddObstruction,
mep.MEPAddTransition,
mep.MEPAddBend,
mep.MEPJoinSegments,
mep.EnableBendPreview,
mep.FinishBendPreview,
mep.CancelBendPreview,
mep.GizmoBendPreview,
mep.EnableEditingPipeSegment,
mep.FinishEditingPipeSegment,
mep.CancelEditingPipeSegment,
@@ -2108,6 +2108,163 @@ def _fill_quads_alpha(
gpu.state.blend_set("NONE")
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
segment's local Z axis when the extend icon is hovered. Self-gates every
draw on the viewport gizmo toggle and the per-feature ``extend`` pref."""
draw_method = "draw_line"
LINE_WIDTH = 1.5
LINE_ALPHA = 0.8
def draw_line(self, context: bpy.types.Context) -> None:
if not tool.Blender.are_viewport_gizmos_enabled():
return
prefs = tool.Blender.get_addon_preferences()
active = context.active_object
if active is None:
return
selected = list(tool.Blender.get_selected_objects())
if active not in selected or len(selected) != 1:
return
element = tool.Ifc.get_entity(active)
if element is None:
return
from bonsai.bim.module.model.mep import (
GizmoDuctSegmentEdition,
GizmoPipeSegmentEdition,
)
if tool.Parametric.is_pipe_segment(element):
gizmo_prefs = getattr(prefs.gizmos, "pipe_segment", None)
gizmo_cls = GizmoPipeSegmentEdition
elif tool.Parametric.is_duct_segment(element):
gizmo_prefs = getattr(prefs.gizmos, "duct_segment", None)
gizmo_cls = GizmoDuctSegmentEdition
else:
return
if gizmo_prefs is None or not getattr(gizmo_prefs, "enabled", True):
return
if not self._cursor_icon_hovered(gizmo_cls, "extend_gizmo", context):
return
current_length = max(c[2] for c in active.bound_box) if active.bound_box else 0.0
line = self._compute_extend_preview_line(
active.matrix_world, context.scene.cursor.location, current_length, min_projected_length=0.01
)
if line is None:
return
start_world, end_world = line
color = tuple(prefs.decorator_color_selected[:3])
_stroke_lines_alpha(
context,
[(tuple(start_world), tuple(end_world))],
color,
self.LINE_WIDTH,
self.LINE_ALPHA,
)
@staticmethod
def _compute_extend_preview_line(
matrix_world: Matrix,
cursor_world: Vector,
current_length: float,
min_projected_length: float = 0.01,
) -> tuple[Vector, Vector] | None:
"""Returns ``(current_end_world, target_end_world)`` or ``None`` when
no extend would happen (degenerate segment, or cursor on the existing
end). Target follows the cursor's local Z clamped to
``min_projected_length`` so the preview matches where the operator
actually commits (which floors at the minimum)."""
if current_length <= 0:
return None
cursor_local = matrix_world.inverted() @ cursor_world
if abs(cursor_local.z - current_length) < 1e-6:
return None
target_local_z = max(min_projected_length, cursor_local.z)
current_end_world = matrix_world @ Vector((0.0, 0.0, current_length))
target_end_world = matrix_world @ Vector((0.0, 0.0, target_local_z))
return current_end_world, target_end_world
class BendPreviewDecorator(tool.Blender.ViewportDecorator):
"""GPU preview lines for the bend-creation flow.
Polls on ``scene.BIMPreviewProperties.bend.is_active`` and renders the
centerline + leg projections returned by ``mep.compute_bend_preview_polylines``.
The two leg lines (segment tangent point) show how each segment will
be shortened; the arc polyline approximates the bend curve. On invalid
geometry, draws the two rejected axes in warning colour instead so the
user sees why the bend cannot be placed.
Installed once per Blender session from ``bim/handler.py:load_post``.
Cheap to leave running because the first thing ``draw`` does is check
``is_active`` and return when False.
"""
LINE_WIDTH_LEG = 1.5
LINE_WIDTH_ARC = 2.5
LINE_ALPHA = 0.7
def draw(self, context: bpy.types.Context) -> None:
scene = context.scene
preview = getattr(scene, "BIMPreviewProperties", None)
props = preview.bend if preview is not None else None
if props is None or not props.is_active:
return
ifc_file = tool.Ifc.get()
if ifc_file is None:
return
try:
start_element = ifc_file.by_id(props.start_segment_id)
end_element = ifc_file.by_id(props.end_segment_id)
except Exception:
return
start_obj = tool.Ifc.get_object(start_element) if start_element else None
end_obj = tool.Ifc.get_object(end_element) if end_element else None
if start_obj is None or end_obj is None:
return
# Late import: decorator.py loads at addon enable but mep.py imports
# this module for the extend preview, so a module-level import would
# cycle.
from bonsai.bim.module.model.mep import compute_bend_preview_polylines
preview = compute_bend_preview_polylines(start_obj, end_obj, props.start_length, props.end_length, props.radius)
prefs = tool.Blender.get_addon_preferences()
if not preview["valid"]:
warning_color = tuple(prefs.decorator_color_error[:3])
axes = preview.get("invalid_axes") or []
if axes:
segments = [(tuple(a), tuple(b)) for a, b in axes]
_stroke_lines_alpha(context, segments, warning_color, self.LINE_WIDTH_ARC, self.LINE_ALPHA)
return
leg_color = tuple(prefs.decorations_colour[:3])
arc_color = tuple(prefs.decorator_color_selected[:3])
leg_a_far, leg_a_end = preview["leg_a"]
leg_b_far, leg_b_end = preview["leg_b"]
_stroke_lines_alpha(
context,
[(tuple(leg_a_far), tuple(leg_a_end)), (tuple(leg_b_far), tuple(leg_b_end))],
leg_color,
self.LINE_WIDTH_LEG,
self.LINE_ALPHA,
)
arc = preview["arc"]
if len(arc) >= 2:
arc_segments = [(tuple(arc[i]), tuple(arc[i + 1])) for i in range(len(arc) - 1)]
_stroke_lines_alpha(context, arc_segments, arc_color, self.LINE_WIDTH_ARC, self.LINE_ALPHA)
class WallFilletPreviewDecorator(tool.Blender.ViewportDecorator):
"""GPU preview lines for the wall-fillet flow.
+361 -1
View File
@@ -21,7 +21,7 @@ import json
import re
import weakref
from copy import copy
from math import cos, degrees, pi, radians, sin, tan
from math import acos, cos, degrees, pi, radians, sin, tan
from typing import ClassVar
import bpy
@@ -1289,6 +1289,42 @@ def segments_are_parallel(start_object, end_object) -> bool:
return tool.Cad.are_edges_parallel(start_axis, end_axis)
class MEPJoinSegments(bpy.types.Operator):
"""Dispatcher: join two MEP segments via transition (parallel) or bend
(non-parallel).
``MEPAddTransition`` rejects non-parallel inputs; ``MEPAddBend`` rejects
parallel inputs (its axis-intersection step is undefined for parallel
lines). Collapsing them under one click target removes a per-frame
question the user shouldn't have to answer."""
bl_idname = "bim.mep_join_segments"
bl_label = "Join MEP Segments"
bl_description = "Join the two selected MEP segments — transition if parallel, bend if not"
bl_options = {"REGISTER", "UNDO"}
@classmethod
def poll(cls, context):
if not _n_mep_selected(2):
cls.poll_message_set("Select exactly 2 MEP segments to join.")
return False
return True
def execute(self, context):
selected = tool.Blender.get_selected_objects()
active = context.active_object
if active is None or active not in selected:
self.report({"ERROR"}, "Active object must be one of the selected MEP segments.")
return {"CANCELLED"}
other = next((o for o in selected if o is not active), None)
if other is None:
self.report({"ERROR"}, "Two MEP segments must be selected.")
return {"CANCELLED"}
if segments_are_parallel(active, other):
return bpy.ops.bim.mep_add_transition()
return bpy.ops.bim.enable_bend_preview()
class EnableBendPreview(bpy.types.Operator):
"""Enter bend-preview mode for two selected MEP segments. Populates
scene.BIMPreviewProperties.bend with segment IFC ids and default
@@ -1400,6 +1436,330 @@ class CancelBendPreview(bpy.types.Operator):
return {"FINISHED"}
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
where the axes meet INSIDE one of the segments (the bend fitting
wouldn't physically fit)."""
base = near - far
if base.length < 1e-6:
return False
return (intersection - near).dot(base.normalized()) > 1e-6
def compute_bend_preview_polylines(
start_object,
end_object,
start_length: float,
end_length: float,
radius: float,
arc_resolution: int = 24,
):
"""Compute the centerline polylines visualising a bend between two MEP
segments WITHOUT mutating IFC or Blender state.
Returns a dict with keys:
- ``"valid"`` (bool) False for parallel / collinear / degenerate axes
and for in-segment intersections.
- ``"leg_a"`` / ``"leg_b"`` ``(far_endpoint, tangent_point)`` per
segment, ``None`` when invalid.
- ``"arc"`` ``arc_resolution + 1`` points sampling the bend arc.
- ``"invalid_axes"`` (when invalid + in-segment) pair of
``(far_endpoint, intersection)`` so the decorator can highlight the
rejected axes in warning colour."""
from mathutils import Quaternion
start_axis = tool.Model.get_flow_segment_axis(start_object)
end_axis = tool.Model.get_flow_segment_axis(end_object)
intersection = tool.Cad.intersect_edges(start_axis, end_axis)
if intersection is None:
return {"valid": False, "leg_a": None, "leg_b": None, "arc": []}
intersection_point = intersection[0]
start_near, start_far = tool.Cad.closest_and_furthest_vectors(intersection_point, start_axis)
end_near, end_far = tool.Cad.closest_and_furthest_vectors(intersection_point, end_axis)
# The intersection MUST lie outside both segments — past the near-endpoint
# on the bend-corner side. When it lands inside a segment the tangent
# points overlap the segment itself and the arc sweeps through a
# degenerate half-circle.
invalid_axes = [
(start_far, intersection_point),
(end_far, intersection_point),
]
if not _intersection_past_near(intersection_point, start_near, start_far):
return {
"valid": False,
"reason": "intersection_inside_start",
"leg_a": None,
"leg_b": None,
"arc": [],
"invalid_axes": invalid_axes,
}
if not _intersection_past_near(intersection_point, end_near, end_far):
return {
"valid": False,
"reason": "intersection_inside_end",
"leg_a": None,
"leg_b": None,
"arc": [],
"invalid_axes": invalid_axes,
}
dir_into_start = start_near - intersection_point
dir_into_end = end_near - intersection_point
if dir_into_start.length < 1e-6 or dir_into_end.length < 1e-6:
return {"valid": False, "leg_a": None, "leg_b": None, "arc": []}
dir_into_start.normalize()
dir_into_end.normalize()
cos_angle = max(-1.0, min(1.0, dir_into_start.dot(dir_into_end)))
angle = acos(cos_angle)
bend_angle = pi - angle
if bend_angle < 1e-3 or bend_angle > pi - 1e-3:
return {"valid": False, "leg_a": None, "leg_b": None, "arc": []}
tangent_offset = radius * tan(bend_angle / 2)
leg_a_tangent = intersection_point + dir_into_start * tangent_offset
leg_b_tangent = intersection_point + dir_into_end * tangent_offset
leg_a_endpoint = leg_a_tangent + dir_into_start * start_length
leg_b_endpoint = leg_b_tangent + dir_into_end * end_length
plane_normal = dir_into_start.cross(dir_into_end)
if plane_normal.length < 1e-6:
return {"valid": False, "leg_a": None, "leg_b": None, "arc": []}
plane_normal.normalize()
perp_to_start = plane_normal.cross(dir_into_start).normalized()
if perp_to_start.dot(dir_into_end) < 0:
perp_to_start = -perp_to_start
arc_center = leg_a_tangent + perp_to_start * radius
v_a = leg_a_tangent - arc_center
v_b = leg_b_tangent - arc_center
sweep_axis = plane_normal if v_a.cross(v_b).dot(plane_normal) > 0 else -plane_normal
arc_points = []
for i in range(arc_resolution + 1):
t = i / arc_resolution
q = Quaternion(sweep_axis, bend_angle * t)
arc_points.append(arc_center + (q @ v_a))
return {
"valid": True,
"leg_a": (start_far, leg_a_endpoint),
"leg_b": (end_far, leg_b_endpoint),
"arc": arc_points,
}
def _bend_preview_segments(context):
"""Resolve the two segment objects from the scene-level preview props.
Re-resolves by IFC id each frame so undo / file reload during preview
never dangles a stale bpy reference."""
props = context.scene.BIMPreviewProperties.bend
ifc_file = tool.Ifc.get()
if ifc_file is None or not props.is_active:
return None, None
try:
start_element = ifc_file.by_id(props.start_segment_id)
end_element = ifc_file.by_id(props.end_segment_id)
except Exception:
return None, None
start_obj = tool.Ifc.get_object(start_element) if start_element else None
end_obj = tool.Ifc.get_object(end_element) if end_element else None
return start_obj, end_obj
def _gizmo_x_matrix(location: Vector, x_direction: Vector) -> Matrix:
"""Build a 4x4 matrix placing a gizmo at ``location`` with its local +X
axis aligned to ``x_direction`` in world space. ``BIM_GT_gizmo_dimension``
draws + drags along local +X by convention."""
x = x_direction.normalized()
seed = Vector((0, 0, 1)) if abs(x.z) < 0.9 else Vector((1, 0, 0))
y = (seed - x * seed.dot(x)).normalized()
z = x.cross(y)
mat = Matrix.Identity(4)
mat[0][:3] = (x.x, y.x, z.x)
mat[1][:3] = (x.y, y.y, z.y)
mat[2][:3] = (x.z, y.z, z.z)
mat.translation = location
return mat
class GizmoBendPreview(bpy.types.GizmoGroup):
"""Interactive gizmo group for the bend preview flow.
Three dimension widgets drag start_length / end_length / radius; two
icon gizmos commit or cancel. When the geometry is degenerate the
dimensions and validate hide but cancel stays visible so the user
always has an exit."""
bl_idname = "OBJECT_GGT_bim_bend_preview"
bl_label = "Bend Preview Gizmos"
bl_space_type = "VIEW_3D"
bl_region_type = "WINDOW"
bl_options = {"3D", "PERSISTENT"}
ICON_SCALE: ClassVar[float] = 0.375
ICON_SPACING_X: ClassVar[float] = 0.4
ICON_Z_OFFSET: ClassVar[float] = 1.5
@classmethod
def poll(cls, context):
preview = getattr(context.scene, "BIMPreviewProperties", None)
props = preview.bend if preview is not None else None
if props is None or not props.is_active:
return False
if not tool.Blender.are_viewport_gizmos_enabled():
return False
ifc_file = tool.Ifc.get()
if ifc_file is None:
return False
try:
ifc_file.by_id(props.start_segment_id)
ifc_file.by_id(props.end_segment_id)
except (RuntimeError, KeyError):
return False
return True
def setup(self, context):
prefs = tool.Blender.get_addon_preferences()
default_color = tuple(prefs.decorations_colour[:3])
highlight_color = tuple(prefs.decorator_color_selected[:3])
_props = preview_base.make_props_callback("bend")
def setup_dimension(attr: str, prop_name: str, invert_delta: bool = False) -> bpy.types.Gizmo:
gz = self.gizmos.new("BIM_GT_gizmo_dimension")
gz.move_get_cb = preview_base.make_dim_getter(_props, attr)
gz.move_set_cb = preview_base.make_dim_setter(_props, attr)
gz.axis = Vector((1, 0, 0))
gz.invert_delta = invert_delta
gz.delta_scale = 1.0
gz.prop_name = prop_name
gz.gizmo_group = self
gz.color = default_color
gz.color_highlight = highlight_color
gz.alpha = 1.0
gz.use_draw_modal = True
gz.use_draw_scale = False
gz.text_offset_sign = 1
gz.text_alignment = gizmo.TextAlignment.CENTER
gz.show_start_arrow = False
gz.show_end_arrow = True
gz.show_extension_lines = False
gz.text_formatter = None
return gz
self.start_dim = setup_dimension("start_length", "Start Length")
self.end_dim = setup_dimension("end_length", "End Length")
self.radius_dim = setup_dimension("radius", "Radius")
from bonsai.bim.module.drawing.gizmos import BaseParametricGizmoGroup
self.validate_icon = self.gizmos.new("VIEW3D_GT_validate")
self.validate_icon.use_draw_scale = False
self.validate_icon.color = BaseParametricGizmoGroup.COLOR_GREEN
self.validate_icon.color_highlight = highlight_color
self.validate_icon.target_set_operator("bim.finish_bend_preview")
self.cancel_icon = self.gizmos.new("VIEW3D_GT_cancel")
self.cancel_icon.use_draw_scale = False
self.cancel_icon.color = BaseParametricGizmoGroup.COLOR_RED
self.cancel_icon.color_highlight = highlight_color
self.cancel_icon.target_set_operator("bim.cancel_bend_preview")
def refresh(self, context):
self._position_gizmos(context)
def draw_prepare(self, context):
self._position_gizmos(context)
def _position_gizmos(self, context):
"""Place gizmos at the bend intersection using the current scene
props. Cancel stays visible on degenerate geometry so the user
always has an exit; the other widgets hide when there's no defined
tangent / arc to anchor them on."""
start_obj, end_obj = _bend_preview_segments(context)
if start_obj is None or end_obj is None:
for gz in (self.start_dim, self.end_dim, self.radius_dim, self.validate_icon, self.cancel_icon):
gz.hide = True
return
props = context.scene.BIMPreviewProperties.bend
preview = compute_bend_preview_polylines(start_obj, end_obj, props.start_length, props.end_length, props.radius)
if not preview["valid"]:
for gz in (self.start_dim, self.end_dim, self.radius_dim, self.validate_icon):
gz.hide = True
self.cancel_icon.hide = False
axes = preview.get("invalid_axes") or []
if axes:
intersection_point = axes[0][1]
billboard_rot = gizmo.get_billboard_rotation(context)
anchor = intersection_point + Vector((0, 0, self.ICON_Z_OFFSET))
self.cancel_icon.matrix_basis = gizmo.billboarded_at(anchor, billboard_rot, scale=self.ICON_SCALE)
return
for gz in (self.start_dim, self.end_dim, self.radius_dim, self.validate_icon, self.cancel_icon):
gz.hide = False
leg_a_far, leg_a_end = preview["leg_a"]
leg_b_far, leg_b_end = preview["leg_b"]
toward_bend_a = (
(leg_a_end - leg_a_far).normalized() if (leg_a_end - leg_a_far).length > 1e-6 else Vector((0, 0, 1))
)
toward_bend_b = (
(leg_b_end - leg_b_far).normalized() if (leg_b_end - leg_b_far).length > 1e-6 else Vector((0, 0, 1))
)
leg_a_tangent = leg_a_end + toward_bend_a * props.start_length
leg_b_tangent = leg_b_end + toward_bend_b * props.end_length
# axis is set in world space every frame so the drag projection
# matches the visual regardless of either segment's matrix_world.
self.start_dim.matrix_basis = _gizmo_x_matrix(leg_a_tangent, -toward_bend_a)
self.start_dim.axis = -toward_bend_a
self.start_dim.set_dimension_length(props.start_length)
self.end_dim.matrix_basis = _gizmo_x_matrix(leg_b_tangent, -toward_bend_b)
self.end_dim.axis = -toward_bend_b
self.end_dim.set_dimension_length(props.end_length)
arc = preview["arc"]
if len(arc) >= 3:
mid = len(arc) // 2
chord_mid = (arc[0] + arc[-1]) * 0.5
toward_mid = arc[mid] - chord_mid
if toward_mid.length > 1e-6:
toward_mid = toward_mid.normalized()
half_chord = (arc[-1] - arc[0]).length * 0.5
center_dist = max(0.0, props.radius * props.radius - half_chord * half_chord) ** 0.5
arc_center = chord_mid - toward_mid * center_dist
radial_out = arc[mid] - arc_center
if radial_out.length > 1e-6:
radial_out.normalize()
inward = -radial_out
self.radius_dim.matrix_basis = _gizmo_x_matrix(arc[mid], inward)
self.radius_dim.axis = inward
self.radius_dim.set_dimension_length(props.radius)
else:
self.radius_dim.hide = True
else:
self.radius_dim.hide = True
else:
self.radius_dim.hide = True
billboard_rot = gizmo.get_billboard_rotation(context)
anchor_base = arc[len(arc) // 2] if arc else (leg_a_end + leg_b_end) * 0.5
anchor = anchor_base + Vector((0, 0, self.ICON_Z_OFFSET))
offset_x = billboard_rot @ Vector((self.ICON_SPACING_X, 0.0, 0.0))
self.validate_icon.matrix_basis = gizmo.billboarded_at(anchor, billboard_rot, scale=self.ICON_SCALE)
self.cancel_icon.matrix_basis = gizmo.billboarded_at(anchor + offset_x, billboard_rot, scale=self.ICON_SCALE)
# --- MEP segment parametric edit + cursor-anchored operators ---------------
@@ -0,0 +1,314 @@
# 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.
"""Unit tests for the bend-preview flow scaffolding.
Covers three surfaces:
1. ``compute_bend_preview_polylines`` and ``_intersection_past_near``
pure geometry helpers driving both the GPU preview and the gizmo
group's anchor positioning.
2. Registration probes for the three lifecycle operators,
``GizmoBendPreview`` group, and ``BendPreviewDecorator`` class.
3. ``FinishBendPreview``'s RuntimeError catch — when the dispatched
``bim.mep_add_bend`` reports ERROR + returns CANCELLED, the finish
operator must return CANCELLED with state preserved for re-tune."""
from unittest.mock import MagicMock, Mock, patch
import bpy
import pytest
pytestmark = pytest.mark.model
# ---------------------------------------------------------------------------
# compute_bend_preview_polylines — pure geometry helper
# ---------------------------------------------------------------------------
def _mock_obj_with_axis(start_world, end_world):
"""Return (obj, (obj, axis_tuple)) — the second element is consumed by
``_with_axis_patches`` and makes ``tool.Model.get_flow_segment_axis(obj)``
return the supplied axis. No real Blender object needed."""
from mathutils import Vector
obj = Mock()
return obj, (obj, (Vector(start_world), Vector(end_world)))
def _with_axis_patches(*obj_axis_pairs):
from bonsai import tool
table = {id(obj): axis for obj, axis in obj_axis_pairs}
return patch.object(tool.Model, "get_flow_segment_axis", side_effect=lambda o: table.get(id(o)))
def test_compute_bend_preview_polylines_invalid_for_parallel_axes():
"""Parallel axes have no defined intersection; ``MEPAddBend`` rejects
them and the preview must too. Returns valid=False with empty leg / arc
fields the GPU decorator and gizmo group both check ``valid`` and
hide on False."""
from bonsai import tool
from bonsai.bim.module.model.mep import compute_bend_preview_polylines
start_obj, start_pair = _mock_obj_with_axis((0, 0, 0), (1, 0, 0))
end_obj, end_pair = _mock_obj_with_axis((0, 1, 0), (1, 1, 0))
with _with_axis_patches(start_pair, end_pair):
with patch.object(tool.Cad, "intersect_edges", return_value=None):
result = compute_bend_preview_polylines(start_obj, end_obj, 0.1, 0.1, 0.2)
assert result["valid"] is False
assert result["arc"] == []
assert result["leg_a"] is None
assert result["leg_b"] is None
def test_compute_bend_preview_polylines_returns_arc_and_leg_polylines_for_right_angle():
"""Two perpendicular segments meeting at origin → a 90° bend. Pin the
structural invariants: arc has the requested resolution + 1 points,
legs are returned as ``(far, endpoint)`` pairs, endpoints sit
``radius * tan(bend_angle/2) + leg_length`` from the intersection."""
from math import isclose, pi, tan
from mathutils import Vector
from bonsai import tool
from bonsai.bim.module.model.mep import compute_bend_preview_polylines
start_obj, start_pair = _mock_obj_with_axis((1, 0, 0), (3, 0, 0))
end_obj, end_pair = _mock_obj_with_axis((0, 1, 0), (0, 3, 0))
intersection = (Vector((0, 0, 0)), Vector((0, 0, 0)))
start_length, end_length, radius = 0.5, 0.5, 0.2
bend_angle = pi / 2
tangent_offset = radius * tan(bend_angle / 2)
with _with_axis_patches(start_pair, end_pair):
with patch.object(tool.Cad, "intersect_edges", return_value=intersection):
with patch.object(
tool.Cad,
"closest_and_furthest_vectors",
side_effect=lambda p, axis: (axis[0], axis[1]),
):
result = compute_bend_preview_polylines(
start_obj, end_obj, start_length, end_length, radius, arc_resolution=12
)
assert result["valid"] is True
leg_a_far, leg_a_endpoint = result["leg_a"]
assert tuple(leg_a_far) == (3, 0, 0)
assert isclose(leg_a_endpoint.x, tangent_offset + start_length, abs_tol=1e-6)
assert isclose(leg_a_endpoint.y, 0.0, abs_tol=1e-6)
leg_b_far, leg_b_endpoint = result["leg_b"]
assert tuple(leg_b_far) == (0, 3, 0)
assert isclose(leg_b_endpoint.x, 0.0, abs_tol=1e-6)
assert isclose(leg_b_endpoint.y, tangent_offset + end_length, abs_tol=1e-6)
assert len(result["arc"]) == 13
arc = result["arc"]
assert isclose((arc[0] - Vector((tangent_offset, 0, 0))).length, 0.0, abs_tol=1e-6)
assert isclose((arc[-1] - Vector((0, tangent_offset, 0))).length, 0.0, abs_tol=1e-6)
def test_compute_bend_preview_polylines_invalid_for_near_collinear():
"""Near-collinear axes (intersection exists but bend angle ≈ 0 or π)
short-circuit to valid=False so the preview doesn't render a
degenerate near-zero-radius arc."""
from mathutils import Vector
from bonsai import tool
from bonsai.bim.module.model.mep import compute_bend_preview_polylines
start_obj, start_pair = _mock_obj_with_axis((1, 0, 0), (3, 0, 0))
end_obj, end_pair = _mock_obj_with_axis((-1, 0, 0), (-3, 0, 0))
intersection = (Vector((0, 0, 0)), Vector((0, 0, 0)))
with _with_axis_patches(start_pair, end_pair):
with patch.object(tool.Cad, "intersect_edges", return_value=intersection):
with patch.object(
tool.Cad,
"closest_and_furthest_vectors",
side_effect=lambda p, axis: (axis[0], axis[1]),
):
result = compute_bend_preview_polylines(start_obj, end_obj, 0.1, 0.1, 0.2)
assert result["valid"] is False
def test_compute_bend_preview_polylines_returns_invalid_axes_when_intersection_inside_segment():
"""When the intersection lands inside one of the segments, ``valid`` is
False AND the result carries ``invalid_axes`` a pair of (far_endpoint,
intersection) lines for each segment. ``BendPreviewDecorator`` reads
these to draw warning-red axes instead of rendering a degenerate arc."""
from mathutils import Vector
from bonsai import tool
from bonsai.bim.module.model.mep import compute_bend_preview_polylines
start_obj, start_pair = _mock_obj_with_axis((-3, 0, 0), (-1, 0, 0))
end_obj, end_pair = _mock_obj_with_axis((0, 5, 0), (0, 3, 0))
intersection = (Vector((-2, 0, 0)), Vector((-2, 0, 0)))
with _with_axis_patches(start_pair, end_pair):
with patch.object(tool.Cad, "intersect_edges", return_value=intersection):
with patch.object(
tool.Cad,
"closest_and_furthest_vectors",
# axis[0] = closer endpoint (near), axis[1] = farther (far).
side_effect=lambda p, axis: (axis[1], axis[0]),
):
result = compute_bend_preview_polylines(start_obj, end_obj, 0.1, 0.1, 0.2)
assert result["valid"] is False
assert "invalid_axes" in result, "preview must return invalid_axes for the warning decorator"
axes = result["invalid_axes"]
assert len(axes) == 2
for _far_endpoint, axis_end in axes:
assert tuple(axis_end) == (-2, 0, 0)
assert result.get("reason") in ("intersection_inside_start", "intersection_inside_end")
# ---------------------------------------------------------------------------
# _intersection_past_near — degenerate-intersection guard for the preview
# ---------------------------------------------------------------------------
@pytest.mark.parametrize(
"intersection,near,far,expected",
[
# Normal: intersection past near, opposite side from far.
((0, 0, 0), (-1, 0, 0), (-3, 0, 0), True),
# Degenerate: intersection BETWEEN near and far (inside the segment).
((-2, 0, 0), (-1, 0, 0), (-3, 0, 0), False),
# Degenerate: intersection past FAR (opposite side from the bend).
((-4, 0, 0), (-1, 0, 0), (-3, 0, 0), False),
# Borderline: intersection coincides with near — within tolerance → False.
((-1, 0, 0), (-1, 0, 0), (-3, 0, 0), False),
# Degenerate: zero-length segment — can't classify, False.
((0, 0, 0), (-1, 0, 0), (-1, 0, 0), False),
],
)
def test_intersection_past_near(intersection, near, far, expected):
"""Pins the degenerate-intersection classification used by
``compute_bend_preview_polylines`` to reject in-segment intersections."""
from mathutils import Vector
from bonsai.bim.module.model.mep import _intersection_past_near
assert _intersection_past_near(Vector(intersection), Vector(near), Vector(far)) is expected
# ---------------------------------------------------------------------------
# Registration probes
# ---------------------------------------------------------------------------
def test_bend_preview_operators_are_registered():
"""The three bend-preview operators must resolve via ``bpy.ops.bim.*`` —
enable populates scene props, finish dispatches ``bim.mep_add_bend``
with the tuned params, cancel clears the state."""
assert hasattr(bpy.ops.bim, "enable_bend_preview")
assert hasattr(bpy.ops.bim, "finish_bend_preview")
assert hasattr(bpy.ops.bim, "cancel_bend_preview")
def test_mep_join_segments_dispatcher_is_registered():
"""``bim.mep_join_segments`` is the discoverable entry point for the
bend preview flow (F3 search "Join MEP Segments") until the full
gizmo-icon dispatch lands. Routes parallel transition, non-parallel
enable_bend_preview."""
assert hasattr(bpy.ops.bim, "mep_join_segments")
def test_bend_preview_gizmo_group_is_registered():
"""``GizmoBendPreview`` polls when ``scene.BIMPreviewProperties.bend.is_active``
is True. Pin the bl_idname so a typo wouldn't silently hide the preview
gizmos at runtime."""
from bonsai.bim.module.model.mep import GizmoBendPreview
assert GizmoBendPreview.bl_idname == "OBJECT_GGT_bim_bend_preview"
assert issubclass(GizmoBendPreview, bpy.types.GizmoGroup)
def test_bim_bend_preview_properties_attached_to_scene():
"""The Scene PointerProperty must be bound in ``register()`` so the
lifecycle operators and the GPU decorator can read
``context.scene.BIMPreviewProperties.bend.is_active``."""
assert hasattr(bpy.types.Scene, "BIMPreviewProperties")
assert hasattr(bpy.context.scene.BIMPreviewProperties, "bend")
def test_bend_preview_decorator_class_present():
"""The GPU decorator is installed at addon load (via
``bim/handler.py:load_post``). Verify the class exists with the
install / uninstall interface the handler expects."""
from bonsai.bim.module.model.decorator import BendPreviewDecorator
assert hasattr(BendPreviewDecorator, "install")
assert hasattr(BendPreviewDecorator, "uninstall")
# ---------------------------------------------------------------------------
# Finish-catches-RuntimeError contract
# ---------------------------------------------------------------------------
def test_finish_bend_preview_catches_runtime_error_from_dispatch():
"""When the dispatched ``bim.mep_add_bend`` reports ERROR + returns
CANCELLED, ``bpy.ops`` promotes that to RuntimeError. Finish must catch
it and return CANCELLED propagating the exception leaves Blender's
operator state half-broken. Preview state must remain active so the
user can re-tune."""
from types import SimpleNamespace
from bonsai import tool
from bonsai.bim.module.model.mep import FinishBendPreview
class _Stand:
def __init__(self):
self.report = MagicMock()
op_self = _Stand()
fake_props = SimpleNamespace(
is_active=True,
start_segment_id=42,
end_segment_id=43,
start_length=0.1,
end_length=0.1,
radius=0.2,
)
context = SimpleNamespace(
screen=MagicMock(),
scene=SimpleNamespace(BIMPreviewProperties=SimpleNamespace(bend=fake_props)),
)
mock_ops_bim = MagicMock()
mock_ops_bim.mep_add_bend.side_effect = RuntimeError("synthetic dispatch error")
with (
patch.object(tool.Ifc, "get", return_value=MagicMock(name="ifc_file")),
patch.object(bpy.ops, "bim", new=mock_ops_bim),
):
result = FinishBendPreview.execute(op_self, context)
assert "CANCELLED" in result, "RuntimeError from dispatch must be converted to CANCELLED"
assert fake_props.is_active is True, "failed dispatch must leave preview active for re-tune"
op_self.report.assert_called()
@@ -317,6 +317,117 @@ def test_segment_operators_are_registered(op):
assert hasattr(getattr(bpy.ops, namespace), verb), f"Operator {op!r} is not registered."
# ---------------------------------------------------------------------------
# MEPSegmentExtendPreviewDecorator._compute_extend_preview_line — pure helper
# ---------------------------------------------------------------------------
def test_extend_preview_line_returns_none_for_degenerate_segment():
"""A zero-length segment has no endpoint to draw from. Pin so a future
refactor doesn't divide-by-zero or render a phantom line at the
object origin."""
from bonsai.bim.module.model.decorator import MEPSegmentExtendPreviewDecorator
result = MEPSegmentExtendPreviewDecorator._compute_extend_preview_line(
matrix_world=Matrix.Identity(4),
cursor_world=Vector((0.0, 0.0, 1.0)),
current_length=0.0,
min_projected_length=0.01,
)
assert result is None
def test_extend_preview_line_returns_none_when_cursor_at_current_end():
"""If the cursor projection matches the current segment length exactly,
the extend operator would be a no-op don't render the line either."""
from bonsai.bim.module.model.decorator import MEPSegmentExtendPreviewDecorator
result = MEPSegmentExtendPreviewDecorator._compute_extend_preview_line(
matrix_world=Matrix.Identity(4),
cursor_world=Vector((0.0, 0.0, 1.5)),
current_length=1.5,
min_projected_length=0.01,
)
assert result is None
def test_extend_preview_line_renders_extension_when_cursor_past_end():
"""Happy path: cursor past current end → line runs from current end to
the cursor's projected length. Identity matrix: local-Z maps 1:1 to
world-Z. Pin the endpoints exactly."""
from bonsai.bim.module.model.decorator import MEPSegmentExtendPreviewDecorator
result = MEPSegmentExtendPreviewDecorator._compute_extend_preview_line(
matrix_world=Matrix.Identity(4),
cursor_world=Vector((0.0, 0.0, 3.0)),
current_length=1.0,
min_projected_length=0.01,
)
assert result is not None
start, end = result
assert tuple(start) == pytest.approx((0.0, 0.0, 1.0))
assert tuple(end) == pytest.approx((0.0, 0.0, 3.0))
def test_extend_preview_line_renders_trim_when_cursor_inside_segment():
"""Cursor inside the segment → line runs from current end BACK to the
projected (shorter) length."""
from bonsai.bim.module.model.decorator import MEPSegmentExtendPreviewDecorator
result = MEPSegmentExtendPreviewDecorator._compute_extend_preview_line(
matrix_world=Matrix.Identity(4),
cursor_world=Vector((0.0, 0.0, 0.4)),
current_length=1.0,
min_projected_length=0.01,
)
assert result is not None
start, end = result
assert tuple(start) == pytest.approx((0.0, 0.0, 1.0))
assert tuple(end) == pytest.approx((0.0, 0.0, 0.4))
def test_extend_preview_line_clamps_cursor_projection_to_minimum():
"""When the cursor's projected Z is negative (behind segment origin) or
near zero, the extend operator clamps to ``min_projected_length``. The
preview must match the same clamp so the line lands where the operator
would actually commit, not at the raw cursor position."""
from bonsai.bim.module.model.decorator import MEPSegmentExtendPreviewDecorator
result = MEPSegmentExtendPreviewDecorator._compute_extend_preview_line(
matrix_world=Matrix.Identity(4),
cursor_world=Vector((0.0, 0.0, -2.0)),
current_length=1.0,
min_projected_length=0.01,
)
assert result is not None
start, end = result
assert tuple(start) == pytest.approx((0.0, 0.0, 1.0))
assert tuple(end) == pytest.approx((0.0, 0.0, 0.01))
def test_extend_preview_line_respects_object_rotation():
"""A rotated segment (90° around Y) should produce world-space endpoints
rotated accordingly. Pin so a future refactor doesn't drop the
matrix_world multiplication."""
import math
from bonsai.bim.module.model.decorator import MEPSegmentExtendPreviewDecorator
rotation = Matrix.Rotation(math.pi / 2, 4, "Y")
result = MEPSegmentExtendPreviewDecorator._compute_extend_preview_line(
matrix_world=rotation,
cursor_world=Vector((3.0, 0.0, 0.0)),
current_length=1.0,
min_projected_length=0.01,
)
assert result is not None
start, end = result
# local (0, 0, 1) rotated by 90° around Y → world (1, 0, 0).
assert tuple(start) == pytest.approx((1.0, 0.0, 0.0), abs=1e-6)
# local (0, 0, 3) rotated by 90° around Y → world (3, 0, 0).
assert tuple(end) == pytest.approx((3.0, 0.0, 0.0), abs=1e-6)
# ---------------------------------------------------------------------------
# Lifecycle drift handling — Enable / Finish / Cancel must commit / restore
# matrix_world ↔ IFC ObjectPlacement at the appropriate lifecycle points.