addon to use the blender keys
authorEduardo <[email protected]>
Tue, 6 Feb 2024 00:33:02 +0000 (01:33 +0100)
committerEduardo <[email protected]>
Tue, 6 Feb 2024 00:33:02 +0000 (01:33 +0100)
addons/gd-blender-3d-shortcuts/Utils.gd [new file with mode: 0644]
addons/gd-blender-3d-shortcuts/plugin.cfg [new file with mode: 0644]
addons/gd-blender-3d-shortcuts/plugin.gd [new file with mode: 0644]
addons/gd-blender-3d-shortcuts/scenes/pie_menu/PieMenu.gd [new file with mode: 0644]
addons/gd-blender-3d-shortcuts/scenes/pie_menu/PieMenu.tscn [new file with mode: 0644]
addons/gd-blender-3d-shortcuts/scenes/pie_menu/PieMenuGroup.gd [new file with mode: 0644]
addons/gd-blender-3d-shortcuts/scenes/pie_menu/PieMenuGroup.tscn [new file with mode: 0644]
addons/gdscript_formatter/plugin.cfg [new file with mode: 0644]

diff --git a/addons/gd-blender-3d-shortcuts/Utils.gd b/addons/gd-blender-3d-shortcuts/Utils.gd
new file mode 100644 (file)
index 0000000..6a559e3
--- /dev/null
@@ -0,0 +1,188 @@
+static func apply_transform(nodes, transform, cache_global_transforms):
+       var i = 0
+       for node in nodes:
+               var cache_global_transform = cache_global_transforms[i]
+               node.global_transform.origin = cache_global_transform.origin
+               node.global_transform.origin += cache_global_transform.basis.get_rotation_quaternion() * transform.origin
+               node.global_transform.basis.x = cache_global_transform.basis * transform.basis.x
+               node.global_transform.basis.y = cache_global_transform.basis * transform.basis.y
+               node.global_transform.basis.z = cache_global_transform.basis * transform.basis.z
+               i += 1
+
+static func apply_global_transform(nodes, transform, cache_transforms):
+       var i = 0
+       for node in nodes:
+               node.global_transform = transform * cache_transforms[i]
+               i += 1
+
+static func revert_transform(nodes, cache_global_transforms):
+       var i = 0
+       for node in nodes:
+               node.global_transform = cache_global_transforms[i]
+               i += 1
+
+static func reset_translation(nodes):
+       for node in nodes:
+               node.transform.origin = Vector3.ZERO
+
+static func reset_rotation(nodes):
+       for node in nodes:
+               var scale = node.transform.basis.get_scale()
+               node.transform.basis = Basis().scaled(scale)
+
+static func reset_scale(nodes):
+       for node in nodes:
+               var quat = node.transform.basis.get_rotation_quaternion()
+               node.transform.basis = Basis(quat)
+
+static func hide_nodes(nodes, is_hide=true):
+       for node in nodes:
+               node.visible = !is_hide
+
+static func recursive_get_children(node):
+       var children = node.get_children()
+       if children.size() == 0:
+               return []
+       else:
+               for child in children:
+                       children += recursive_get_children(child)
+               return children
+
+static func get_spatial_editor(base_control):
+       var children = recursive_get_children(base_control)
+       for child in children:
+               if child.get_class() == "Node3DEditor":
+                       return child
+
+static func get_spatial_editor_viewport_container(spatial_editor):
+       var children = recursive_get_children(spatial_editor)
+       for child in children:
+               if child.get_class() == "Node3DEditorViewportContainer":
+                       return child
+
+static func get_spatial_editor_viewports(spatial_editor_viewport):
+       var children = recursive_get_children(spatial_editor_viewport)
+       var spatial_editor_viewports = []
+       for child in children:
+               if child.get_class() == "Node3DEditorViewport":
+                       spatial_editor_viewports.append(child)
+       return spatial_editor_viewports
+
+static func get_spatial_editor_viewport_viewport(spatial_editor_viewport):
+       var children = recursive_get_children(spatial_editor_viewport)
+       for child in children:
+               if child.get_class() == "SubViewport":
+                       return child
+
+static func get_spatial_editor_viewport_control(spatial_editor_viewport):
+       var children = recursive_get_children(spatial_editor_viewport)
+       for child in children:
+               if child.get_class() == "Control":
+                       return child
+
+static func get_focused_spatial_editor_viewport(spatial_editor_viewports):
+       for viewport in spatial_editor_viewports:
+               var viewport_control = get_spatial_editor_viewport_control(viewport)
+               if viewport_control.get_rect().has_point(viewport_control.get_local_mouse_position()):
+                       return viewport
+
+static func get_snap_dialog(spatial_editor):
+       var children = recursive_get_children(spatial_editor)
+       for child in children:
+               if child.get_class() == "ConfirmationDialog":
+                       if child.title == "Snap Settings":
+                               return child
+
+static func get_snap_dialog_line_edits(snap_dialog):
+       var line_edits = []
+       for child in recursive_get_children(snap_dialog):
+               if child.get_class() == "LineEdit":
+                       line_edits.append(child)
+       return line_edits
+
+static func get_spatial_editor_local_space_button(spatial_editor):
+       var children = recursive_get_children(spatial_editor)
+       for child in children:
+               if child.get_class() == "Button":
+                       if child.shortcut:
+                               if child.shortcut.get_as_text() == OS.get_keycode_string(KEY_T):# TODO: Check if user has custom shortcut
+                                       return child
+
+static func get_spatial_editor_snap_button(spatial_editor):
+       var children = recursive_get_children(spatial_editor)
+       for child in children:
+               if child.get_class() == "Button":
+                       if child.shortcut:
+                               if child.shortcut.get_as_text() == OS.get_keycode_string(KEY_Y):# TODO: Check if user has custom shortcut
+                                       return child
+
+static func project_on_plane(camera, screen_point, plane):
+       var from = camera.project_ray_origin(screen_point)
+       var dir = camera.project_ray_normal(screen_point)
+       var intersection = plane.intersects_ray(from, dir)
+       return intersection if intersection else Vector3.ZERO
+
+static func transform_to_plane(t):
+       var a = t.basis.x
+       var b = t.basis.z
+       var c = a + b
+       var o = t.origin
+       return Plane(a + o, b + o, c + o)
+
+# Return new position when out of bounds
+static func infinite_rect(rect, from, to):
+       # Clamp from position to rect first, so it won't hit current side
+       from = Vector2(clamp(from.x, rect.position.x + 2, rect.size.x - 2), clamp(from.y, rect.position.y + 2, rect.size.y - 2))
+       # Intersect with sides of rect
+       var intersection
+       # Top
+       intersection = Geometry2D.segment_intersects_segment(rect.position, Vector2(rect.size.x, rect.position.y), from, to)
+       if intersection:
+               return intersection
+       # Left
+       intersection = Geometry2D.segment_intersects_segment(rect.position, Vector2(rect.position.x, rect.size.y), from, to)
+       if intersection:
+               return intersection
+       # Right
+       intersection = Geometry2D.segment_intersects_segment(rect.size, Vector2(rect.size.x, rect.position.y), from, to)
+       if intersection:
+               return intersection
+       # Bottom
+       intersection = Geometry2D.segment_intersects_segment(rect.size, Vector2(rect.position.x, rect.size.y), from, to)
+       if intersection:
+               return intersection
+       return null
+
+static func draw_axis(im, origin, axis, length, color):
+       var from = origin + (-axis * length / 2)
+       var to = origin + (axis * length / 2)
+       im.surface_begin(Mesh.PRIMITIVE_LINES)
+       im.surface_set_color(color)
+       im.surface_add_vertex(from)
+       im.surface_add_vertex(to)
+       im.surface_end()
+
+static func draw_dashed_line(canvas_item, from, to, color, width, dash_length = 5, cap_end = false, antialiased = false):
+       # See https://github.com/juddrgledhill/godot-dashed-line/blob/master/line_harness.gd
+       var length = (to - from).length()
+       var normal = (to - from).normalized()
+       var dash_step = normal * dash_length
+       
+       if length < dash_length: #not long enough to dash
+               canvas_item.draw_line(from, to, color, width, antialiased)
+               return
+
+       else:
+               var draw_flag = true
+               var segment_start = from
+               var steps = length/dash_length
+               for start_length in range(0, steps + 1):
+                       var segment_end = segment_start + dash_step
+                       if draw_flag:
+                               canvas_item.draw_line(segment_start, segment_end, color, width, antialiased)
+
+                       segment_start = segment_end
+                       draw_flag = !draw_flag
+               
+               if cap_end:
+                       canvas_item.draw_line(segment_start, to, color, width, antialiased)
diff --git a/addons/gd-blender-3d-shortcuts/plugin.cfg b/addons/gd-blender-3d-shortcuts/plugin.cfg
new file mode 100644 (file)
index 0000000..f07fa59
--- /dev/null
@@ -0,0 +1,7 @@
+[plugin]
+
+name="Blender 3D Shortcuts"
+description="Blender's 3D transforming shortcuts in Godot"
+author="imjp94"
+version="0.3.1"
+script="plugin.gd"
diff --git a/addons/gd-blender-3d-shortcuts/plugin.gd b/addons/gd-blender-3d-shortcuts/plugin.gd
new file mode 100644 (file)
index 0000000..b6e56ac
--- /dev/null
@@ -0,0 +1,794 @@
+@tool
+extends EditorPlugin
+
+const Utils = preload("Utils.gd")
+const PieMenuScn = preload("scenes/pie_menu/PieMenu.tscn")
+const PieMenuGroupScn = preload("scenes/pie_menu/PieMenuGroup.tscn")
+
+const DEFAULT_LINE_COLOR = Color.WHITE
+# [name, value]
+const DEBUG_DRAW_OPTIONS = [
+       ["Normal", 0], ["Unshaded", 1], ["Lighting", 2], ["Overdraw", 3], ["Wireframe", 4],
+       [
+               "Advance",
+               [
+                       ["Shadows", 
+                               [
+                                       ["Shadow Atlas", 9], ["Directional Shadow Atlas", 10], ["Directional Shadow Splits", 14]
+                               ]
+                       ],
+                       ["Lights", 
+                               [
+                                       ["Omni Lights Cluster", 20], ["Spot Lights Cluster", 21]
+                               ]
+                       ],
+                       ["VoxelGI", 
+                               [
+                                       ["VoxelGI Albedo", 6], ["VoxelGI Lighting", 7], ["VoxelGI Emission", 8]
+                               ]
+                       ],
+                       ["SDFGI", 
+                               [
+                                       ["SDFGI", 16], ["SDFGI Probes", 17], ["GI Buffer", 18]
+                               ]
+                       ],
+                       ["Environment", 
+                               [
+                                       ["SSAO", 12], ["SSIL", 13]
+                               ]
+                       ],
+                       ["Decals", 
+                               [
+                                       ["Decal Atlas", 15], ["Decal Cluster", 22]
+                               ]
+                       ],
+                       ["Others", 
+                               [
+                                       ["Normal Buffer", 5], ["Scene Luminance", 11], ["Disable LOD", 19], ["Cluster Reflection Probes", 23], ["Occluders", 24], ["Motion Vectors", 25]
+                               ]
+                       ],
+               ]
+       ],
+]
+
+enum SESSION {
+       TRANSLATE,
+       ROTATE,
+       SCALE,
+       NONE
+}
+
+var translate_snap_line_edit
+var rotate_snap_line_edit
+var scale_snap_line_edit
+var local_space_button
+var snap_button
+var overlay_control
+var spatial_editor_viewports
+var debug_draw_pie_menu
+var overlay_control_canvas_layer = CanvasLayer.new()
+
+var overlay_label = Label.new()
+var axis_mesh_inst
+var axis_im = ImmediateMesh.new()
+var axis_im_material = StandardMaterial3D.new()
+
+var current_session = SESSION.NONE
+var pivot_point = Vector3.ZERO
+var constraint_axis = Vector3.ONE
+var translate_snap = 1.0
+var rotate_snap = deg_to_rad(15.0)
+var scale_snap = 0.1
+var is_snapping = false
+var is_global = true
+var axis_length = 1000
+var precision_mode = false
+var precision_factor = 0.1
+
+var _is_editing = false
+var _camera
+var _editing_transform = Transform3D.IDENTITY
+var _applying_transform = Transform3D.IDENTITY
+var _last_world_pos = Vector3.ZERO
+var _init_angle = NAN
+var _last_angle = 0
+var _last_center_offset = 0
+var _cummulative_center_offset = 0
+var _max_x = 0
+var _min_x = 0
+var _cache_global_transforms = []
+var _cache_transforms = [] # Nodes' local transform relative to pivot_point
+var _input_string = ""
+var _is_global_on_session = false
+var _is_warping_mouse = false
+
+
+func _init():
+       axis_im_material.flags_unshaded = true
+       axis_im_material.vertex_color_use_as_albedo = true
+       axis_im_material.flags_no_depth_test = true
+
+       overlay_label.set("custom_colors/font_color_shadow", Color.BLACK)
+
+func _ready():
+       var spatial_editor = Utils.get_spatial_editor(get_editor_interface().get_base_control())
+       var snap_dialog = Utils.get_snap_dialog(spatial_editor)
+       var snap_dialog_line_edits = Utils.get_snap_dialog_line_edits(snap_dialog)
+       translate_snap_line_edit = snap_dialog_line_edits[0]
+       rotate_snap_line_edit = snap_dialog_line_edits[1]
+       scale_snap_line_edit = snap_dialog_line_edits[2]
+       translate_snap_line_edit.connect("text_changed", _on_snap_value_changed.bind(SESSION.TRANSLATE))
+       rotate_snap_line_edit.connect("text_changed", _on_snap_value_changed.bind(SESSION.ROTATE))
+       scale_snap_line_edit.connect("text_changed", _on_snap_value_changed.bind(SESSION.SCALE))
+       local_space_button = Utils.get_spatial_editor_local_space_button(spatial_editor)
+       local_space_button.connect("toggled", _on_local_space_button_toggled)
+       snap_button = Utils.get_spatial_editor_snap_button(spatial_editor)
+       snap_button.connect("toggled", _on_snap_button_toggled)
+       debug_draw_pie_menu = PieMenuGroupScn.instantiate()
+       debug_draw_pie_menu.populate_menu(DEBUG_DRAW_OPTIONS, PieMenuScn.instantiate())
+       debug_draw_pie_menu.theme_source_node = spatial_editor
+       debug_draw_pie_menu.connect("item_focused", _on_PieMenu_item_focused)
+       debug_draw_pie_menu.connect("item_selected", _on_PieMenu_item_selected)
+       var spatial_editor_viewport_container = Utils.get_spatial_editor_viewport_container(spatial_editor)
+       if spatial_editor_viewport_container:
+               spatial_editor_viewports = Utils.get_spatial_editor_viewports(spatial_editor_viewport_container)
+       sync_settings()
+
+func _input(event):
+       if event is InputEventKey:
+               if event.pressed and not event.echo:
+                       match event.keycode:
+                               KEY_Z:
+                                       if debug_draw_pie_menu.visible:
+                                               debug_draw_pie_menu.hide()
+                                               get_viewport().set_input_as_handled()
+                                       else:
+                                               if not (event.ctrl_pressed or event.alt_pressed or event.shift_pressed) and current_session == SESSION.NONE:
+                                                       show_debug_draw_pie_menu()
+                                                       get_viewport().set_input_as_handled()
+                       # Hacky way to intercept default shortcut behavior when in session
+                       if current_session != SESSION.NONE:
+                               var event_text = event.as_text()
+                               if event_text.begins_with("Kp"):
+                                       append_input_string(event_text.replace("Kp ", ""))
+                                       get_viewport().set_input_as_handled()
+                               match event.keycode:
+                                       KEY_Y:
+                                               if event.shift_pressed:
+                                                       toggle_constraint_axis(Vector3.RIGHT + Vector3.BACK)
+                                               else:
+                                                       toggle_constraint_axis(Vector3.UP)
+                                               get_viewport().set_input_as_handled()
+       
+       if event is InputEventMouseMotion:
+               if current_session != SESSION.NONE and overlay_control:
+                       # Infinite mouse movement
+                       var rect = overlay_control.get_rect()
+                       var local_mouse_pos = overlay_control.get_local_mouse_position()
+                       if not rect.has_point(local_mouse_pos):
+                               var warp_pos = Utils.infinite_rect(rect, local_mouse_pos, -event.velocity.normalized() * rect.size.length())
+                               if warp_pos:
+                                       Input.warp_mouse(overlay_control.global_position + warp_pos)
+                                       _is_warping_mouse = true
+
+func _on_snap_value_changed(text, session):
+       match session:
+               SESSION.TRANSLATE:
+                       translate_snap = text.to_float()
+               SESSION.ROTATE:
+                       rotate_snap = deg_to_rad(text.to_float())
+               SESSION.SCALE:
+                       scale_snap = text.to_float() / 100.0
+
+func _on_PieMenu_item_focused(menu, index):
+       var value = menu.buttons[index].get_meta("value", 0)
+       if not (value is Array):
+               switch_display_mode(value)
+
+func _on_PieMenu_item_selected(menu, index):
+       var value = menu.buttons[index].get_meta("value", 0)
+       if not (value is Array):
+               switch_display_mode(value)
+
+func show_debug_draw_pie_menu():
+       var spatial_editor_viewport = Utils.get_focused_spatial_editor_viewport(spatial_editor_viewports)
+       overlay_control = Utils.get_spatial_editor_viewport_control(spatial_editor_viewport) if spatial_editor_viewport else null
+       if overlay_control_canvas_layer.get_parent() != overlay_control:
+               overlay_control.add_child(overlay_control_canvas_layer)
+       if debug_draw_pie_menu.get_parent() != overlay_control_canvas_layer:
+               overlay_control_canvas_layer.add_child(debug_draw_pie_menu)
+               var viewport = Utils.get_spatial_editor_viewport_viewport(spatial_editor_viewport)
+
+       debug_draw_pie_menu.popup(overlay_control.get_global_mouse_position())
+
+func _on_local_space_button_toggled(pressed):
+       is_global = !pressed
+
+func _on_snap_button_toggled(pressed):
+       is_snapping = pressed
+
+func _handles(object):
+       if object is Node3D:
+               _is_editing = get_editor_interface().get_selection().get_selected_nodes().size()
+               return _is_editing
+       else:
+               _is_editing = get_editor_interface().get_selection().get_transformable_selected_nodes().size() > 0
+               return _is_editing
+       return false
+
+func _edit(object):
+       var scene_root = get_editor_interface().get_edited_scene_root()
+       if scene_root:
+               # Let editor free axis_mesh_inst as the scene closed,
+               # then create new instance whenever needed
+               if not is_instance_valid(axis_mesh_inst):
+                       axis_mesh_inst = MeshInstance3D.new()
+                       axis_mesh_inst.mesh = axis_im
+                       axis_mesh_inst.material_override = axis_im_material
+               if axis_mesh_inst.get_parent() == null:
+                       scene_root.get_parent().add_child(axis_mesh_inst)
+               else:
+                       if axis_mesh_inst.get_parent() != scene_root:
+                               axis_mesh_inst.get_parent().remove_child(axis_mesh_inst)
+                               scene_root.get_parent().add_child(axis_mesh_inst)
+
+func _forward_3d_gui_input(camera, event):
+       var forward = false
+       if current_session == SESSION.NONE:
+               if _is_editing:
+                       if event is InputEventKey:
+                               if event.pressed:
+                                       match event.keycode:
+                                               KEY_G:
+                                                       start_session(SESSION.TRANSLATE, camera, event)
+                                                       forward = true
+                                               KEY_R:
+                                                       start_session(SESSION.ROTATE, camera, event)
+                                                       forward = true
+                                               KEY_S:
+                                                       if not event.ctrl_pressed:
+                                                               start_session(SESSION.SCALE, camera, event)
+                                                               forward = true
+                                               KEY_H:
+                                                       commit_hide_nodes()
+                                               KEY_X:
+                                                       if event.shift_pressed:
+                                                               delete_selected_nodes()
+                                                       else:
+                                                               confirm_delete_selected_nodes()
+       else:
+               if event is InputEventKey:
+                       # Not sure why event.pressed always return false for numpad keys
+                       match event.keycode:
+                               KEY_KP_SUBTRACT:
+                                       toggle_input_string_sign()
+                                       return true
+                               KEY_KP_ENTER:
+                                       commit_session()
+                                       end_session()
+                                       return true
+                       
+                       if event.keycode == KEY_SHIFT:
+                               precision_mode = event.pressed
+                               forward = true
+
+                       if event.pressed:
+                               var event_text = event.as_text()
+                               if append_input_string(event_text):
+                                       return true
+                               match event.keycode:
+                                       KEY_G:
+                                               if current_session != SESSION.TRANSLATE:
+                                                       revert()
+                                                       clear_session()
+                                                       start_session(SESSION.TRANSLATE, camera, event)
+                                                       return true
+                                       KEY_R:
+                                               if current_session != SESSION.ROTATE:
+                                                       revert()
+                                                       clear_session()
+                                                       start_session(SESSION.ROTATE, camera, event)
+                                                       return true
+                                       KEY_S:
+                                               if not event.ctrl_pressed:
+                                                       if current_session != SESSION.SCALE:
+                                                               revert()
+                                                               clear_session()
+                                                               start_session(SESSION.SCALE, camera, event)
+                                                               return true
+                                       KEY_X:
+                                               if event.shift_pressed:
+                                                       toggle_constraint_axis(Vector3.UP + Vector3.BACK)
+                                               else:
+                                                       toggle_constraint_axis(Vector3.RIGHT)
+                                               return true
+                                       KEY_Y:
+                                               if event.shift_pressed:
+                                                       toggle_constraint_axis(Vector3.RIGHT + Vector3.BACK)
+                                               else:
+                                                       toggle_constraint_axis(Vector3.UP)
+                                               return true
+                                       KEY_Z:
+                                               if event.shift_pressed:
+                                                       toggle_constraint_axis(Vector3.RIGHT + Vector3.UP)
+                                               else:
+                                                       toggle_constraint_axis(Vector3.BACK)
+                                               return true
+                                       KEY_MINUS:
+                                               toggle_input_string_sign()
+                                               return true
+                                       KEY_BACKSPACE:
+                                               trim_input_string()
+                                               return true
+                                       KEY_ENTER:
+                                               commit_session()
+                                               end_session()
+                                               return true
+                                       KEY_ESCAPE:
+                                               revert()
+                                               end_session()
+                                               return true
+               
+               if event is InputEventMouseButton:
+                       if event.pressed:
+                               commit_session()
+                               end_session()
+                               forward = true
+
+               if event is InputEventMouseMotion:
+                       match current_session:
+                               SESSION.TRANSLATE, SESSION.ROTATE, SESSION.SCALE:
+                                       mouse_transform(event)
+                                       update_overlays()
+                                       forward = true
+
+       return forward
+       
+func _forward_3d_draw_over_viewport(overlay):
+       if current_session == SESSION.NONE:
+               if overlay_label.get_parent() != null:
+                       overlay_label.get_parent().remove_child(overlay_label)
+               return
+
+       var editor_settings = get_editor_interface().get_editor_settings()
+       var line_color = DEFAULT_LINE_COLOR
+       if editor_settings.has_setting("editors/3d/selection_box_color"):
+               line_color = editor_settings.get_setting("editors/3d/selection_box_color")
+       var snapped = "snapped" if is_snapping else ""
+       var global_or_local = "global" if is_global else "local"
+       var along_axis = ""
+       if not constraint_axis.is_equal_approx(Vector3.ONE):
+               if constraint_axis.x > 0:
+                       along_axis = "X"
+               if constraint_axis.y > 0:
+                       along_axis += ", Y" if along_axis.length() else "Y"
+               if constraint_axis.z > 0:
+                       along_axis += ", Z" if along_axis.length() else "Z"
+       if along_axis.length():
+               along_axis = "along " + along_axis
+
+       if overlay_label.get_parent() == null:
+               overlay_control.add_child(overlay_label)
+               overlay_label.set_anchors_and_offsets_preset(Control.PRESET_BOTTOM_LEFT)
+               overlay_label.position += Vector2(8, -8)
+       match current_session:
+               SESSION.TRANSLATE:
+                       var translation = _applying_transform.origin
+                       overlay_label.text = ("Translate (%.3f, %.3f, %.3f) %s %s %s" % [translation.x, translation.y, translation.z, global_or_local, along_axis, snapped])
+               SESSION.ROTATE:
+                       var rotation = _applying_transform.basis.get_euler()
+                       overlay_label.text = ("Rotate (%.3f, %.3f, %.3f) %s %s %s" % [rad_to_deg(rotation.x), rad_to_deg(rotation.y), rad_to_deg(rotation.z), global_or_local, along_axis, snapped])
+               SESSION.SCALE:
+                       var scale = _applying_transform.basis.get_scale()
+                       overlay_label.text = ("Scale (%.3f, %.3f, %.3f) %s %s %s" % [scale.x, scale.y, scale.z, global_or_local, along_axis, snapped])
+       if not _input_string.is_empty():
+               overlay_label.text += "(%s)" % _input_string
+       var is_pivot_point_behind_camera = _camera.is_position_behind(pivot_point)
+       var screen_origin = overlay.size / 2.0 if is_pivot_point_behind_camera else _camera.unproject_position(pivot_point)
+       Utils.draw_dashed_line(overlay, screen_origin, overlay.get_local_mouse_position(), line_color, 1, 5, true, true)
+
+func text_transform(text):
+       var input_value = text.to_float()
+       match current_session:
+               SESSION.TRANSLATE:
+                       _applying_transform.origin = constraint_axis * input_value
+               SESSION.ROTATE:
+                       _applying_transform.basis = Basis().rotated((-_camera.global_transform.basis.z * constraint_axis).normalized(), deg_to_rad(input_value))
+               SESSION.SCALE:
+                       if constraint_axis.x:
+                               _applying_transform.basis.x = Vector3.RIGHT * input_value
+                       if constraint_axis.y:
+                               _applying_transform.basis.y = Vector3.UP * input_value
+                       if constraint_axis.z:
+                               _applying_transform.basis.z = Vector3.BACK * input_value
+       var nodes = get_editor_interface().get_selection().get_transformable_selected_nodes()
+       var t = _applying_transform
+       if is_global or (constraint_axis.is_equal_approx(Vector3.ONE) and current_session == SESSION.TRANSLATE):
+               t.origin += pivot_point
+               Utils.apply_global_transform(nodes, t, _cache_transforms)
+       else:
+               Utils.apply_transform(nodes, t, _cache_global_transforms)
+
+func mouse_transform(event):
+       var nodes = get_editor_interface().get_selection().get_transformable_selected_nodes()
+       var is_single_node = nodes.size() == 1
+       var node1 = nodes[0]
+       var is_pivot_point_behind_camera = _camera.is_position_behind(pivot_point)
+       if is_nan(_init_angle):
+               var screen_origin = _camera.unproject_position(pivot_point)
+               _init_angle = event.position.angle_to_point(screen_origin)
+       # Translation offset
+       var plane_transform = _camera.global_transform
+       plane_transform.origin = pivot_point
+       plane_transform.basis = plane_transform.basis.rotated(plane_transform.basis * Vector3.LEFT, deg_to_rad(90))
+       if is_pivot_point_behind_camera:
+               plane_transform.origin = _camera.global_transform.origin + -_camera.global_transform.basis.z * 10.0
+       var plane = Utils.transform_to_plane(plane_transform)
+       var axis_count = get_constraint_axis_count()
+       if axis_count == 2:
+               var normal = (Vector3.ONE - constraint_axis).normalized()
+               if is_single_node and not is_global:
+                       normal = node1.global_transform.basis * normal
+               var plane_dist = normal * plane_transform.origin
+               plane = Plane(normal, plane_dist.x + plane_dist.y + plane_dist.z)
+       var world_pos = Utils.project_on_plane(_camera, event.position, plane)
+       if not is_global and is_single_node and axis_count < 3:
+               var normalized_node1_basis = node1.global_transform.basis.scaled(Vector3.ONE / node1.global_transform.basis.get_scale())
+               world_pos = world_pos * normalized_node1_basis
+       if is_equal_approx(_last_world_pos.length(), 0):
+               _last_world_pos = world_pos
+       var offset = world_pos - _last_world_pos
+       offset *= constraint_axis
+       offset = offset.snapped(Vector3.ONE * 0.001)
+       if _is_warping_mouse:
+               offset = Vector3.ZERO
+       # Rotation offset
+       var screen_origin = _camera.unproject_position(pivot_point)
+       if is_pivot_point_behind_camera:
+               screen_origin = overlay_control.size / 2.0
+       var angle = event.position.angle_to_point(screen_origin) - _init_angle
+       var angle_offset = angle - _last_angle
+       angle_offset = snapped(angle_offset, 0.001)
+       # Scale offset
+       if _max_x == 0:
+               _max_x = event.position.x
+               _min_x = _max_x - (_max_x - screen_origin.x) * 2
+       var center_value = 2 * ((event.position.x - _min_x) / (_max_x - _min_x)) - 1
+       if _last_center_offset == 0:
+               _last_center_offset = center_value
+       var center_offset = center_value - _last_center_offset
+       center_offset = snapped(center_offset, 0.001)
+       if _is_warping_mouse:
+               center_offset = 0
+       _cummulative_center_offset += center_offset
+       if _input_string.is_empty():
+               match current_session:
+                       SESSION.TRANSLATE:
+                               _editing_transform = _editing_transform.translated(offset)
+                               _applying_transform.origin = _editing_transform.origin
+                               if is_snapping:
+                                       var snap = Vector3.ONE * (translate_snap if not precision_mode else translate_snap * precision_factor)
+                                       _applying_transform.origin = _applying_transform.origin.snapped(snap)
+                       SESSION.ROTATE:
+                               var rotation_axis = (-_camera.global_transform.basis.z * constraint_axis).normalized()
+                               if not rotation_axis.is_equal_approx(Vector3.ZERO):
+                                       _editing_transform.basis = _editing_transform.basis.rotated(rotation_axis, angle_offset)
+                                       var quat = _editing_transform.basis.get_rotation_quaternion()
+                                       if is_snapping:
+                                               var snap = Vector3.ONE * (rotate_snap if not precision_mode else rotate_snap * precision_factor)
+                                               quat.from_euler(quat.get_euler().snapped(snap))
+                                       _applying_transform.basis = Basis(quat)
+                       SESSION.SCALE:
+                               if constraint_axis.x:
+                                       _editing_transform.basis.x = Vector3.RIGHT * (1 + _cummulative_center_offset)
+                               if constraint_axis.y:
+                                       _editing_transform.basis.y = Vector3.UP * (1 + _cummulative_center_offset)
+                               if constraint_axis.z:
+                                       _editing_transform.basis.z = Vector3.BACK * (1 + _cummulative_center_offset)
+                               _applying_transform.basis = _editing_transform.basis
+                               if is_snapping:
+                                       var snap = Vector3.ONE * (scale_snap if not precision_mode else scale_snap * precision_factor)
+                                       _applying_transform.basis.x = _applying_transform.basis.x.snapped(snap)
+                                       _applying_transform.basis.y = _applying_transform.basis.y.snapped(snap)
+                                       _applying_transform.basis.z = _applying_transform.basis.z.snapped(snap)
+
+       var t = _applying_transform
+       if is_global or (constraint_axis.is_equal_approx(Vector3.ONE) and current_session == SESSION.TRANSLATE):
+               t.origin += pivot_point
+               Utils.apply_global_transform(nodes, t, _cache_transforms)
+       else:
+               Utils.apply_transform(nodes, t, _cache_global_transforms)
+       _last_world_pos = world_pos
+       _last_center_offset = center_value
+       _last_angle = angle
+       _is_warping_mouse = false
+
+func cache_selected_nodes_transforms():
+       var nodes = get_editor_interface().get_selection().get_transformable_selected_nodes()
+       var inversed_pivot_transform = Transform3D().translated(pivot_point).affine_inverse()
+       for i in nodes.size():
+               var node = nodes[i]
+               _cache_global_transforms.append(node.global_transform)
+               _cache_transforms.append(inversed_pivot_transform * node.global_transform)
+
+func update_pivot_point():
+       var nodes = get_editor_interface().get_selection().get_transformable_selected_nodes()
+       var aabb = AABB()
+       for i in nodes.size():
+               var node = nodes[i]
+               if i == 0:
+                       aabb.position = node.global_transform.origin
+               aabb = aabb.expand(node.global_transform.origin)
+       pivot_point = aabb.position + aabb.size / 2.0
+
+func start_session(session, camera, event):
+       if get_editor_interface().get_selection().get_transformable_selected_nodes().size() == 0:
+               return
+       current_session = session
+       _camera = camera
+       _is_global_on_session = is_global
+       update_pivot_point()
+       cache_selected_nodes_transforms()
+
+       if event.alt_pressed:
+               commit_reset_transform()
+               end_session()
+               return
+
+       update_overlays()
+       var spatial_editor_viewport = Utils.get_focused_spatial_editor_viewport(spatial_editor_viewports)
+       overlay_control = Utils.get_spatial_editor_viewport_control(spatial_editor_viewport) if spatial_editor_viewport else null
+
+func end_session():
+       _is_editing = get_editor_interface().get_selection().get_transformable_selected_nodes().size() > 0
+       # Manually set is_global to avoid triggering revert()
+       if is_instance_valid(local_space_button):
+               local_space_button.button_pressed = !_is_global_on_session
+       is_global = _is_global_on_session
+       clear_session()
+       update_overlays()
+
+func commit_session():
+       var undo_redo = get_undo_redo()
+       var nodes = get_editor_interface().get_selection().get_transformable_selected_nodes()
+       Utils.revert_transform(nodes, _cache_global_transforms)
+       undo_redo.create_action(SESSION.keys()[current_session].to_lower().capitalize())
+       var t = _applying_transform
+       if is_global or (constraint_axis.is_equal_approx(Vector3.ONE) and current_session == SESSION.TRANSLATE):
+               t.origin += pivot_point
+               undo_redo.add_do_method(Utils, "apply_global_transform", nodes, t, _cache_transforms)
+       else:
+               undo_redo.add_do_method(Utils, "apply_transform", nodes, t, _cache_global_transforms)
+       undo_redo.add_undo_method(Utils, "revert_transform", nodes, _cache_global_transforms)
+       undo_redo.commit_action()
+
+func commit_reset_transform():
+       var undo_redo = get_undo_redo()
+       var nodes = get_editor_interface().get_selection().get_transformable_selected_nodes()
+       match current_session:
+               SESSION.TRANSLATE:
+                       undo_redo.create_action("Reset Translation")
+                       undo_redo.add_do_method(Utils, "reset_translation", nodes)
+                       undo_redo.add_undo_method(Utils, "revert_transform", nodes, _cache_global_transforms)
+                       undo_redo.commit_action()
+               SESSION.ROTATE:
+                       undo_redo.create_action("Reset Rotation")
+                       undo_redo.add_do_method(Utils, "reset_rotation", nodes)
+                       undo_redo.add_undo_method(Utils, "revert_transform", nodes, _cache_global_transforms)
+                       undo_redo.commit_action()
+               SESSION.SCALE:
+                       undo_redo.create_action("Reset Scale")
+                       undo_redo.add_do_method(Utils, "reset_scale", nodes)
+                       undo_redo.add_undo_method(Utils, "revert_transform", nodes, _cache_global_transforms)
+                       undo_redo.commit_action()
+       current_session = SESSION.NONE
+
+func commit_hide_nodes():
+       var undo_redo = get_undo_redo()
+       var nodes = get_editor_interface().get_selection().get_transformable_selected_nodes()
+       undo_redo.create_action("Hide Nodes")
+       undo_redo.add_do_method(Utils, "hide_nodes", nodes, true)
+       undo_redo.add_undo_method(Utils, "hide_nodes", nodes, false)
+       undo_redo.commit_action()
+
+## Opens a popup dialog to confirm deletion of selected nodes.
+func confirm_delete_selected_nodes():
+       var selected_nodes = get_editor_interface().get_selection().get_selected_nodes()
+       if selected_nodes.is_empty():
+               return
+
+       var editor_theme = get_editor_interface().get_base_control().theme
+       var popup = ConfirmationDialog.new()
+       popup.theme = editor_theme
+
+       # Setting dialog text dynamically depending on the selection to mimick Godot's normal behavior.
+       popup.dialog_text = "Delete "
+       var selection_size = selected_nodes.size()
+       if selection_size == 1:
+               popup.dialog_text += selected_nodes[0].get_name()
+       elif selection_size > 1:
+               popup.dialog_text += str(selection_size) + " nodes"
+       for node in selected_nodes:
+               if node.get_child_count() > 0:
+                       popup.dialog_text += " and children"
+                       break
+       popup.dialog_text += "?"
+
+       add_child(popup)
+       popup.popup_centered()
+       popup.canceled.connect(popup.queue_free)
+       popup.confirmed.connect(delete_selected_nodes)
+       popup.confirmed.connect(popup.queue_free)
+
+## Instantly deletes selected nodes and creates an undo history entry.
+func delete_selected_nodes():
+       var undo_redo = get_undo_redo()
+       
+       var selected_nodes = get_editor_interface().get_selection().get_selected_nodes()
+       # Avoid creating an unnecessary history entry if no nodes are selected.
+       if selected_nodes.is_empty():
+               return
+
+       undo_redo.create_action("Delete Nodes", UndoRedo.MERGE_DISABLE)
+       for node in selected_nodes:
+               # We can't free nodes, they must be kept in memory for undo to work.
+               # That's why we use remove_child instead and call UndoRedo.add_undo_reference() below.
+               undo_redo.add_do_method(node.get_parent(), "remove_child", node)
+               undo_redo.add_undo_method(node.get_parent(), "add_child", node, true)
+               undo_redo.add_undo_method(node.get_parent(), "move_child", node, node.get_index())
+               # Every node's owner must be set upon undoing, otherwise, it won't appear in the scene dock
+               # and it'll be lost upon saving.
+               undo_redo.add_undo_method(node, "set_owner", node.owner)
+               for child in Utils.recursive_get_children(node):
+                       undo_redo.add_undo_method(child, "set_owner", node.owner)
+               undo_redo.add_undo_reference(node)
+       undo_redo.commit_action()
+
+func revert():
+       var nodes = get_editor_interface().get_selection().get_transformable_selected_nodes()
+       Utils.revert_transform(nodes, _cache_global_transforms)
+       _editing_transform = Transform3D.IDENTITY
+       _applying_transform = Transform3D.IDENTITY
+       _last_world_pos = Vector3.ZERO
+       axis_im.clear_surfaces()
+
+func clear_session():
+       current_session = SESSION.NONE
+       constraint_axis = Vector3.ONE
+       pivot_point = Vector3.ZERO
+       precision_mode = false
+       _editing_transform = Transform3D.IDENTITY
+       _applying_transform = Transform3D.IDENTITY
+       _last_world_pos = Vector3.ZERO
+       _init_angle = NAN
+       _last_angle = 0
+       _last_center_offset = 0
+       _cummulative_center_offset = 0
+       _max_x = 0
+       _min_x = 0
+       _cache_global_transforms = []
+       _cache_transforms = []
+       _input_string = ""
+       _is_warping_mouse = false
+       axis_im.clear_surfaces()
+
+func sync_settings():
+       if translate_snap_line_edit:
+               translate_snap = translate_snap_line_edit.text.to_float()
+       if rotate_snap_line_edit:
+               rotate_snap = deg_to_rad(rotate_snap_line_edit.text.to_float())
+       if scale_snap_line_edit:
+               scale_snap = scale_snap_line_edit.text.to_float() / 100.0
+       if local_space_button:
+               is_global = !local_space_button.button_pressed
+       if snap_button:
+               is_snapping = snap_button.button_pressed
+
+func switch_display_mode(debug_draw):
+       var spatial_editor_viewport = Utils.get_focused_spatial_editor_viewport(spatial_editor_viewports)
+       if spatial_editor_viewport:
+               var viewport = Utils.get_spatial_editor_viewport_viewport(spatial_editor_viewport)
+               viewport.debug_draw = debug_draw
+
+# Repeatedly applying same axis will results in toggling is_global, just like pressing xx, yy or zz in blender
+func toggle_constraint_axis(axis):
+       # Following order as below:
+       # 1) Apply constraint on current mode
+       # 2) Toggle mode
+       # 3) Toggle mode again, and remove constraint
+       if is_global == _is_global_on_session:
+               if not constraint_axis.is_equal_approx(axis):
+                       # 1
+                       set_constraint_axis(axis)
+               else:
+                       # 2
+                       set_is_global(!_is_global_on_session)
+       else:
+               if constraint_axis.is_equal_approx(axis):
+                       # 3
+                       set_is_global(_is_global_on_session)
+                       set_constraint_axis(Vector3.ONE)
+               else:
+                       # Others situation
+                       set_constraint_axis(axis)
+
+func toggle_input_string_sign():
+       if _input_string.begins_with("-"):
+               _input_string = _input_string.trim_prefix("-")
+       else:
+               _input_string = "-" + _input_string
+       input_string_changed()
+
+func trim_input_string():
+       _input_string = _input_string.substr(0, _input_string.length() - 1)
+       input_string_changed()
+
+func append_input_string(text):
+       text = "." if text == "Period" else text
+       if text.is_valid_int() or text == ".":
+               _input_string += text
+               input_string_changed()
+               return true
+
+func input_string_changed():
+       if not _input_string.is_empty():
+               text_transform(_input_string)
+       else:
+               _applying_transform = Transform3D.IDENTITY
+               var nodes = get_editor_interface().get_selection().get_transformable_selected_nodes()
+               Utils.revert_transform(nodes, _cache_global_transforms)
+       update_overlays()
+
+func get_constraint_axis_count():
+       var axis_count = 3
+       if constraint_axis.x == 0:
+               axis_count -= 1
+       if constraint_axis.y == 0:
+               axis_count -= 1
+       if constraint_axis.z == 0:
+               axis_count -= 1
+       return axis_count
+
+func set_constraint_axis(v):
+       revert()
+       if constraint_axis != v:
+               constraint_axis = v
+               draw_axises()
+       else:
+               constraint_axis = Vector3.ONE
+       if not _input_string.is_empty():
+               text_transform(_input_string)
+       update_overlays()
+
+func set_is_global(v):
+       if is_global != v:
+               if is_instance_valid(local_space_button):
+                       local_space_button.button_pressed = !v
+               revert()
+               is_global = v
+               draw_axises()
+               if not _input_string.is_empty():
+                       text_transform(_input_string)
+               update_overlays()
+
+func draw_axises():
+       if not constraint_axis.is_equal_approx(Vector3.ONE):
+               var nodes = get_editor_interface().get_selection().get_transformable_selected_nodes()
+               var axis_lines = []
+               if constraint_axis.x > 0:
+                       axis_lines.append({"axis": Vector3.RIGHT, "color": Color.RED})
+               if constraint_axis.y > 0:
+                       axis_lines.append({"axis": Vector3.UP, "color": Color.GREEN})
+               if constraint_axis.z > 0:
+                       axis_lines.append({"axis": Vector3.BACK, "color": Color.BLUE})
+
+               for axis_line in axis_lines:
+                       var axis = axis_line.get("axis")
+                       var color = axis_line.get("color")
+                       if is_global:
+                               var is_pivot_point_behind_camera = _camera.is_position_behind(pivot_point)
+                               var axis_origin = _camera.global_transform.origin + -_camera.global_transform.basis.z * 10.0 if is_pivot_point_behind_camera else pivot_point
+                               Utils.draw_axis(axis_im, axis_origin, axis, axis_length, color)
+                       else:
+                               for node in nodes:
+                                       Utils.draw_axis(axis_im, node.global_transform.origin, node.global_transform.basis * axis, axis_length, color)
diff --git a/addons/gd-blender-3d-shortcuts/scenes/pie_menu/PieMenu.gd b/addons/gd-blender-3d-shortcuts/scenes/pie_menu/PieMenu.gd
new file mode 100644 (file)
index 0000000..de83552
--- /dev/null
@@ -0,0 +1,159 @@
+@tool
+extends Control
+
+signal item_selected(index)
+signal item_focused(index)
+signal item_cancelled()
+
+const button_margin = 6
+
+@export var items := [] : set =  set_items
+@export var selected_index = -1 : set = set_selected_index
+@export var radius = 100.0 : set = set_radius
+
+var buttons = []
+var pie_menus = []
+
+var focused_index = -1
+var theme_source_node = self : set = set_theme_source_node
+var grow_with_max_button_width = false
+
+
+func _ready():
+       set_items(items)
+       set_selected_index(selected_index)
+       set_radius(radius)
+       hide()
+       connect("visibility_changed", _on_visiblity_changed)
+
+func _input(event):
+       if visible:
+               if event is InputEventKey:
+                       if event.pressed:
+                               match event.keycode:
+                                       KEY_ESCAPE:
+                                               cancel()
+               if event is InputEventMouseMotion:
+                       focus_item()
+                       get_viewport().set_input_as_handled()
+               if event is InputEventMouseButton:
+                       if event.pressed:
+                               match event.button_index:
+                                       MOUSE_BUTTON_LEFT:
+                                               select_item(focused_index)
+                                               get_viewport().set_input_as_handled()
+                                       MOUSE_BUTTON_RIGHT:
+                                               cancel()
+                                               get_viewport().set_input_as_handled()
+
+func _on_visiblity_changed():
+       if not visible:
+               if selected_index != focused_index: # Cancellation
+                       focused_index = selected_index
+
+func cancel():
+       hide()
+       get_viewport().set_input_as_handled()
+       emit_signal("item_cancelled")
+
+func select_item(index):
+       set_button_style(selected_index, "normal", "normal")
+       selected_index = index
+       focused_index = selected_index
+       hide()
+       emit_signal("item_selected", selected_index)
+
+func focus_item():
+       queue_redraw()
+       var pos = get_global_mouse_position()
+       var count = max(buttons.size(), 1)
+       var angle_offset = 2 * PI / count
+       var angle = pos.angle_to_point(global_position) + PI / 2 # -90 deg initial offset
+       if angle < 0:
+               angle += 2 * PI
+       
+       var index = (angle / angle_offset)
+       var decimal = index - floor(index)
+       index = floor(index)
+       if decimal >= 0.5:
+               index += 1
+       if index > buttons.size()-1:
+               index = 0
+       
+       set_button_style(focused_index, "normal", "normal")
+       focused_index = index
+       set_button_style(focused_index, "normal", "hover")
+       set_button_style(selected_index, "normal", "focus")
+       emit_signal("item_focused", focused_index)
+
+func popup(pos):
+       global_position = pos
+       show()
+
+func populate_menu():
+       clear_menu()
+       buttons = []
+       for i in items.size():
+               var item = items[i]
+               var is_array = item is Array
+               var name = item if not is_array else item[0]
+               var value = null if not is_array else item[1]
+               var button = Button.new()
+               button.grow_horizontal = Control.GROW_DIRECTION_BOTH
+               button.text = name
+               if value != null:
+                       button.set_meta("value", value)
+               buttons.append(button)
+               set_button_style(i, "hover", "hover")
+               set_button_style(i, "pressed", "pressed")
+               set_button_style(i, "focus", "focus")
+               set_button_style(i, "disabled", "disabled")
+               set_button_style(i, "normal", "normal")
+               add_child(button)
+       align()
+
+       set_button_style(selected_index, "normal", "focus")
+
+func align():
+       var final_radius = radius
+       if grow_with_max_button_width:
+               var max_button_width = 0.0
+               for button in buttons:
+                       max_button_width = max(max_button_width, button.size.x)
+               final_radius = max(radius, max_button_width)
+       var count = max(buttons.size(), 1)
+       var angle_offset = 2 * PI / count
+       var angle = PI / 2 # 90 deg initial offset
+       for button in buttons:
+               button.position = Vector2(final_radius, 0.0).rotated(angle) - (button.size / 2.0)
+               angle += angle_offset
+
+func clear_menu():
+       for button in buttons:
+               button.queue_free()
+
+func set_button_style(index, name, source):
+       if index < 0 or index > buttons.size() - 1:
+               return
+
+       buttons[index].set("theme_override_styles/%s" % name, get_theme_stylebox(source, "Button"))
+
+func set_items(v):
+       items = v
+       if is_inside_tree():
+               populate_menu()
+
+func set_selected_index(v):
+       set_button_style(selected_index, "normal", "normal")
+       selected_index = v
+       set_button_style(selected_index, "normal", "focus")
+
+func set_radius(v):
+       radius = v
+       align()
+
+func set_theme_source_node(v):
+       theme_source_node = v
+       for pie_menu in pie_menus:
+               if pie_menu:
+                       pie_menu.theme_source_node = theme_source_node
diff --git a/addons/gd-blender-3d-shortcuts/scenes/pie_menu/PieMenu.tscn b/addons/gd-blender-3d-shortcuts/scenes/pie_menu/PieMenu.tscn
new file mode 100644 (file)
index 0000000..e7d367c
--- /dev/null
@@ -0,0 +1,11 @@
+[gd_scene load_steps=2 format=3 uid="uid://bxummco35581e"]
+
+[ext_resource type="Script" path="res://addons/gd-blender-3d-shortcuts/scenes/pie_menu/PieMenu.gd" id="1"]
+
+[node name="PieMenu" type="Control"]
+visible = false
+layout_mode = 3
+anchors_preset = 15
+anchor_right = 1.0
+anchor_bottom = 1.0
+script = ExtResource("1")
diff --git a/addons/gd-blender-3d-shortcuts/scenes/pie_menu/PieMenuGroup.gd b/addons/gd-blender-3d-shortcuts/scenes/pie_menu/PieMenuGroup.gd
new file mode 100644 (file)
index 0000000..997035e
--- /dev/null
@@ -0,0 +1,113 @@
+@tool
+extends Control
+const PieMenuScn = preload("PieMenu.tscn")
+
+signal item_focused(menu, index)
+signal item_selected(menu, index)
+signal item_cancelled(menu)
+
+var root
+var page_index = [0]
+var theme_source_node = self : set = set_theme_source_node
+
+
+func _ready():
+       hide()
+
+func _on_item_cancelled(pie_menu):
+       back()
+       emit_signal("item_cancelled", pie_menu)
+
+func _on_item_focused(index, pie_menu):
+       var current_menu = get_current_menu()
+       if current_menu == pie_menu:
+               emit_signal("item_focused", current_menu, index)
+
+func _on_item_selected(index):
+       var last_menu = get_current_menu()
+       page_index.append(index)
+       var current_menu = get_current_menu()
+       if current_menu:
+               current_menu.selected_index = -1
+               if current_menu.pie_menus.size() > 0: # Has next page
+                       current_menu.popup(global_position)
+       else:
+               # Final selection, revert page index
+               if page_index.size() > 1:
+                       page_index.pop_back()
+               last_menu = get_current_menu()
+               page_index = [0]
+               hide()
+               emit_signal("item_selected", last_menu, index)
+
+func popup(pos):
+       global_position = pos
+       var pie_menu = get_current_menu()
+       pie_menu.popup(global_position)
+       show()
+
+func populate_menu(items, pie_menu):
+       add_child(pie_menu)
+       if not root:
+               root = pie_menu
+               root.connect("item_focused", _on_item_focused.bind(pie_menu))
+               root.connect("item_selected", _on_item_selected)
+               root.connect("item_cancelled", _on_item_cancelled.bind(pie_menu))
+
+       pie_menu.items = items
+
+       for i in items.size():
+               var item = items[i]
+               var is_array = item is Array
+               # var name = item if not is_array else item[0]
+               var value = null if not is_array else item[1]
+               if value is Array:
+                       var new_pie_menu = PieMenuScn.instantiate()
+                       new_pie_menu.connect("item_focused", _on_item_focused.bind(new_pie_menu))
+                       new_pie_menu.connect("item_selected", _on_item_selected)
+                       new_pie_menu.connect("item_cancelled", _on_item_cancelled.bind(new_pie_menu))
+                       
+                       populate_menu(value, new_pie_menu)
+                       pie_menu.pie_menus.append(new_pie_menu)
+               else:
+                       pie_menu.pie_menus.append(null)
+       return pie_menu
+
+func clear_menu():
+       if root:
+               root.queue_free()
+
+func back():
+       var last_menu = get_current_menu()
+       last_menu.hide()
+       page_index.pop_back()
+       if page_index.size() == 0:
+               page_index = [0]
+               hide()
+               return
+       else:
+               var current_menu = get_current_menu()
+               if current_menu:
+                       current_menu.popup(global_position)
+
+func get_menu(indexes=[0]):
+       var pie_menu = root
+       for i in indexes.size():
+               if i == 0:
+                       continue # root
+               
+               var page = indexes[i]
+               pie_menu = pie_menu.pie_menus[page]
+       return pie_menu
+
+func get_current_menu():
+       return get_menu(page_index)
+
+func set_theme_source_node(v):
+       theme_source_node = v
+       if not root:
+               return
+       
+       for pie_menu in root.pie_menus:
+               if pie_menu:
+                       pie_menu.theme_source_node = theme_source_node
diff --git a/addons/gd-blender-3d-shortcuts/scenes/pie_menu/PieMenuGroup.tscn b/addons/gd-blender-3d-shortcuts/scenes/pie_menu/PieMenuGroup.tscn
new file mode 100644 (file)
index 0000000..db382e5
--- /dev/null
@@ -0,0 +1,13 @@
+[gd_scene load_steps=2 format=3 uid="uid://c4cfbaj52t05b"]
+
+[ext_resource type="Script" path="res://addons/gd-blender-3d-shortcuts/scenes/pie_menu/PieMenuGroup.gd" id="1"]
+
+[node name="PieMenuGroup" type="Control"]
+visible = false
+layout_mode = 3
+anchors_preset = 15
+anchor_right = 1.0
+anchor_bottom = 1.0
+grow_horizontal = 2
+grow_vertical = 2
+script = ExtResource("1")
diff --git a/addons/gdscript_formatter/plugin.cfg b/addons/gdscript_formatter/plugin.cfg
new file mode 100644 (file)
index 0000000..1b85829
--- /dev/null
@@ -0,0 +1,13 @@
+[plugin]
+
+name="GDScript Formatter"
+description="Using \"gdtoolkit\" to format GDScripts.
+1. Require \"pip\" for install/update \"gdtoolkit\".
+2. Default format shortcut is \"Shift+Alt+F\".
+3. You can update gdtoolkit by:
+               Project->Tool->GDScript Formatter: Install/Update gdtoolkit
+4. You can change shorcut and format preference by edit resources in \"res://addons/gdscript_formatter/\".
+5. Format on save: default is disabled, turn on by setting \"format_preference.tres\"'s property \"format_on_save\" to true."
+author="DaylilyZeleen-忘忧の([email protected])"
+version="0.0.3"
+script="gdscript_formatter.gd"