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 # window.modal_operators — the macro's own idname does. The
# ``BIM_OT_`` prefix is what Blender returns from ``bl_idname`` at # ``BIM_OT_`` prefix is what Blender returns from ``bl_idname`` at
# runtime (the class declaration uses the dotted ``bim.`` form). # runtime (the class declaration uses the dotted ``bim.`` form).
"BIM_OT_override_move_macro", # G key "BIM_OT_override_move_macro", # G key
"BIM_OT_override_object_duplicate_move_macro", # Shift+D "BIM_OT_override_object_duplicate_move_macro", # Shift+D
"BIM_OT_override_object_duplicate_move_linked_macro", # Alt+D "BIM_OT_override_object_duplicate_move_linked_macro", # Alt+D
"BIM_OT_object_duplicate_move_linked_aggregate_macro",# Ctrl+Shift+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) 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: def get_screen_up(billboard_rot: Matrix) -> Vector:
"""Camera's screen-up direction in world space — local +Y of the billboard """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 rotation. Use to lift a gizmo above an anchor in a way that stays
@@ -2154,9 +2154,7 @@ class MEPSegmentExtendPreviewDecorator(tool.Blender.ViewportDecorator):
return return
current_length = max(c[2] for c in active.bound_box) if active.bound_box else 0.0 current_length = max(c[2] for c in active.bound_box) if active.bound_box else 0.0
line = self._compute_extend_preview_line( line = self._compute_extend_preview_line(active.matrix_world, context.scene.cursor.location, current_length)
active.matrix_world, context.scene.cursor.location, current_length, min_projected_length=0.01
)
if line is None: if line is None:
return return
start_world, end_world = line start_world, end_world = line
@@ -2174,21 +2172,20 @@ class MEPSegmentExtendPreviewDecorator(tool.Blender.ViewportDecorator):
matrix_world: Matrix, matrix_world: Matrix,
cursor_world: Vector, cursor_world: Vector,
current_length: float, current_length: float,
min_projected_length: float = 0.01,
) -> tuple[Vector, Vector] | None: ) -> tuple[Vector, Vector] | None:
"""Returns ``(current_end_world, target_end_world)`` or ``None`` when """Returns ``(current_end_world, target_end_world)`` or ``None`` when
no extend would happen (degenerate segment, or cursor on the existing no extend would happen (degenerate segment, or cursor on the existing
end). Target follows the cursor's local Z clamped to end). Target follows the cursor's raw local-Z projection unbounded —
``min_projected_length`` so the preview matches where the operator the line stays visible past the segment origin (negative local Z)
actually commits (which floors at the minimum).""" because the user expects to see where they're pointing even when the
operator would floor it."""
if current_length <= 0: if current_length <= 0:
return None return None
cursor_local = matrix_world.inverted() @ cursor_world cursor_local = matrix_world.inverted() @ cursor_world
if abs(cursor_local.z - current_length) < 1e-6: if abs(cursor_local.z - current_length) < 1e-6:
return None return None
target_local_z = max(min_projected_length, cursor_local.z)
current_end_world = matrix_world @ Vector((0.0, 0.0, current_length)) 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 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_local = Vector((0.0, 0.0, cursor_local.z))
projected_world = mw @ projected_local projected_world = mw @ projected_local
billboard_rot = self._frame_billboard_rot or gizmo.get_billboard_rotation(context) 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 = self.extend_gizmo
gz.hide = self.is_gizmo_hidden_by_modal(gz) gz.hide = self.is_gizmo_hidden_by_modal(gz)
gz.matrix_basis = gizmo.billboarded_at(projected_world, billboard_rot) gz.matrix_basis = gizmo.billboarded_along_axis(projected_world, billboard_rot, segment_axis_world)
if gizmo.should_flip_extend_arrow(projected_world, mw.translation, billboard_rot): # 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 gz.matrix_basis = gz.matrix_basis @ gizmo.EXTEND_FLIP_MIRROR_X
if hasattr(self, "split_gizmo"): if hasattr(self, "split_gizmo"):
split_gz = self.split_gizmo split_gz = self.split_gizmo
obj = context.active_object
if obj is None or not obj.bound_box: if obj is None or not obj.bound_box:
split_gz.hide = True split_gz.hide = True
else: else:
# Endpoint-cut threshold matches split_mep_segment's rejection # Endpoint-cut threshold matches split_mep_segment's rejection
# window so the icon never offers an invalid affordance. # 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) in_range = 0.01 < cursor_local.z < (current_length - 0.01)
if not in_range or self.is_gizmo_hidden_by_modal(split_gz): if not in_range or self.is_gizmo_hidden_by_modal(split_gz):
split_gz.hide = True split_gz.hide = True
else: else:
split_gz.hide = False split_gz.hide = False
offset_world = billboard_rot @ Vector((0.0, self.CURSOR_STACK_OFFSET, 0.0)) # Stack the split icon perpendicular to the segment axis
split_gz.matrix_basis = gizmo.billboarded_at(projected_world + offset_world, billboard_rot) # 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`` # 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), matrix_world=Matrix.Identity(4),
cursor_world=Vector((0.0, 0.0, 1.0)), cursor_world=Vector((0.0, 0.0, 1.0)),
current_length=0.0, current_length=0.0,
min_projected_length=0.01,
) )
assert result is None 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), matrix_world=Matrix.Identity(4),
cursor_world=Vector((0.0, 0.0, 1.5)), cursor_world=Vector((0.0, 0.0, 1.5)),
current_length=1.5, current_length=1.5,
min_projected_length=0.01,
) )
assert result is None assert result is None
@@ -361,7 +359,6 @@ def test_extend_preview_line_renders_extension_when_cursor_past_end():
matrix_world=Matrix.Identity(4), matrix_world=Matrix.Identity(4),
cursor_world=Vector((0.0, 0.0, 3.0)), cursor_world=Vector((0.0, 0.0, 3.0)),
current_length=1.0, current_length=1.0,
min_projected_length=0.01,
) )
assert result is not None assert result is not None
start, end = result start, end = result
@@ -378,7 +375,6 @@ def test_extend_preview_line_renders_trim_when_cursor_inside_segment():
matrix_world=Matrix.Identity(4), matrix_world=Matrix.Identity(4),
cursor_world=Vector((0.0, 0.0, 0.4)), cursor_world=Vector((0.0, 0.0, 0.4)),
current_length=1.0, current_length=1.0,
min_projected_length=0.01,
) )
assert result is not None assert result is not None
start, end = result 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)) assert tuple(end) == pytest.approx((0.0, 0.0, 0.4))
def test_extend_preview_line_clamps_cursor_projection_to_minimum(): def test_extend_preview_line_follows_raw_projection_behind_segment_origin():
"""When the cursor's projected Z is negative (behind segment origin) or """When the cursor's projected Z is negative (behind segment origin),
near zero, the extend operator clamps to ``min_projected_length``. The the preview line must follow the raw cursor projection the user is
preview must match the same clamp so the line lands where the operator pointing somewhere and expects to see where, even though the operator
would actually commit, not at the raw cursor position.""" 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 from bonsai.bim.module.model.decorator import MEPSegmentExtendPreviewDecorator
result = MEPSegmentExtendPreviewDecorator._compute_extend_preview_line( result = MEPSegmentExtendPreviewDecorator._compute_extend_preview_line(
matrix_world=Matrix.Identity(4), matrix_world=Matrix.Identity(4),
cursor_world=Vector((0.0, 0.0, -2.0)), cursor_world=Vector((0.0, 0.0, -2.0)),
current_length=1.0, current_length=1.0,
min_projected_length=0.01,
) )
assert result is not None assert result is not None
start, end = result start, end = result
assert tuple(start) == pytest.approx((0.0, 0.0, 1.0)) 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(): def test_extend_preview_line_respects_object_rotation():
@@ -418,7 +414,6 @@ def test_extend_preview_line_respects_object_rotation():
matrix_world=rotation, matrix_world=rotation,
cursor_world=Vector((3.0, 0.0, 0.0)), cursor_world=Vector((3.0, 0.0, 0.0)),
current_length=1.0, current_length=1.0,
min_projected_length=0.01,
) )
assert result is not None assert result is not None
start, end = result start, end = result