mirror of
https://github.com/IfcOpenShell/IfcOpenShell.git
synced 2026-08-06 07:51:47 +00:00
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:
@@ -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.
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user