## Custom In-app Curve Editor class_name CustomCurveEditor extends VBoxContainer #region Theme Values ## Theme Type const THEME_TYPE : StringName = &"CustomCurveEditor" ## Background color theme key const THEME_BACKGROUND_COLOR : StringName = &"background_color" ## Background color theme key const THEME_BACKGROUND_STYLEBOX : StringName = &"background_box" ## Curve color theme key const THEME_CURVE_COLOR : StringName = &"curve_color" ## Curve thickness theme key const THEME_CURVE_THICKNESS : StringName = &"curve_thickness" ## Point size/radius theme key const THEME_POINT_SIZE : StringName = &"point_size" ## Point exclusion vincinity theme key const THEME_POINT_EXCLUSION_SIZE : StringName = &"point_exclusion_size" ## Point color theme key const THEME_POINT_COLOR : StringName = &"point_color" ## Selected point color theme key const THEME_SELECTED_POINT_COLOR : StringName = &"selected_point_color" ## Tangent length theme key const THEME_TANGENT_LENGTH : StringName = &"tangent_length" ## Tangent thickness theme key const THEME_TANGENT_THICKNESS : StringName = &"tangent_thickness" ## Tangent handle size theme key const THEME_TANGENT_HANDLE_SIZE : String = &"tangent_handle_size" ## Tangent color theme key const THEME_TANGENT_COLOR : StringName = &"tangent_color" ## Selected tangent color theme key const THEME_SELECTED_TANGENT_COLOR : StringName = &"selected_tangent_color" ## Default background color const DEFAULT_BACKGROUND_COLOR : Color = Color("#8888") ## Default curve color const DEFAULT_CURVE_COLOR : Color = Color.WHITE ## Default curve thickness const DEFAULT_CURVE_THICKNESS : int = 2 ## Default point size const DEFAULT_POINT_SIZE : int = 4 ## Default point exclusion vincinity const DEFAULT_POINT_EXCLUSION_SIZE : int = 8 ## Default point color const DEFAULT_POINT_COLOR : Color = DEFAULT_CURVE_COLOR ## Default selected point color const DEFAULT_SELECTED_POINT_COLOR : Color = Color.STEEL_BLUE ## Default tangent length const DEFAULT_TANGENT_LENGTH : int = 32 ## Default tangent length const DEFAULT_TANGENT_THICKNESS : int = 2 ## Default tangent handle size const DEFAULT_TANGENT_HANDLE_SIZE : int = 4 ## Default tangent color const DEFAULT_TANGENT_COLOR : Color = Color("#AAFD") ## Default selected tangent color const DEFAULT_SELECTED_TANGENT_COLOR : Color = Color("#F88E") #endregion #region Exports ## Editable Curve @export var curve : Curve : set(v): if curve and curve_render: curve.domain_changed.disconnect(_on_domain_changed) curve.range_changed.disconnect(_on_value_changed) curve.changed.disconnect(curve_render.queue_redraw) curve = v if curve_render: _connect_curve() ## Edition capabilities flag @export_subgroup("Edition", "edit_") ## Can create/update/delete points @export var edit_points : bool = false : set(v): if not v: selected_point = -1 edit_points = v if curve_render: curve_render.queue_redraw.call_deferred() ## Can update tangents @export var edit_tangents : bool = false : set(v): if not v: selected_tangent = 0 edit_tangents = v if curve_render: curve_render.queue_redraw.call_deferred() ## Can update value range @export var edit_range : bool = false : set(v): edit_range = v; if value_controls: value_controls.visible = v ## Can update domain range @export var edit_domain : bool = false : set(v): edit_domain = v; if domain_controls: domain_controls.visible = v ## Controls @export_subgroup("Controls", "control_") ## Add/selection/modification mouse button @export var control_selection_mouse_button : MouseButton = MOUSE_BUTTON_LEFT ## Deletion mouse button @export var control_deletion_mouse_button : MouseButton = MOUSE_BUTTON_RIGHT #endregion #region Internal Data ## Drawing and interacting with the curve var curve_render : ColorRect ## Container for value range controls var value_controls : Control ## Min value editor var min_value_edit : SpinBox ## Max value editor var max_value_edit : SpinBox ## Container for domain range controls var domain_controls : Control ## Min domain editor var min_domain_edit : SpinBox ## Max domain editor var max_domain_edit : SpinBox ## True if dragging a point var point_drag : bool ## True if dragging a tangent var tangent_drag : bool ## Selected point @onready var selected_point : int = -1 ## Selected tangent @onready var selected_tangent : int = 0 ## Left handle position var left_handle : Vector2 ## Right handle position var right_handle : Vector2 ## If true, edit tangent in linear mode var coupled_tangent : bool #endregion func _ready() -> void: curve_render = ColorRect.new() curve_render.color = Color.TRANSPARENT curve_render.size_flags_vertical = Control.SIZE_EXPAND_FILL add_child(curve_render, false, Node.INTERNAL_MODE_FRONT) var value_control_grid : GridContainer = GridContainer.new() value_control_grid.columns = 2 min_value_edit = _add_value_editor("Min Value", value_control_grid) max_value_edit = _add_value_editor("Max Value", value_control_grid) var domain_control_grid : GridContainer = GridContainer.new() domain_control_grid.columns = 2 min_domain_edit = _add_value_editor("Min Domain", domain_control_grid) max_domain_edit = _add_value_editor("Max Domain", domain_control_grid) min_value_edit.value_changed.connect(func (v : float) -> void: curve.min_value = v) max_value_edit.value_changed.connect(func (v : float) -> void: curve.max_value = v) min_domain_edit.value_changed.connect(func (v : float) -> void: curve.min_domain = v) max_domain_edit.value_changed.connect(func (v : float) -> void: curve.max_domain = v) value_controls = value_control_grid; value_controls.visible = edit_range domain_controls = domain_control_grid; domain_controls.visible = edit_domain add_child(value_controls, false, Node.INTERNAL_MODE_BACK) add_child(domain_controls, false, Node.INTERNAL_MODE_BACK) curve_render.draw.connect(_curve_draw) curve_render.gui_input.connect(_curve_input) if curve: _connect_curve.call_deferred() curve_render.queue_redraw() ## Add value editor to container func _add_value_editor(text : String, container : Control) -> SpinBox: var l : Label = Label.new(); l.text = text; l.size_flags_horizontal = Control.SIZE_EXPAND_FILL container.add_child(l) var editor : SpinBox = SpinBox.new(); editor.size_flags_horizontal = Control.SIZE_EXPAND_FILL editor.allow_lesser = true; editor.allow_greater = true; editor.step = 0.001 container.add_child(editor) return editor ## Connect curve func _connect_curve() -> void: min_value_edit.value = curve.min_value max_value_edit.value = curve.max_value min_domain_edit.value = curve.min_domain max_domain_edit.value = curve.max_domain curve.domain_changed.connect(_on_domain_changed) curve.range_changed.connect(_on_value_changed) curve.changed.connect(curve_render.queue_redraw) ## Update value range editors upon curve change func _on_value_changed() -> void: min_value_edit.value = curve.min_value max_value_edit.value = curve.max_value curve_render.queue_redraw() ## Update domain range editors upon curve change func _on_domain_changed() -> void: min_domain_edit.value = curve.min_domain max_domain_edit.value = curve.max_domain curve_render.queue_redraw() ## Manage input event to manipulate the curve func _curve_input(event : InputEvent) -> void: if not (curve && (edit_points || edit_tangents)): return if event as InputEventMouseButton: var mb : InputEventMouseButton = event as InputEventMouseButton if mb.pressed: var point_size : int = _get_constant(THEME_POINT_SIZE, DEFAULT_POINT_SIZE) if mb.button_index == control_selection_mouse_button: var point_vincinity : int = _get_constant(THEME_POINT_EXCLUSION_SIZE, DEFAULT_POINT_EXCLUSION_SIZE) var handle_size : int = _get_constant(THEME_TANGENT_HANDLE_SIZE, DEFAULT_TANGENT_HANDLE_SIZE) tangent_drag = false # Look for handle first if tangent edition is enabled if edit_tangents and selected_point >= 0: coupled_tangent = not mb.shift_pressed if selected_point > 0 and mb.position.distance_to(left_handle) < handle_size: curve.set_point_left_mode(selected_point, Curve.TANGENT_FREE) selected_tangent = -1 elif selected_point < (curve.point_count - 1) and mb.position.distance_to(right_handle) < handle_size: curve.set_point_right_mode(selected_point, Curve.TANGENT_FREE) selected_tangent = 1 else: selected_tangent = 0 if selected_tangent != 0: tangent_drag = true curve_render.queue_redraw();return # Look for pressed point if any, else create one. selected_point = -1 var too_close : bool = false for i : int in range(curve.point_count): var point_pos : Vector2 = _compute_point_in_render_space(curve.get_point_position(i)) var d : float = point_pos.distance_to(mb.position) if d < point_size: selected_point = i break elif d < max(point_size, point_vincinity): too_close = true if edit_points and selected_point < 0 and not too_close: # No point selected, but check if we are on the curve. var curve_thickness : int = _get_constant(THEME_CURVE_THICKNESS, DEFAULT_CURVE_THICKNESS) var y : float = _get_height_for(mb.position) if absf(y - mb.position.y) < (max(curve_thickness, point_size)): # Create a new point selected_point = curve.add_point(_compute_point_in_curve_space(mb.position)) curve_render.queue_redraw() point_drag = edit_points && selected_point >= 0 elif mb.button_index == control_deletion_mouse_button and edit_points: # Look for point in vincinity and delete it for i : int in range(curve.point_count): var point_pos : Vector2 = _compute_point_in_render_space(curve.get_point_position(i)) if point_pos.distance_to(mb.position) < point_size: selected_point = -1 curve.remove_point(i) break else: point_drag = false tangent_drag = false elif event is InputEventMouseMotion and (point_drag or tangent_drag): var mm : InputEventMouseMotion = event as InputEventMouseMotion var logical_position : Vector2 = _compute_point_in_curve_space(mm.position) if edit_points and point_drag: curve.set_point_value(selected_point, logical_position.y) selected_point = curve.set_point_offset(selected_point, logical_position.x) elif edit_tangents and tangent_drag: var point_pos : Vector2 = curve.get_point_position(selected_point) # Compute slope var refined_pos : Vector2 = logical_position refined_pos.x = max(logical_position.x, point_pos.x + 0.0001) if selected_tangent > 0 else min(logical_position.x, point_pos.x - 0.0001) var t : float = (point_pos.y - logical_position.y) / (point_pos.x - refined_pos.x) if selected_tangent < 0 or coupled_tangent: curve.set_point_left_tangent(selected_point, t) if selected_tangent > 0 or coupled_tangent: curve.set_point_right_tangent(selected_point, t) curve_render.queue_redraw() pass ## Draw the curve area func _curve_draw() -> void: if not curve: return RenderingServer.canvas_item_set_clip.call_deferred(curve_render.get_canvas_item(), true) var sz : Vector2 = curve_render.size var bg_color : Color = _get_color(THEME_BACKGROUND_COLOR, DEFAULT_BACKGROUND_COLOR) var curve_color : Color = _get_color(THEME_CURVE_COLOR, DEFAULT_CURVE_COLOR) var curve_thickness : int = _get_constant(THEME_CURVE_THICKNESS, DEFAULT_CURVE_THICKNESS) var point_size : int = _get_constant(THEME_POINT_SIZE, DEFAULT_POINT_SIZE) var point_color : Color = _get_color(THEME_POINT_COLOR, DEFAULT_POINT_COLOR) var selected_point_color : Color = _get_color(THEME_SELECTED_POINT_COLOR, DEFAULT_SELECTED_POINT_COLOR) var sbox : StyleBox = _get_style_box(THEME_BACKGROUND_STYLEBOX, null) if sbox is StyleBoxGrid: var r : Vector2 = Vector2(point_size, point_size) / sz sbox.range_min_x = curve.min_domain - r.x; sbox.range_max_x = curve.max_domain + r.x sbox.range_min_y = curve.min_value - r.y; sbox.range_max_y = curve.max_value + r.y var canvas_rect : Rect2 = Rect2(Vector2.ZERO, sz) if sbox: curve_render.draw_style_box(sbox, canvas_rect) else: curve_render.draw_rect(canvas_rect, bg_color) var nb_sample : int = curve.bake_resolution var sample_off : float = (curve.max_domain - curve.min_domain) / float(nb_sample-1) var points : Array[Vector2] = [] for i : int in range(nb_sample): var bake : float = (sample_off * i) + curve.min_domain points.append(_compute_point_in_render_space(Vector2(bake, curve.sample(bake)))) curve_render.draw_polyline(PackedVector2Array(points), curve_color, curve_thickness, true) for i : int in range(curve.point_count): var pos : Vector2 = _compute_point_in_render_space(curve.get_point_position(i)) if i == selected_point and edit_tangents: # Draw tangents var tangent_length : int = _get_constant(THEME_TANGENT_LENGTH, DEFAULT_TANGENT_LENGTH) var tangent_thickness : int = _get_constant(THEME_TANGENT_THICKNESS, DEFAULT_TANGENT_THICKNESS) var tangent_size : int = _get_constant(THEME_TANGENT_HANDLE_SIZE, DEFAULT_TANGENT_HANDLE_SIZE) var tangent_color : Color = _get_color(THEME_TANGENT_COLOR, DEFAULT_TANGENT_COLOR) var tangent_selected_color : Color = _get_color(THEME_SELECTED_TANGENT_COLOR, DEFAULT_SELECTED_TANGENT_COLOR) var rate : Vector2 = (curve_render.size - Vector2(point_size * 2, point_size * 2)) / Vector2(curve.get_domain_range(), curve.get_value_range()) rate.y = - rate.y if i > 0: # Left tangent left_handle = _draw_tangent( pos, -rate, curve.get_point_left_tangent(i), tangent_length, tangent_thickness, tangent_size, tangent_selected_color if selected_tangent < 0 else tangent_color) if i < curve.point_count - 1: # Right tangent right_handle = _draw_tangent( pos, rate, curve.get_point_right_tangent(i), tangent_length, tangent_thickness, tangent_size, tangent_selected_color if selected_tangent > 0 else tangent_color) curve_render.draw_circle(pos, point_size, selected_point_color if i == selected_point else point_color, true, -1.0, true) ## Draw point tangent func _draw_tangent(pos : Vector2, rate : Vector2, t : float, length : int, thickness : int, handle : int, c : Color) -> Vector2: var v : Vector2 = pos + Vector2(rate.x, t * rate.y).normalized() * length curve_render.draw_line(pos, v, c, thickness) curve_render.draw_circle(v, handle, c, true, -1.0, true) return v ## Convert a position from render space to curve space func _compute_point_in_curve_space(local_position : Vector2) -> Vector2: var point_size : int = _get_constant(THEME_POINT_SIZE, DEFAULT_POINT_SIZE) var x_pos : float = ((local_position.x - point_size) / (curve_render.size.x - (point_size * 2)) * curve.get_domain_range()) + curve.min_domain var y_pos : float = ((1. - (local_position.y - point_size) / (curve_render.size.y - (point_size * 2))) * curve.get_value_range()) + curve.min_value return Vector2(x_pos, y_pos) ## Convert a position from curve space to render space func _compute_point_in_render_space(logical_position : Vector2) -> Vector2: var point_size : int = _get_constant(THEME_POINT_SIZE, DEFAULT_POINT_SIZE) var curve_sz : Vector2 = curve_render.size - Vector2(point_size * 2, point_size * 2) return Vector2( point_size + ((logical_position.x - curve.min_domain) / curve.get_domain_range()) * curve_sz.x, point_size + (1.0 - ((logical_position.y - curve.min_value) / curve.get_value_range())) * curve_sz.y) ## Compute the curve position on Y-axis in render space given a point in render space func _get_height_for(local_position : Vector2) -> float: var point_size : int = _get_constant(THEME_POINT_SIZE, DEFAULT_POINT_SIZE) var x_pos : float = ((local_position.x - point_size) / (curve_render.size.x - (point_size * 2)) * curve.get_domain_range()) + curve.min_domain var value : float = curve.sample(x_pos) return point_size + (1.0 - ((value - curve.min_value) / curve.get_value_range())) * (curve_render.size.y - (point_size * 2)) ## Provide the theme color if it exists, else returns the specified default value func _get_color(color_name : StringName, def : Color) -> Color: return theme.get_color(color_name, THEME_TYPE) if theme and theme.has_color(color_name, THEME_TYPE) else def ## Provide the theme constant if it exists, else returns the specified default value func _get_constant(constant_name : StringName, def : int) -> int: return theme.get_constant(constant_name, THEME_TYPE) if theme and theme.has_constant(constant_name, THEME_TYPE) else def ## Provide the theme style if it exists, else returns the specified default value func _get_style_box(box_name : StringName, def : StyleBox) -> StyleBox: return theme.get_stylebox(box_name, THEME_TYPE) if theme and theme.has_stylebox(box_name, THEME_TYPE) else def
or share this direct link: