From 8cd84fb1fc8220e3f33af798a8923ff3f9077433 Mon Sep 17 00:00:00 2001 From: Eduardo Date: Tue, 6 Feb 2024 01:33:02 +0100 Subject: [PATCH] addon to use the blender keys --- addons/gd-blender-3d-shortcuts/Utils.gd | 188 +++++ addons/gd-blender-3d-shortcuts/plugin.cfg | 7 + addons/gd-blender-3d-shortcuts/plugin.gd | 794 ++++++++++++++++++ .../scenes/pie_menu/PieMenu.gd | 159 ++++ .../scenes/pie_menu/PieMenu.tscn | 11 + .../scenes/pie_menu/PieMenuGroup.gd | 113 +++ .../scenes/pie_menu/PieMenuGroup.tscn | 13 + addons/gdscript_formatter/plugin.cfg | 13 + 8 files changed, 1298 insertions(+) create mode 100644 addons/gd-blender-3d-shortcuts/Utils.gd create mode 100644 addons/gd-blender-3d-shortcuts/plugin.cfg create mode 100644 addons/gd-blender-3d-shortcuts/plugin.gd create mode 100644 addons/gd-blender-3d-shortcuts/scenes/pie_menu/PieMenu.gd create mode 100644 addons/gd-blender-3d-shortcuts/scenes/pie_menu/PieMenu.tscn create mode 100644 addons/gd-blender-3d-shortcuts/scenes/pie_menu/PieMenuGroup.gd create mode 100644 addons/gd-blender-3d-shortcuts/scenes/pie_menu/PieMenuGroup.tscn create mode 100644 addons/gdscript_formatter/plugin.cfg diff --git a/addons/gd-blender-3d-shortcuts/Utils.gd b/addons/gd-blender-3d-shortcuts/Utils.gd new file mode 100644 index 0000000..6a559e3 --- /dev/null +++ b/addons/gd-blender-3d-shortcuts/Utils.gd @@ -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 index 0000000..f07fa59 --- /dev/null +++ b/addons/gd-blender-3d-shortcuts/plugin.cfg @@ -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 index 0000000..b6e56ac --- /dev/null +++ b/addons/gd-blender-3d-shortcuts/plugin.gd @@ -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 index 0000000..de83552 --- /dev/null +++ b/addons/gd-blender-3d-shortcuts/scenes/pie_menu/PieMenu.gd @@ -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 index 0000000..e7d367c --- /dev/null +++ b/addons/gd-blender-3d-shortcuts/scenes/pie_menu/PieMenu.tscn @@ -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 index 0000000..997035e --- /dev/null +++ b/addons/gd-blender-3d-shortcuts/scenes/pie_menu/PieMenuGroup.gd @@ -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 index 0000000..db382e5 --- /dev/null +++ b/addons/gd-blender-3d-shortcuts/scenes/pie_menu/PieMenuGroup.tscn @@ -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 index 0000000..1b85829 --- /dev/null +++ b/addons/gdscript_formatter/plugin.cfg @@ -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-忘忧の(daylily-zeleen@foxmail.com)" +version="0.0.3" +script="gdscript_formatter.gd" -- 2.30.2