Align extend gizmo arrow with segment axis

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.
This commit is contained in:
Gorgious56
2026-06-09 11:51:37 +02:00
parent becbcfdfe7
commit d7dd8ecf57
4 changed files with 68 additions and 31 deletions
+31 -4
View File
@@ -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
@@ -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
+24 -6
View File
@@ -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``
@@ -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