diff --git a/src/bonsai/bonsai/bim/module/drawing/gizmos.py b/src/bonsai/bonsai/bim/module/drawing/gizmos.py index 15961c3c4c..ba1cd10bec 100644 --- a/src/bonsai/bonsai/bim/module/drawing/gizmos.py +++ b/src/bonsai/bonsai/bim/module/drawing/gizmos.py @@ -167,10 +167,10 @@ _BONSAI_TRANSFORM_MACROS = frozenset( # window.modal_operators — the macro's own idname does. The # ``BIM_OT_`` prefix is what Blender returns from ``bl_idname`` at # runtime (the class declaration uses the dotted ``bim.`` form). - "BIM_OT_override_move_macro", # G key - "BIM_OT_override_object_duplicate_move_macro", # Shift+D - "BIM_OT_override_object_duplicate_move_linked_macro", # Alt+D - "BIM_OT_object_duplicate_move_linked_aggregate_macro",# Ctrl+Shift+D + "BIM_OT_override_move_macro", # G key + "BIM_OT_override_object_duplicate_move_macro", # Shift+D + "BIM_OT_override_object_duplicate_move_linked_macro", # Alt+D + "BIM_OT_object_duplicate_move_linked_aggregate_macro", # Ctrl+Shift+D } ) @@ -1739,6 +1739,33 @@ def billboarded_at(world_pos: Vector, billboard_rot: Matrix, scale: float = DEFA return Matrix.Translation(world_pos) @ billboard_rot @ Matrix.Scale(scale, 4) +def billboarded_along_axis( + world_pos: Vector, + billboard_rot: Matrix, + axis_world: Vector, + scale: float = DEFAULT_BILLBOARD_SCALE, +) -> Matrix: + """Composed matrix_basis like ``billboarded_at`` but with local +X + rotated about the camera-forward axis to align with ``axis_world`` + projected onto the screen plane. + + The gizmo still faces the camera (local +Z stays along camera-forward), + only its in-plane orientation changes. Falls back to plain + ``billboarded_at`` when the axis is near-parallel to the view direction + (no usable screen projection).""" + camera_forward = billboard_rot @ Vector((0.0, 0.0, 1.0)) + projected = axis_world - camera_forward * axis_world.dot(camera_forward) + if projected.length < 1e-4: + return billboarded_at(world_pos, billboard_rot, scale) + projected.normalize() + y_axis = camera_forward.cross(projected).normalized() + rot = Matrix.Identity(4) + rot[0][:3] = (projected.x, y_axis.x, camera_forward.x) + rot[1][:3] = (projected.y, y_axis.y, camera_forward.y) + rot[2][:3] = (projected.z, y_axis.z, camera_forward.z) + return Matrix.Translation(world_pos) @ rot @ Matrix.Scale(scale, 4) + + def get_screen_up(billboard_rot: Matrix) -> Vector: """Camera's screen-up direction in world space — local +Y of the billboard rotation. Use to lift a gizmo above an anchor in a way that stays diff --git a/src/bonsai/bonsai/bim/module/model/decorator.py b/src/bonsai/bonsai/bim/module/model/decorator.py index d7b739d4c2..a64520622a 100644 --- a/src/bonsai/bonsai/bim/module/model/decorator.py +++ b/src/bonsai/bonsai/bim/module/model/decorator.py @@ -2154,9 +2154,7 @@ class MEPSegmentExtendPreviewDecorator(tool.Blender.ViewportDecorator): 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 - ) + line = self._compute_extend_preview_line(active.matrix_world, context.scene.cursor.location, current_length) if line is None: return start_world, end_world = line @@ -2174,21 +2172,20 @@ class MEPSegmentExtendPreviewDecorator(tool.Blender.ViewportDecorator): 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).""" + end). Target follows the cursor's raw local-Z projection unbounded — + the line stays visible past the segment origin (negative local Z) + because the user expects to see where they're pointing even when the + operator would floor it.""" 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)) + target_end_world = matrix_world @ Vector((0.0, 0.0, cursor_local.z)) return current_end_world, target_end_world diff --git a/src/bonsai/bonsai/bim/module/model/mep.py b/src/bonsai/bonsai/bim/module/model/mep.py index 95d62a7ffe..13abddad65 100644 --- a/src/bonsai/bonsai/bim/module/model/mep.py +++ b/src/bonsai/bonsai/bim/module/model/mep.py @@ -2190,29 +2190,47 @@ class _MEPSegmentEditionMixin: projected_local = Vector((0.0, 0.0, cursor_local.z)) projected_world = mw @ projected_local billboard_rot = self._frame_billboard_rot or gizmo.get_billboard_rotation(context) + # Segment extrusion axis in world space — local +Z of the active + # object. The extend icon orients its +X arrow along this so the + # arrow visually runs along the pipe / duct rather than horizontally. + segment_axis_world = (mw.to_3x3() @ Vector((0.0, 0.0, 1.0))).normalized() gz = self.extend_gizmo gz.hide = self.is_gizmo_hidden_by_modal(gz) - gz.matrix_basis = gizmo.billboarded_at(projected_world, billboard_rot) - if gizmo.should_flip_extend_arrow(projected_world, mw.translation, billboard_rot): + gz.matrix_basis = gizmo.billboarded_along_axis(projected_world, billboard_rot, segment_axis_world) + # Flip so the arrow points away from the current segment end (the + # direction the extend would grow). Comparing cursor projection + # against current_length picks the right end regardless of viewport + # orientation. + obj = context.active_object + current_length = max((c[2] for c in obj.bound_box), default=0.0) if obj is not None else 0.0 + if cursor_local.z < current_length: gz.matrix_basis = gz.matrix_basis @ gizmo.EXTEND_FLIP_MIRROR_X if hasattr(self, "split_gizmo"): split_gz = self.split_gizmo - obj = context.active_object if obj is None or not obj.bound_box: split_gz.hide = True else: # Endpoint-cut threshold matches split_mep_segment's rejection # window so the icon never offers an invalid affordance. - current_length = max(c[2] for c in obj.bound_box) in_range = 0.01 < cursor_local.z < (current_length - 0.01) if not in_range or self.is_gizmo_hidden_by_modal(split_gz): split_gz.hide = True else: split_gz.hide = False - offset_world = billboard_rot @ Vector((0.0, self.CURSOR_STACK_OFFSET, 0.0)) - split_gz.matrix_basis = gizmo.billboarded_at(projected_world + offset_world, billboard_rot) + # Stack the split icon perpendicular to the segment axis + # in screen space so it doesn't overlap the rotated + # extend arrow. + camera_forward = billboard_rot @ Vector((0.0, 0.0, 1.0)) + perp_axis = camera_forward.cross(segment_axis_world) + if perp_axis.length < 1e-4: + perp_axis = billboard_rot @ Vector((0.0, 1.0, 0.0)) + else: + perp_axis.normalize() + split_gz.matrix_basis = gizmo.billboarded_at( + projected_world + perp_axis * self.CURSOR_STACK_OFFSET, billboard_rot + ) # Dimension config shared between pipe and duct segments. ``matrix_position`` 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 2d4b218f5c..33997028ad 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 @@ -332,7 +332,6 @@ def test_extend_preview_line_returns_none_for_degenerate_segment(): 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 @@ -346,7 +345,6 @@ def test_extend_preview_line_returns_none_when_cursor_at_current_end(): 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 @@ -361,7 +359,6 @@ def test_extend_preview_line_renders_extension_when_cursor_past_end(): 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 @@ -378,7 +375,6 @@ def test_extend_preview_line_renders_trim_when_cursor_inside_segment(): 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 @@ -386,23 +382,23 @@ def test_extend_preview_line_renders_trim_when_cursor_inside_segment(): 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.""" +def test_extend_preview_line_follows_raw_projection_behind_segment_origin(): + """When the cursor's projected Z is negative (behind segment origin), + the preview line must follow the raw cursor projection — the user is + pointing somewhere and expects to see where, even though the operator + would floor the actual commit. Matching the operator's clamp would + hide the line whenever the cursor crossed the segment 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, -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)) + assert tuple(end) == pytest.approx((0.0, 0.0, -2.0)) def test_extend_preview_line_respects_object_rotation(): @@ -418,7 +414,6 @@ def test_extend_preview_line_respects_object_rotation(): 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