MagicNStuff/source/addons/godot_3d_cursor/plugin.gd
SchimmelSpreu83 2a60380258 Added Plugins
- ProtoShape
- StairsCharacter (GDExtension)
- TODO Manager
- 3D Cursor
2025-09-21 23:42:15 +02:00

489 lines
17 KiB
GDScript

@tool
extends EditorPlugin
## This variable indicates whether the active tab is 3D
var is_in_3d_tab: bool = false
## The position of the mouse used to raycast into the 3D world
var mouse_position: Vector2
## The camera used for raycasting to calculate the position
## for the 3D cursor
var temp_camera: Camera3D
## The Editor Viewport used to get the mouse position
var editor_viewport: SubViewport
## The camera that displays what the user sees in the 3D editor tab
var editor_camera: Camera3D
## The root node of the active scene
var edited_scene_root: Node
## The scene used to instantiate the 3D Cursor
var cursor_scene: PackedScene
## The instance of the 3D Cursor
var cursor: Cursor3D
## The scene used to instantiate the pie menu for the 3D Cursor
var pie_menu_scene: PackedScene
## The instance of the pie menu for the 3D Cursor
var pie_menu: PieMenu
## A reference to the [EditorCommandPalette] singleton used to add
## some useful actions to the command palette such as '3D Cursor to Origin'
## or '3D Cursor to selected object' like in Blender
var command_palette: EditorCommandPalette
## The InputEvent holding the MouseButton event to trigger the
## set position function of the 3D Cursor
var input_event_set_3d_cursor: InputEventMouseButton
var input_event_show_pie_menu: InputEventKey
## The boolean that ensures the _recover_cursor function is executed once
var cursor_set: bool = false
## The instance of the Undo Redo class
var undo_redo: EditorUndoRedoManager
func _enter_tree() -> void:
# Register the switching of tabs in the editor. We only want the
# 3D Cursor functionality within the 3D tab
connect("main_screen_changed", _on_main_scene_changed)
# We want to place newly added Nodes that inherit [Node3D] at
# the location of the 3D Cursor. Therefore we listen to the
# node_added event
get_tree().connect("node_added", _on_node_added)
# Loading the 3D Cursor scene for later instancing
cursor_scene = preload("res://addons/godot_3d_cursor/3d_cursor.tscn")
pie_menu_scene = preload("res://addons/godot_3d_cursor/pie_menu.tscn")
command_palette = EditorInterface.get_command_palette()
# Adding the previously mentioned actions
command_palette.add_command("3D Cursor to Origin", "3D Cursor/3D Cursor to Origin", _3d_cursor_to_origin)
command_palette.add_command("3D Cursor to Selected Object", "3D Cursor/3D Cursor to Selected Object", _3d_cursor_to_selected_objects)
command_palette.add_command("Selected Object to 3D Cursor", "3D Cursor/Selected Object to 3D Cursor", _selected_object_to_3d_cursor)
# Adding the remove 3D Cursor in Scene action
command_palette.add_command("Remove 3D Cursor from Scene", "3D Cursor/Remove 3D Cursor from Scene", _remove_3d_cursor_from_scene)
command_palette.add_command("Toggle 3D Cursor", "3D Cursor/Toggle 3D Cursor", _toggle_3d_cursor)
editor_viewport = EditorInterface.get_editor_viewport_3d()
editor_camera = editor_viewport.get_camera_3d()
# Get the reference to the UndoRedo instance of the editor
undo_redo = get_undo_redo()
# Instantiating the pie menu for the 3D Cursor commands
pie_menu = pie_menu_scene.instantiate()
pie_menu.hide()
# Connecting the button events from the pie menu to the corresponding function
pie_menu.connect("cursor_to_origin_pressed", _3d_cursor_to_origin)
pie_menu.connect("cursor_to_selected_objects_pressed", _3d_cursor_to_selected_objects)
pie_menu.connect("selected_object_to_cursor_pressed", _selected_object_to_3d_cursor)
pie_menu.connect("remove_cursor_from_scene_pressed", _remove_3d_cursor_from_scene)
pie_menu.connect("toggle_cursor_pressed", _toggle_3d_cursor)
add_child(pie_menu)
# Setting up the InputMap so that we can set the 3D Cursor
# by Shift + Right Click
if not InputMap.has_action("3d_cursor_set_location"):
InputMap.add_action("3d_cursor_set_location")
input_event_set_3d_cursor = InputEventMouseButton.new()
input_event_set_3d_cursor.button_index = MOUSE_BUTTON_RIGHT
InputMap.action_add_event("3d_cursor_set_location", input_event_set_3d_cursor)
# Adding the action that shows the pie menu for the 3D Cursor commands.
if not InputMap.has_action("3d_cursor_show_pie_menu"):
InputMap.add_action("3d_cursor_show_pie_menu")
input_event_show_pie_menu = InputEventKey.new()
input_event_show_pie_menu.keycode = KEY_S
InputMap.action_add_event("3d_cursor_show_pie_menu", input_event_show_pie_menu)
func _exit_tree() -> void:
# Removing listeners
disconnect("main_screen_changed", _on_main_scene_changed)
get_tree().disconnect("node_added", _on_node_added)
pie_menu.disconnect("cursor_to_origin_pressed", _3d_cursor_to_origin)
pie_menu.disconnect("cursor_to_selected_objects_pressed", _3d_cursor_to_selected_objects)
pie_menu.disconnect("selected_object_to_cursor_pressed", _selected_object_to_3d_cursor)
pie_menu.disconnect("remove_cursor_from_scene_pressed", _remove_3d_cursor_from_scene)
pie_menu.disconnect("toggle_cursor_pressed", _toggle_3d_cursor)
# Removing the actions from the [EditorCommandPalette]
command_palette.remove_command("3D Cursor/3D Cursor to Origin")
command_palette.remove_command("3D Cursor/3D Cursor to Selected Object")
command_palette.remove_command("3D Cursor/Selected Object to 3D Cursor")
command_palette.remove_command("3D Cursor/Remove 3D Cursor from Scene")
command_palette.remove_command("3D Cursor/Toggle 3D Cursor")
command_palette = null
# Removing the '3D Cursor set Location' action from the InputMap
if InputMap.has_action("3d_cursor_set_location"):
InputMap.action_erase_event("3d_cursor_set_location", input_event_set_3d_cursor)
InputMap.erase_action("3d_cursor_set_location")
# Removing the 'Show Pie Menu' action from the InputMap
if InputMap.has_action("3d_cursor_show_pie_menu"):
InputMap.action_erase_event("3d_cursor_show_pie_menu", input_event_show_pie_menu)
InputMap.erase_action("3d_cursor_show_pie_menu")
# Removing and freeing the helper objects
if temp_camera != null and editor_viewport != null:
editor_viewport.remove_child(temp_camera)
temp_camera.queue_free()
# Deleting the 3D Cursor
if cursor != null:
cursor.queue_free()
cursor_scene = null
# Deleting the pie menu
if pie_menu != null:
pie_menu.queue_free()
pie_menu_scene = null
func _process(delta: float) -> void:
# Only allow setting the 3D Cursors location in 3D tab
if not is_in_3d_tab:
return
# If the action is not yet set up: return
if not InputMap.has_action("3d_cursor_set_location"):
return
# Set the location of the 3D Cursor
if Input.is_key_pressed(KEY_SHIFT) and Input.is_action_just_pressed("3d_cursor_set_location"):
mouse_position = editor_viewport.get_mouse_position()
_get_selection()
if cursor == null or not cursor.is_inside_tree():
return
if Input.is_key_pressed(KEY_SHIFT) and Input.is_action_just_pressed("3d_cursor_show_pie_menu"):
pie_menu.visible = not pie_menu.visible
_set_visibility_toggle_label()
func _input(event: InputEvent) -> void:
if event.is_released():
return
if not pie_menu.visible:
return
if pie_menu.hit_any_button():
return
if event is InputEventKey and event.keycode == KEY_S and event.is_echo():
return
if event is InputEventKey or event is InputEventMouseButton:
pie_menu.hide()
# CAUTION: Do not mess with this statement! It can render your editor
# responseless. If it happens remove the plugin and restart the engine.
editor_viewport.set_input_as_handled()
## Checks whether the current active tab is named '3D'
## returns true if so, otherwise false
func _on_main_scene_changed(screen_name: String) -> void:
is_in_3d_tab = screen_name == "3D"
## Connected to the node_added event of the get_tree()
func _on_node_added(node: Node) -> void:
if not _cursor_available():
return
if EditorInterface.get_edited_scene_root() != cursor.owner:
return
if node.name == cursor.name:
return
if cursor.is_ancestor_of(node):
return
if not node is Node3D:
return
# Apply the position of the new node to the 3D Cursors position if the
# 3D cursor is available, the node is not the 3D cursor itself, the node
# is no descendant of the 3D Cursor and the node inherits [Node3D]
node.global_position = cursor.global_position
## Set the postion of the 3D Cursor to the origin (or [Vector3.ZERO])
func _3d_cursor_to_origin() -> void:
if not _cursor_available():
return
_create_undo_redo_action(
cursor,
"global_position",
Vector3.ZERO,
"Move 3D Cursor to Origin",
)
## Set the position of the 3D Cursor to the selected object and if multiple
## Nodes are selected to the average of the positions of all selected nodes
## that inherit [Node3D]
func _3d_cursor_to_selected_objects() -> void:
if not _cursor_available():
return
# Get the selection and through this the selected nodes as an Array of Nodes
var selection: EditorSelection = EditorInterface.get_selection()
var selected_nodes: Array[Node] = selection.get_selected_nodes()
if selected_nodes.is_empty():
return
if selected_nodes.size() == 1 and not selected_nodes.front() is Node3D:
return
# If only one Node is selected and it inherits Node3D set the position
# of the 3D Cursor to its position
if selected_nodes.size() == 1:
_create_undo_redo_action(
cursor,
"global_position",
selected_nodes.front().global_position,
"Move 3D Cursor to selected Object",
)
return
# Introduce a count variable to keep track of the amount of valid positions
# to calculate the average position later
var count = 0
var position_sum: Vector3 = Vector3.ZERO
for node in selected_nodes:
if not (node is Node3D or node is Cursor3D):
continue
# If the node is a valid object increment count and add the position
# to position_sum
count += 1
position_sum += node.global_position
if count == 0:
return
# Calculate the average position for multiple selected Nodes and set
# the 3D Cursor to this position
var average_position = position_sum / count
_create_undo_redo_action(
cursor,
"global_position",
average_position,
"Move 3D Cursor to selected Objects",
)
cursor.global_position = average_position
## Set the position of the selected object that inherits [Node3D]
## to the position of the 3D Cursor. If multiple nodes are selected the first
## valid node (i.e. a node that inherits [Node3D]) will be moved to
## position of the 3D Cursor. This funcitonality is disabled if the cursor
## is not set or hidden in the scene.
func _selected_object_to_3d_cursor() -> void:
if not _cursor_available():
return
# Get the selection and through this the selected nodes as an Array of Nodes
var selection: EditorSelection = EditorInterface.get_selection()
var selected_nodes: Array[Node] = selection.get_selected_nodes()
if selected_nodes.is_empty():
return
if selected_nodes.size() == 1 and not selected_nodes.front() is Node3D:
return
selected_nodes = selected_nodes.filter(func(node): return node is Node3D and not node is Cursor3D)
if selected_nodes.is_empty():
return
_create_undo_redo_action(
selected_nodes.front(),
"global_position",
cursor.global_position,
"Move Object to 3D Cursor"
)
## Disable the 3D Cursor to prevent the node placement at the position of
## the 3D Cursor.
func _toggle_3d_cursor() -> void:
if not _cursor_available(true):
return
cursor.visible = not cursor.visible
_set_visibility_toggle_label()
## Sets the correct label on the toggle visibility button in the pie menu
func _set_visibility_toggle_label() -> void:
pie_menu.change_toggle_label(cursor.visible)
## Remove every 3D Cursor from the scene including the active one.
func _remove_3d_cursor_from_scene() -> void:
if cursor == null:
return
# Remove the active 3D Cursor
cursor.queue_free()
cursor = null
# Get the root nodes children to filter for old instances of [Cursor3D]
var root_children = edited_scene_root.get_children()
if root_children.any(func(node): return node is Cursor3D):
# Iterate over all old instances and free them
for old_cursor: Cursor3D in root_children.filter(func(node): return node is Cursor3D):
old_cursor.queue_free()
## Check whether the 3D Cursor is set up and ready for use. A hidden 3D Cursor
## should also disable its functionality. Therefore this function yields false
## if the cursor is hidden in the scene
func _cursor_available(ignore_hidden = false) -> bool:
# CAUTION: Do not mess with this statement! It can render your editor
# responseless. If it happens remove the plugin and restart the engine.
editor_viewport.set_input_as_handled()
if cursor == null:
return false
if not cursor.is_inside_tree():
return false
if ignore_hidden and not cursor.is_visible_in_tree():
return true
if not cursor.is_visible_in_tree():
return false
return true
## This function uses raycasting to determine the position of the mouse click
## to set the position of the 3D Cursor. This means that it is necessary for
## the clicked on objects to have a collider the raycast can hit
func _get_selection() -> void:
# If the scene is switched stop
if edited_scene_root != null and edited_scene_root != EditorInterface.get_edited_scene_root() and cursor != null:
# Reset scene root, viewport and camera for new scene
edited_scene_root = null
editor_viewport = EditorInterface.get_editor_viewport_3d()
editor_camera = editor_viewport.get_camera_3d()
# Clear the 3D Cursor on the old screen.
cursor.queue_free()
cursor = null
if not cursor_set:
_recover_cursor()
if temp_camera == null:
# Set up the temp_camera to resemble the one of the 3D Viewport
_create_temp_camera()
# Get the transform of the camera from the 3D Viewport
var editor_camera_transform = _get_editor_camera_transform()
# Position the temp_camera so that it is exactly where the 3D Viewport
# camera is located
temp_camera.global_transform = editor_camera_transform
# if the editor_camera_transform is Transform3D.IDENTITY that means
# that for some reason the editor_camera is null.
if editor_camera_transform == Transform3D.IDENTITY:
return
# Set up the raycast parameters
var ray_origin = temp_camera.project_ray_origin(mouse_position)
var ray_end = temp_camera.project_position(mouse_position, 1000)
var ray_length = 1000
if edited_scene_root == null:
edited_scene_root = EditorInterface.get_edited_scene_root()
# The space state where the raycast should be performed in
var space_state = edited_scene_root.get_world_3d().direct_space_state
# Perform a raycast with the parameters above and store the result
var result = space_state.intersect_ray(PhysicsRayQueryParameters3D.create(ray_origin, ray_end))
var just_created: bool = false
# When the cursor is not yet created instantiate it, add it to the scene
# and position it at the collision detected by the raycast
if cursor == null:
cursor = cursor_scene.instantiate()
edited_scene_root.add_child(cursor)
cursor.owner = edited_scene_root
just_created = true
# If the cursor is not in the node tree at this point it means that the
# user probably deleted it. Then add it again
if not cursor.is_inside_tree():
edited_scene_root.add_child(cursor)
cursor.owner = edited_scene_root
just_created = true
# No collision means do nothing
if result.is_empty():
return
if just_created:
# Position the 3D Cursor to the position of the collision
cursor.global_transform.origin = result.position
return
_create_undo_redo_action(
cursor,
"global_position",
result.position,
"Set Position for 3D Cursor"
)
## This function creates the temp_camera and sets it up so that it resembles
## the camera from 3D Tab itself
func _create_temp_camera() -> void:
temp_camera = Camera3D.new()
temp_camera.hide()
# Add the temp_camera to the editor_viewport so that we can perform raycasts
# later on
editor_viewport.add_child(temp_camera)
# These are the most important settings the temp_camera needs to copy
# from the editor_camera so that their image is congruent
temp_camera.fov = editor_camera.fov
temp_camera.near = editor_camera.near
temp_camera.far = editor_camera.far
## This function returns the transform of the camera from the 3D Editor itself
func _get_editor_camera_transform() -> Transform3D:
if editor_camera != null:
return editor_camera.get_camera_transform()
return Transform3D.IDENTITY
## This function recovers any 3D Cursor present in the scene if you reload
## the project
func _recover_cursor() -> void:
# This boolean ensures this function is run exactly once
cursor_set = true
# Gets the children of the active scenes root node
var root_children = EditorInterface.get_edited_scene_root().get_children()
# Checks whether there are any nodes of type [Cursor3D] in the list of
# children
if root_children.any(func(node): return node is Cursor3D):
# Get the first and probably only instance of [Cursor3D] and assign
# it to the cursor variable. Now the 3D Cursor is considered recovered
cursor = root_children.filter(func(node): return node is Cursor3D).front()
func _create_undo_redo_action(node: Node3D, property: String, value: Variant, action_name: String = "") -> void:
if node == null or property.is_empty() or value == null:
return
if action_name.is_empty():
action_name = "Set " + property + " for " + node.name
undo_redo.create_action(action_name)
var old_value: Variant = node.get(property)
undo_redo.add_do_property(node, property, value)
undo_redo.add_undo_property(node, property, old_value)
undo_redo.commit_action()