mirror of
https://github.com/IfcOpenShell/IfcOpenShell.git
synced 2026-08-09 17:31:45 +00:00
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:
@@ -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
|
||||
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user