diff --git a/Translations/Translations.pot b/Translations/Translations.pot index 79470e50e..73bfd1c6b 100644 --- a/Translations/Translations.pot +++ b/Translations/Translations.pot @@ -1275,6 +1275,16 @@ msgid "Line Tool\n\n" "Hold %s to displace the shape's origin" msgstr "" +msgid "Curve Tool\n\n" +"%s for left mouse button\n" +"%s for right mouse button\n\n" +"Draws bezier curves\n" +"Press and drag to control the curvature\n" +"Press and drag to curve them\n" +"Press %s to remove the last added point" +msgstr "" + + msgid "Rectangle Tool\n\n" "%s for left mouse button\n" "%s for right mouse button\n\n" @@ -2342,6 +2352,9 @@ msgstr "" msgid "Line Tool" msgstr "" +msgid "Curve Tool" +msgstr "" + msgid "Rectangle Tool" msgstr "" diff --git a/assets/graphics/tools/cursors/curvetool.png b/assets/graphics/tools/cursors/curvetool.png new file mode 100644 index 000000000..fe18bac1e Binary files /dev/null and b/assets/graphics/tools/cursors/curvetool.png differ diff --git a/assets/graphics/tools/cursors/curvetool.png.import b/assets/graphics/tools/cursors/curvetool.png.import new file mode 100644 index 000000000..038d7e5ca --- /dev/null +++ b/assets/graphics/tools/cursors/curvetool.png.import @@ -0,0 +1,34 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://deuy67ughoib3" +path="res://.godot/imported/curvetool.png-035b507342a996edf5b660cc7e5f8671.ctex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://assets/graphics/tools/cursors/curvetool.png" +dest_files=["res://.godot/imported/curvetool.png-035b507342a996edf5b660cc7e5f8671.ctex"] + +[params] + +compress/mode=0 +compress/high_quality=false +compress/lossy_quality=0.7 +compress/hdr_compression=1 +compress/normal_map=0 +compress/channel_pack=0 +mipmaps/generate=false +mipmaps/limit=-1 +roughness/mode=0 +roughness/src_normal="" +process/fix_alpha_border=true +process/premult_alpha=false +process/normal_map_invert_y=false +process/hdr_as_srgb=false +process/hdr_clamp_exposure=false +process/size_limit=0 +detect_3d/compress_to=1 diff --git a/assets/graphics/tools/curvetool.png b/assets/graphics/tools/curvetool.png new file mode 100644 index 000000000..2b8c0942e Binary files /dev/null and b/assets/graphics/tools/curvetool.png differ diff --git a/assets/graphics/tools/curvetool.png.import b/assets/graphics/tools/curvetool.png.import new file mode 100644 index 000000000..836f9816e --- /dev/null +++ b/assets/graphics/tools/curvetool.png.import @@ -0,0 +1,34 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://br0lilqrvdvdc" +path="res://.godot/imported/curvetool.png-15311b84332d233b1a0c1817d9a6bd5d.ctex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://assets/graphics/tools/curvetool.png" +dest_files=["res://.godot/imported/curvetool.png-15311b84332d233b1a0c1817d9a6bd5d.ctex"] + +[params] + +compress/mode=0 +compress/high_quality=false +compress/lossy_quality=0.7 +compress/hdr_compression=1 +compress/normal_map=0 +compress/channel_pack=0 +mipmaps/generate=false +mipmaps/limit=-1 +roughness/mode=0 +roughness/src_normal="" +process/fix_alpha_border=true +process/premult_alpha=false +process/normal_map_invert_y=false +process/hdr_as_srgb=false +process/hdr_clamp_exposure=false +process/size_limit=0 +detect_3d/compress_to=1 diff --git a/project.godot b/project.godot index 62bdc3866..5446367f8 100644 --- a/project.godot +++ b/project.godot @@ -865,6 +865,16 @@ go_to_next_layer={ "events": [Object(InputEventKey,"resource_local_to_scene":false,"resource_name":"","device":-1,"window_id":0,"command_or_control_autoremap":true,"alt_pressed":false,"shift_pressed":false,"pressed":false,"keycode":0,"physical_keycode":4194320,"key_label":0,"unicode":0,"echo":false,"script":null) ] } +left_curvetool_tool={ +"deadzone": 0.5, +"events": [Object(InputEventKey,"resource_local_to_scene":false,"resource_name":"","device":-1,"window_id":0,"alt_pressed":false,"shift_pressed":false,"ctrl_pressed":false,"meta_pressed":false,"pressed":false,"keycode":74,"physical_keycode":0,"key_label":0,"unicode":106,"echo":false,"script":null) +] +} +right_curvetool_tool={ +"deadzone": 0.5, +"events": [Object(InputEventKey,"resource_local_to_scene":false,"resource_name":"","device":-1,"window_id":0,"alt_pressed":true,"shift_pressed":false,"ctrl_pressed":false,"meta_pressed":false,"pressed":false,"keycode":74,"physical_keycode":0,"key_label":0,"unicode":106,"echo":false,"script":null) +] +} [input_devices] diff --git a/src/Autoload/Tools.gd b/src/Autoload/Tools.gd index 25b374058..e47b8bd8a 100644 --- a/src/Autoload/Tools.gd +++ b/src/Autoload/Tools.gd @@ -151,6 +151,22 @@ Hold %s to displace the shape's origin""", ["shape_perfect", "shape_center", "shape_displace"] ) ), + "CurveTool": + ( + Tool + . new( + "CurveTool", + "Curve Tool", + "curvetool", + "res://src/Tools/DesignTools/CurveTool.tscn", + [Global.LayerTypes.PIXEL], + """Draws bezier curves +Press %s/%s to add new points +Press and drag to control the curvature +Press %s to remove the last added point""", + ["activate_left_tool", "activate_right_tool", "change_tool_mode"] + ) + ), "RectangleTool": ( Tool diff --git a/src/Tools/DesignTools/CurveTool.gd b/src/Tools/DesignTools/CurveTool.gd new file mode 100644 index 000000000..5fed0692d --- /dev/null +++ b/src/Tools/DesignTools/CurveTool.gd @@ -0,0 +1,234 @@ +extends "res://src/Tools/BaseDraw.gd" + +var _curve := Curve2D.new() ## The [Curve2D] responsible for the shape of the curve being drawn. +var _drawing := false ## Set to true when a curve is being drawn. +var _editing_bezier := false ## Needed to determine when to show the control points preview line. +var _thickness := 1 ## The thickness of the curve. + + +func _init() -> void: + Global.project_about_to_switch.connect(_draw_shape) # To prevent tool from remaining active + _drawer.color_op = Drawer.ColorOp.new() + update_indicator() + + +func update_brush() -> void: + pass + + +func _on_Thickness_value_changed(value: int) -> void: + _thickness = value + update_indicator() + update_config() + save_config() + + +func update_indicator() -> void: + var bitmap := BitMap.new() + bitmap.create(Vector2i.ONE * _thickness) + bitmap.set_bit_rect(Rect2i(Vector2i.ZERO, Vector2i.ONE * _thickness), true) + _indicator = bitmap + _polylines = _create_polylines(_indicator) + + +func get_config() -> Dictionary: + var config := super.get_config() + config["thickness"] = _thickness + return config + + +func set_config(config: Dictionary) -> void: + super.set_config(config) + _thickness = config.get("thickness", _thickness) + + +func update_config() -> void: + super.update_config() + $ThicknessSlider.value = _thickness + + +func _input(event: InputEvent) -> void: + if _drawing: + if event is InputEventMouseButton: + if event.double_click and event.button_index == tool_slot.button: + $DoubleClickTimer.start() + _draw_shape() + else: + if event.is_action_pressed("change_tool_mode"): # Control removes the last added point + if _curve.point_count > 1: + _curve.remove_point(_curve.point_count - 1) + + +func draw_start(pos: Vector2i) -> void: + if !$DoubleClickTimer.is_stopped(): + return + pos = snap_position(pos) + super.draw_start(pos) + if Input.is_action_pressed("shape_displace"): + _picking_color = true + _pick_color(pos) + return + Global.canvas.selection.transform_content_confirm() + update_mask() + if !_drawing: + _drawing = true + _curve.add_point(pos) + + +func draw_move(pos: Vector2i) -> void: + pos = snap_position(pos) + super.draw_move(pos) + if _picking_color: # Still return even if we released Alt + if Input.is_action_pressed("shape_displace"): + _pick_color(pos) + return + if _drawing: + _editing_bezier = true + var current_position := _curve.get_point_position(_curve.point_count - 1) - Vector2(pos) + _curve.set_point_in(_curve.point_count - 1, current_position) + _curve.set_point_out(_curve.point_count - 1, -current_position) + + +func draw_end(pos: Vector2i) -> void: + _editing_bezier = false + super.draw_end(pos) + + +func draw_preview() -> void: + if not _drawing: + return + var canvas: Node2D = Global.canvas.previews + var pos := canvas.position + var canvas_scale := canvas.scale + if Global.mirror_view: # This fixes previewing in mirror mode + pos.x = pos.x + Global.current_project.size.x + canvas_scale.x = -1 + + var points := _bezier() + canvas.draw_set_transform(pos, canvas.rotation, canvas_scale) + var indicator := _fill_bitmap_with_points(points, Global.current_project.size) + + for line in _create_polylines(indicator): + canvas.draw_polyline(PackedVector2Array(line), Color.BLACK) + + canvas.draw_set_transform(canvas.position, canvas.rotation, canvas.scale) + + if _editing_bezier: + var start := _curve.get_point_position(0) + if _curve.point_count > 1: + start = ( + _curve.get_point_position(_curve.point_count - 1) + + _curve.get_point_in(_curve.point_count - 1) + ) + var end := ( + _curve.get_point_position(_curve.point_count - 1) + + _curve.get_point_out(_curve.point_count - 1) + ) + if Global.mirror_view: # This fixes previewing in mirror mode + start.x = Global.current_project.size.x - start.x - 1 + end.x = Global.current_project.size.x - end.x - 1 + + canvas.draw_line(start, end, Color.BLACK) + var circle_radius := Vector2.ONE * (5.0 / Global.camera.zoom.x) + draw_empty_circle(canvas, start, circle_radius, Color.BLACK) + draw_empty_circle(canvas, end, circle_radius, Color.BLACK) + + +func _draw_shape() -> void: + var points := _bezier() + prepare_undo("Draw Shape") + for point in points: + # Reset drawer every time because pixel perfect sometimes breaks the tool + _drawer.reset() + # Draw each point offsetted based on the shape's thickness + draw_tool(point) + _curve.clear_points() + _drawing = false + commit_undo() + + +## Get the [member _curve]'s baked points, and draw lines between them using [method _fill_gap]. +func _bezier() -> Array[Vector2i]: + var last_pixel := Global.canvas.current_pixel + if Global.mirror_view: + # Mirror the last point of the curve + last_pixel.x = (Global.current_project.size.x - 1) - last_pixel.x + _curve.add_point(last_pixel) + var points := _curve.get_baked_points() + _curve.remove_point(_curve.point_count - 1) + var final_points: Array[Vector2i] = [] + for i in points.size() - 1: + var point1 := points[i] + var point2 := points[i + 1] + final_points.append_array(_fill_gap(point1, point2)) + return final_points + + +## Fills the gap between [param point_a] and [param point_b] using Bresenham's line algorithm. +## Takes the [member _thickness] into account. +func _fill_gap(point_a: Vector2i, point_b: Vector2i) -> Array[Vector2i]: + var array: Array[Vector2i] = [] + var dx := absi(point_b.x - point_a.x) + var dy := -absi(point_b.y - point_a.y) + var err := dx + dy + var e2 := err << 1 + var sx := 1 if point_a.x < point_b.x else -1 + var sy := 1 if point_a.y < point_b.y else -1 + var x := point_a.x + var y := point_a.y + + var start := point_a - Vector2i.ONE * (_thickness >> 1) + var end := start + Vector2i.ONE * _thickness + for yy in range(start.y, end.y): + for xx in range(start.x, end.x): + array.append(Vector2i(xx, yy)) + + while !(x == point_b.x && y == point_b.y): + e2 = err << 1 + if e2 >= dy: + err += dy + x += sx + if e2 <= dx: + err += dx + y += sy + + var pos := Vector2i(x, y) + start = pos - Vector2i.ONE * (_thickness >> 1) + end = start + Vector2i.ONE * _thickness + for yy in range(start.y, end.y): + for xx in range(start.x, end.x): + array.append(Vector2i(xx, yy)) + + return array + + +func _fill_bitmap_with_points(points: Array[Vector2i], bitmap_size: Vector2i) -> BitMap: + var bitmap := BitMap.new() + bitmap.create(bitmap_size) + + for point in points: + if point.x < 0 or point.y < 0 or point.x >= bitmap_size.x or point.y >= bitmap_size.y: + continue + bitmap.set_bitv(point, 1) + + return bitmap + + +# Thanks to +# https://www.reddit.com/r/godot/comments/3ktq39/drawing_empty_circles_and_curves/cv0f4eo/ +func draw_empty_circle( + canvas: CanvasItem, circle_center: Vector2, circle_radius: Vector2, color: Color +) -> void: + var draw_counter := 1 + var line_origin := Vector2() + var line_end := Vector2() + line_origin = circle_radius + circle_center + + while draw_counter <= 360: + line_end = circle_radius.rotated(deg_to_rad(draw_counter)) + circle_center + canvas.draw_line(line_origin, line_end, color) + draw_counter += 1 + line_origin = line_end + + line_end = circle_radius.rotated(TAU) + circle_center + canvas.draw_line(line_origin, line_end, color) diff --git a/src/Tools/DesignTools/CurveTool.tscn b/src/Tools/DesignTools/CurveTool.tscn new file mode 100644 index 000000000..34874b3e6 --- /dev/null +++ b/src/Tools/DesignTools/CurveTool.tscn @@ -0,0 +1,39 @@ +[gd_scene load_steps=5 format=3 uid="uid://ckfvd8gf3oy4r"] + +[ext_resource type="PackedScene" uid="uid://ubyatap3sylf" path="res://src/Tools/BaseDraw.tscn" id="1_rvuea"] +[ext_resource type="Script" path="res://src/Tools/DesignTools/CurveTool.gd" id="2_tjnp6"] +[ext_resource type="PackedScene" uid="uid://yjhp0ssng2mp" path="res://src/UI/Nodes/ValueSlider.tscn" id="3_g0nav"] + +[sub_resource type="ButtonGroup" id="ButtonGroup_ko8wf"] +resource_name = "rotate" +allow_unpress = true + +[node name="ToolOptions" instance=ExtResource("1_rvuea")] +script = ExtResource("2_tjnp6") + +[node name="ThicknessSlider" parent="." index="2" instance=ExtResource("3_g0nav")] +layout_mode = 2 +min_value = 1.0 +value = 1.0 +prefix = "Size:" +suffix = "px" +global_increment_action = "brush_size_increment" +global_decrement_action = "brush_size_decrement" + +[node name="Rotate90" parent="RotationOptions/Rotate" index="0"] +button_group = SubResource("ButtonGroup_ko8wf") + +[node name="Rotate180" parent="RotationOptions/Rotate" index="1"] +button_group = SubResource("ButtonGroup_ko8wf") + +[node name="Rotate270" parent="RotationOptions/Rotate" index="2"] +button_group = SubResource("ButtonGroup_ko8wf") + +[node name="Brush" parent="." index="4"] +visible = false + +[node name="DoubleClickTimer" type="Timer" parent="." index="6"] +wait_time = 0.2 +one_shot = true + +[connection signal="value_changed" from="ThicknessSlider" to="." method="_on_Thickness_value_changed"]