From d7dd8ecf571b6b352e1e89dce6dfede866f06cc3 Mon Sep 17 00:00:00 2001 From: Gorgious56 Date: Tue, 9 Jun 2026 11:51:37 +0200 Subject: [PATCH] Align extend gizmo arrow with segment axis MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The extend icon used a pure screen-space billboard that always pointed +X across the screen — the arrow ran horizontally regardless of the pipe / duct's orientation. The new billboarded_along_axis helper rotates the gizmo about the camera- forward axis so its local +X aligns with the segment's local +Z projected onto the screen, keeping the icon camera-facing but visually following the extrusion direction. The flip-mirror branch now reads from cursor-vs-current-end along the segment axis (not screen-X), so the arrow points away from the current endpoint regardless of viewport orientation. The split icon stacks perpendicular to the rotated extend arrow in screen space so the two don't overlap. The decorator's green preview line no longer clamps the cursor projection to min_projected_length — it follows the raw projection so the line stays visible when the cursor crosses behind the segment origin (the user still sees where they're pointing even though the operator floors the actual commit). Generated with the assistance of an AI coding tool. --- .../bonsai/bim/module/drawing/gizmos.py | 35 ++++++++++++++++--- .../bonsai/bim/module/model/decorator.py | 15 ++++---- src/bonsai/bonsai/bim/module/model/mep.py | 30 ++++++++++++---- .../module/model/test_mep_segment_edition.py | 19 ++++------ 4 files changed, 68 insertions(+), 31 deletions(-) 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