diff --git a/src/bonsai/bonsai/bim/handler.py b/src/bonsai/bonsai/bim/handler.py index 58647d55bc..9ed9c201c5 100644 --- a/src/bonsai/bonsai/bim/handler.py +++ b/src/bonsai/bonsai/bim/handler.py @@ -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. diff --git a/src/bonsai/bonsai/bim/module/model/__init__.py b/src/bonsai/bonsai/bim/module/model/__init__.py index de4398f7d7..9b750a0e7c 100644 --- a/src/bonsai/bonsai/bim/module/model/__init__.py +++ b/src/bonsai/bonsai/bim/module/model/__init__.py @@ -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, diff --git a/src/bonsai/bonsai/bim/module/model/decorator.py b/src/bonsai/bonsai/bim/module/model/decorator.py index 81d2b2f1d5..d7b739d4c2 100644 --- a/src/bonsai/bonsai/bim/module/model/decorator.py +++ b/src/bonsai/bonsai/bim/module/model/decorator.py @@ -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. diff --git a/src/bonsai/bonsai/bim/module/model/mep.py b/src/bonsai/bonsai/bim/module/model/mep.py index c57d19495e..95d62a7ffe 100644 --- a/src/bonsai/bonsai/bim/module/model/mep.py +++ b/src/bonsai/bonsai/bim/module/model/mep.py @@ -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 --------------- diff --git a/src/bonsai/test/bim/module/model/test_mep_bend_preview.py b/src/bonsai/test/bim/module/model/test_mep_bend_preview.py new file mode 100644 index 0000000000..9d3678d68f --- /dev/null +++ b/src/bonsai/test/bim/module/model/test_mep_bend_preview.py @@ -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 . +# +# 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() diff --git a/src/bonsai/test/bim/module/model/test_mep_segment_edition.py b/src/bonsai/test/bim/module/model/test_mep_segment_edition.py index d07d563e29..2d4b218f5c 100644 --- a/src/bonsai/test/bim/module/model/test_mep_segment_edition.py +++ b/src/bonsai/test/bim/module/model/test_mep_segment_edition.py @@ -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.