Here’s a draft plugin. You can try it out and see how it works for you.
The command is ALT + middle click. It works only on meshes and ground. Could be extended to colliders.
It ended up a little bit trickier than I expected. Mesh picking works via gizmos but gizmo functionality that allows on-demand ray hit testing is not exposed to scripting i.e. EditorNode3DGizmo::intersect_ray()
Because of this, the plugin needs to pluck triangular meshes from the gpu buffers and do the “manual” ray testing. This is currently done brute force every time the user alt clicks, for all mesh instances. Performance needs to be tested with mesh heavy scenes. Plenty of room for improvement here.
I also used a rather hacky method to execute the built in focusing. Again the control over camera cursor is not exposed to scripting so the only way to do this is tricking the editor to execute the menu callback. If someone knows a better way - please share.
Doing this as a native code extension or source code intervention would be quite a bit simpler as there is access to all of the needed functionality via just a few calls, but then the thing would need to be compiled per platform. So this may be a good enough compromise.
@tool
extends EditorPlugin
func _enable_plugin() -> void:
# enable forwarding of 3d viewport events even if no node is selected
set_input_event_forwarding_always_enabled()
func _forward_3d_gui_input(viewport_camera, event) -> int:
# do our thing on ALT + MMB
if event is InputEventMouseButton:
if event.button_index == MOUSE_BUTTON_MIDDLE and event.is_pressed() and event.alt_pressed:
focus_cam_to_surface_point(event.position, viewport_camera)
return AFTER_GUI_INPUT_PASS
func focus_cam_to_surface_point(screen_position: Vector2, cam: Camera3D) -> void:
# get all mesh instances in the scene
var scene_root: Node = EditorInterface.get_edited_scene_root()
var mesh_instances: Array = scene_root.find_children("*", "MeshInstance3D", true, false)
# get pick ray in world space
var ray_origin: Vector3 = cam.project_ray_origin(screen_position)
var ray_direction: Vector3 = cam.project_ray_normal(screen_position)
# collect pick ray hitpoints on all meshes. SLOW! could use some optimization (e.g. cache the triangle meshes)
var hitpoints: Array[Vector3] = []
for instance: MeshInstance3D in mesh_instances:
# make trimesh
var trimesh: TriangleMesh = instance.mesh.generate_triangle_mesh()
# global to instance local matrix
var to_instance_space = instance.global_transform.affine_inverse()
# transform the pick ray into instance local space
var ray_origin_local: Vector3 = to_instance_space * ray_origin
var ray_direction_local: Vector3 = to_instance_space.basis * ray_direction
# intersect and append hitpoint if mesh was hit
var result: Dictionary = trimesh.intersect_ray(ray_origin_local, ray_direction_local)
if result:
hitpoints.append(instance.global_transform * result["position"])
# sort hitpoints by distance to camera
hitpoints.sort_custom(
func(a: Vector3, b: Vector3):
return cam.global_position.distance_squared_to(a) < cam.global_position.distance_squared_to(b)
)
# decide the target position: nearest hitpoint or ground if there are no hitpoints
var target_position: Vector3
if hitpoints.is_empty():
var plane_hit: Variant = Plane(Vector3.UP).intersects_ray(ray_origin, ray_direction)
if plane_hit:
target_position = plane_hit
else:
return # ray cannot hit ground, we're out of target options
else:
target_position = hitpoints[0]
# create a dummy target node
var dummy_target: Node3D = Node3D.new()
scene_root.add_child(dummy_target)
dummy_target.owner = scene_root # set the owner so we can see the node in the editor if something fails, just for debugging
dummy_target.global_position = target_position
# store the current selection and select the dummy target node
var editor_selection: EditorSelection = EditorInterface.get_selection()
var selected_nodes_before: Array = editor_selection.get_selected_nodes()
editor_selection.clear()
editor_selection.add_node(dummy_target)
# trigger editor's built in focus by faking the menu hotkey hit, happens in all 4 views
# (find a better way to do this)
for b in EditorInterface.get_editor_main_screen().find_children("*", "Button", true, false):
if b.text.contains("Perspective"):
var p: PopupMenu = b.find_children("*", "PopupMenu", false, false)[0]
var e: InputEventKey = InputEventKey.new()
e.keycode = KEY_F
e.pressed = true
p.activate_item_by_event(e)
# delete the dummy target node and restore the selection
dummy_target.queue_free()
for node in selected_nodes_before:
editor_selection.add_node(node)