[gd_scene load_steps=5 format=3 uid="uid://bf3b0i8scthbm"]

[ext_resource type="Texture2D" uid="uid://bnkl8rujlgv0h" path="res://addons/SpritesheetGenerator/Checker.png" id="1_hs1uu"]

[sub_resource type="GDScript" id="1"]
resource_name = "Generator"
script/source = "extends Control

const SUPPORTED_FORMATS: PackedStringArray = [\"bmp\", \"dds\", \"exr\", \"hdr\", \"jpg\", \"jpeg\", \"png\", \"tga\", \"svg\", \"svgz\", \"webp\"]

@onready var grid := %GridContainer

var file_list: Array
var image_list: Array
var texture_list: Array

var images_to_process: Array
var images_to_texturize: Array
var first_time := true
var image_count: int
var output_path: String

var auto := true
var margin := Vector2.ONE

var pan_origin: Vector2
var pan_start: Vector2

signal images_processed

func _enter_tree() -> void:
	$SplitDialog.hide()
	$StashDialog.hide()

func _ready():
	$Status.text = $Status.text % \", \".join(SUPPORTED_FORMATS)
	
	get_viewport().files_dropped.connect(load_files)
	grid.minimum_size_changed.connect(refresh_background)
	set_process(false)

func refresh_background():
	%Background.custom_minimum_size = grid.get_minimum_size()

func load_files(files: PackedStringArray):
	file_list.clear()
	image_list.clear()
	
	%CustomName.text = \"\"
	%Reload.disabled = false
	%SavePNG.disabled = false
	
	if files.size() == 1 and not FileAccess.file_exists(files[0]):
		var dir := DirAccess.open(files[0])
		if not dir:
			show_error(\"Can't open directory.\")
			return
		
		for file in dir.get_files():
			if file.get_extension() in SUPPORTED_FORMATS:
				file_list.append(str(dir.get_current_dir().path_join(file)))
	else:
		var wrong_count: int
		for file in files:
			if file.get_extension() in SUPPORTED_FORMATS:
				file_list.append(file)
			else:
				wrong_count += 1
		
		if wrong_count > 0:
			show_error(\"Skipped %s file(s) with unsupported extension.\" % wrong_count)
	
	if file_list.is_empty():
		show_error(\"No valid files or directories to process.\")
		return
	
	load_images()

func load_images():
	texture_list.clear()
	
	for image in grid.get_children():
		image.free()
	
	for image in %StashImages.get_children():
		image.free()
	update_stash()
	
	var size_map: Dictionary
	
	if not file_list.is_empty():
		image_list = file_list.map(func(file: String):
			var image := Image.load_from_file(file)
			if image:
				image.set_meta(&\"path\", file)
			return image)
	
	for image in image_list:
		if not image:
			continue
		
		if not image.get_size() in size_map:
			size_map[image.get_size()] = []
		size_map[image.get_size()].append(image)
	
	var output_name: String
	var most_common_size: Vector2i
	var most_common_count: int
	
	for size in size_map:
		if size_map[size].size() > most_common_count:
			most_common_size = size
			most_common_count = size_map[size].size()
	
	for image in size_map[most_common_size]:
		if output_path.is_empty():
			var path: String = image.get_meta(&\"path\", \"\")
			output_path = path.get_base_dir()
			output_name = path.get_base_dir().get_file()
		
		images_to_process.append(image)
	size_map.clear()
	
	if not output_name.is_empty() and %CustomName.text.is_empty():
		%CustomName.text = output_name
	update_save_button()
	
	if images_to_process.size() < file_list.size():
		show_error(\"Rejected %s image(s) due to size mismatch.\" % (file_list.size() - images_to_process.size()))
	
	if images_to_process.size() == 1:
		if file_list.size() > 1:
			images_to_process.clear()
			show_error(\"Only one dropped image was valid.\")
		else:
			%SplitPreview.texture = ImageTexture.create_from_image(images_to_process[0])
			$SplitDialog.reset_size()
			$SplitDialog.popup_centered()
		
		return
	
	$Status.show()
	%CenterContainer.hide()
	
	image_count = images_to_process.size()
	%Columns.max_value = image_count
	
	threshold = %Threshold.value
	min_x = 9999999
	min_y = 9999999
	max_x = -9999999
	max_y = -9999999
	
	set_process(true)
	
	await images_processed
	
	for texture in texture_list:
		add_frame(texture)
	
	toggle_auto(auto)
	refresh_margin()
	
	$Status.hide()
	%CenterContainer.show()

var threshold: float
var min_x: int
var min_y: int
var max_x: int
var max_y: int

func _process(delta: float) -> void:
	if not images_to_process.is_empty():
		var image: Image = images_to_process.pop_front()
		$Status.text = str(\"Preprocessing image \", image_count - images_to_process.size(), \"/\", image_count)
		
		for x in image.get_width():
			for y in image.get_height():
				if image.get_pixel(x, y).a >= threshold:
					min_x = mini(min_x, x)
					min_y = mini(min_y, y)
					max_x = maxi(max_x, x)
					max_y = maxi(max_y, y)
		
		images_to_texturize.append(image)
	elif not images_to_texturize.is_empty():
		var rect := Rect2i(min_x, min_y, max_x - min_x + 1, max_y - min_y + 1)
		var image: Image = images_to_texturize.pop_front()
		$Status.text = str(\"Creating texture \", image_count - images_to_texturize.size(), \"/\", image_count)
		
		var true_image := Image.create(rect.size.x, rect.size.y, false, image.get_format())
		true_image.blit_rect(image, rect, Vector2())
		
		var texture := ImageTexture.create_from_image(true_image)
		texture_list.append(texture)
		
		if images_to_texturize.is_empty():
			set_process(false)
			images_processed.emit()
			if first_time:
				recenter()
				first_time = false

func toggle_grid(show: bool) -> void:
	get_tree().call_group(&\"frame\", &\"set_display_background\", show)

func toggle_auto(button_pressed: bool) -> void:
	%Columns.editable = not button_pressed
	auto = button_pressed
	
	if button_pressed:
		var best: int
		var best_score = -9999999
		
		for i in range(1, image_count + 1):
			var cols = i
			var rows = ceili(image_count / float(i))
			
			var score = image_count - cols * rows - maxi(cols, rows) - rows
			if score > best_score:
				best = i
				best_score = score
		
		grid.columns = best
	else:
		grid.columns = %Columns.value
	refresh_grid()

func hmargin_changed(value: float) -> void:
	margin.x = value
	refresh_margin()

func vmargin_changed(value: float) -> void:
	margin.y = value
	refresh_margin()

func refresh_margin():
	get_tree().call_group(&\"frame\", &\"set_frame_margin\", margin)

func columns_changed(value: float) -> void:
	grid.columns = value
	refresh_grid()

func refresh_grid():
	var coord: Vector2
	var dark = false
	
	for rect in grid.get_children():
		rect.set_background_color(Color(0, 0, 0, 0.2 if dark else 0.1))
		dark = not dark
		coord.x += 1
		
		if coord.x == grid.columns:
			coord.x = 0
			coord.y += 1
			dark = int(coord.y) % 2 == 1

func save_png() -> void:
	var image_size: Vector2 = grid.get_child(0).get_minimum_size()
	
	var image := Image.create(image_size.x * grid.columns, image_size.y * (ceil(grid.get_child_count() / float(grid.columns))), false, Image.FORMAT_RGBA8)
	
	for rect in grid.get_children():
		image.blit_rect(rect.get_texture_data(), Rect2(Vector2(), image_size), rect.get_position2())
	
	image.save_png(output_path.path_join(%CustomName.text) + \".png\")

func show_error(text: String):
	if not %Error.visible:
		%Error.show()
	else:
		%Error.text += \"\\n\"
	%Error.text += text
	%Timer.start()

func error_hidden() -> void:
	%Error.text = \"\"

func _input(event: InputEvent) -> void:
	if event is InputEventMouseButton:
		var cc: Control = %CenterContainer
		
		if event.button_index == MOUSE_BUTTON_MIDDLE:
			if event.pressed:
				pan_origin = get_local_mouse_position()
				pan_start = cc.position
			else:
				pan_origin = Vector2()
		
		if event.button_index == MOUSE_BUTTON_WHEEL_DOWN:
			var lm = cc.get_local_mouse_position()
			cc.scale -= Vector2.ONE * 0.05
			if cc.scale.x <= 0:
				cc.scale = Vector2.ONE * 0.05
			
			cc.position -= (lm - cc.get_local_mouse_position()) * cc.scale
		elif event.button_index == MOUSE_BUTTON_WHEEL_UP:
			var lm = cc.get_local_mouse_position()
			cc.scale += Vector2.ONE * 0.05
			cc.position -= (lm - cc.get_local_mouse_position()) * cc.scale
	
	if event is InputEventMouseMotion:
		if pan_origin != Vector2():
			%CenterContainer.position = pan_start + (get_local_mouse_position() - pan_origin)

func recenter() -> void:
	%CenterContainer.position = get_viewport().size / 2 - Vector2i(%CenterContainer.size) / 2
	%CenterContainer.scale = Vector2.ONE

func update_split_preview():
	%SplitPreview.queue_redraw()

func draw_split_preview() -> void:
	var preview: TextureRect = %SplitPreview
	var frame_count := Vector2(%SplitX.value, %SplitY.value)
	var frame_size := preview.size / frame_count
	
	for x in range(1, frame_count.x):
		for y in int(frame_count.y):
			preview.draw_line(frame_size * Vector2(x, y), frame_size * Vector2(x, y + 1), Color.WHITE)
			preview.draw_line(frame_size * Vector2(x, y) + Vector2.RIGHT, frame_size * Vector2(x, y + 1) + Vector2.RIGHT, Color.BLACK)
	
	for y in range(1, frame_count.y):
		for x in int(frame_count.x):
			preview.draw_line(frame_size * Vector2(x, y), frame_size * Vector2(x + 1, y), Color.WHITE)
			preview.draw_line(frame_size * Vector2(x, y) + Vector2.DOWN, frame_size * Vector2(x + 1, y) + Vector2.DOWN, Color.BLACK)

func split_spritesheet() -> void:
	file_list.clear()
	image_list.clear()
	
	var image: Image = images_to_process[0]
	var sub_image_size := image.get_size() / Vector2i(%SplitX.value, %SplitY.value)
	
	for y in %SplitY.value:
		for x in %SplitX.value:
			image_list.append(image.get_region(Rect2i(Vector2i(x, y) * sub_image_size, sub_image_size)))
	
	images_to_process.clear()
	load_images()

func remove_frame(frame):
	var image: Image = frame.get_texture_data()
	var texture := ImageTexture.create_from_image(image)
	
	var button := TextureButton.new()
	button.texture_normal = texture
	button.custom_minimum_size = Vector2(128, 128)
	button.stretch_mode = TextureButton.STRETCH_KEEP_ASPECT_CENTERED
	button.ignore_texture_size = true
	button.pressed.connect(re_add_image.bind(button), CONNECT_DEFERRED)
	%StashImages.add_child(button)
	
	var ref := ReferenceRect.new()
	button.add_child(ref)
	ref.set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT)
	ref.mouse_filter = Control.MOUSE_FILTER_IGNORE
	ref.editor_only = false
	
	frame.free()
	refresh_grid()
	update_stash()

func update_stash():
	%Stash.disabled = %StashImages.get_child_count() == 0

func re_add_image(tb: TextureButton):
	add_frame(tb.texture_normal)
	tb.free()
	refresh_grid()
	update_stash()
	
	if %Stash.disabled:
		$StashDialog.hide()

func add_frame(texture: Texture2D):
	var rect := preload(\"res://addons/SpritesheetGenerator/SpritesheetFrame.tscn\").instantiate()
	rect.set_texture(texture)
	rect.set_display_background(%DisplayGrid.button_pressed)
	rect.set_frame_margin(margin)
	grid.add_child(rect)

func update_save_button() -> void:
	%SavePNG.disabled = %CustomName.text.is_empty()
"

[sub_resource type="StyleBoxFlat" id="5"]
content_margin_left = 20.0
content_margin_top = 20.0
content_margin_right = 20.0
content_margin_bottom = 20.0
bg_color = Color(0, 0, 0, 0.25098)

[sub_resource type="StyleBoxTexture" id="StyleBoxTexture_kjgn5"]
texture = ExtResource("1_hs1uu")
axis_stretch_horizontal = 1
axis_stretch_vertical = 1

[node name="Main" type="HBoxContainer"]
anchors_preset = 15
anchor_right = 1.0
anchor_bottom = 1.0
grow_horizontal = 2
grow_vertical = 2
mouse_filter = 2
script = SubResource("1")

[node name="MarginContainer" type="PanelContainer" parent="."]
layout_mode = 2
theme_override_styles/panel = SubResource("5")

[node name="VBoxContainer" type="VBoxContainer" parent="MarginContainer"]
layout_mode = 2
theme_override_constants/separation = 10

[node name="Label5" type="Label" parent="MarginContainer/VBoxContainer"]
layout_mode = 2
text = "Alpha Threshold"
horizontal_alignment = 1

[node name="Threshold" type="SpinBox" parent="MarginContainer/VBoxContainer"]
unique_name_in_owner = true
layout_mode = 2
max_value = 1.0
step = 0.005
value = 0.9

[node name="Reload" type="Button" parent="MarginContainer/VBoxContainer"]
unique_name_in_owner = true
layout_mode = 2
disabled = true
text = "Reload"

[node name="HSeparator" type="HSeparator" parent="MarginContainer/VBoxContainer"]
layout_mode = 2

[node name="Label" type="Label" parent="MarginContainer/VBoxContainer"]
layout_mode = 2
text = "Columns"
horizontal_alignment = 1

[node name="Columns" type="SpinBox" parent="MarginContainer/VBoxContainer"]
unique_name_in_owner = true
layout_mode = 2
min_value = 1.0
value = 1.0
editable = false

[node name="Grid" type="CheckButton" parent="MarginContainer/VBoxContainer"]
layout_mode = 2
button_pressed = true
text = "Auto"

[node name="Label3" type="Label" parent="MarginContainer/VBoxContainer"]
layout_mode = 2
text = "Horizontal Margin"
horizontal_alignment = 1

[node name="MarginH" type="SpinBox" parent="MarginContainer/VBoxContainer"]
layout_mode = 2
value = 1.0
suffix = "px"

[node name="Label4" type="Label" parent="MarginContainer/VBoxContainer"]
layout_mode = 2
text = "Vertical Margin"
horizontal_alignment = 1

[node name="MarginV" type="SpinBox" parent="MarginContainer/VBoxContainer"]
layout_mode = 2
max_value = 128.0
value = 1.0
suffix = "px"

[node name="Stash" type="Button" parent="MarginContainer/VBoxContainer"]
unique_name_in_owner = true
layout_mode = 2
disabled = true
text = "Image Stash"

[node name="HSeparator2" type="HSeparator" parent="MarginContainer/VBoxContainer"]
layout_mode = 2

[node name="Button" type="Button" parent="MarginContainer/VBoxContainer"]
layout_mode = 2
text = "Recenter View
"

[node name="DisplayGrid" type="CheckBox" parent="MarginContainer/VBoxContainer"]
unique_name_in_owner = true
layout_mode = 2
button_pressed = true
text = "Show Grid"

[node name="HSeparator3" type="HSeparator" parent="MarginContainer/VBoxContainer"]
layout_mode = 2

[node name="HBoxContainer" type="HBoxContainer" parent="MarginContainer/VBoxContainer"]
layout_mode = 2

[node name="CustomName" type="LineEdit" parent="MarginContainer/VBoxContainer/HBoxContainer"]
unique_name_in_owner = true
layout_mode = 2
size_flags_horizontal = 3
placeholder_text = "Image Name"

[node name="SavePNG" type="Button" parent="MarginContainer/VBoxContainer"]
unique_name_in_owner = true
layout_mode = 2
disabled = true
text = "Save PNG"

[node name="Status" type="Label" parent="."]
layout_mode = 2
size_flags_horizontal = 3
size_flags_vertical = 3
text = "Drop folder or image files here to start.

Images should be of the same size. If their sizes don't match, the generator will try to use the dominating size.

The images will be automatically cropped based on the Alpha Threshold value. Greater value means more exact crop.

Supported formats: %s

If you drop a single image, the generator will instead edit it as spritesheet."
horizontal_alignment = 1
vertical_alignment = 1

[node name="View" type="CanvasLayer" parent="."]
layer = -1

[node name="CenterContainer" type="CenterContainer" parent="View"]
unique_name_in_owner = true
visible = false
anchors_preset = 15
anchor_right = 1.0
anchor_bottom = 1.0
grow_horizontal = 2
grow_vertical = 2
size_flags_horizontal = 3

[node name="Background" type="ColorRect" parent="View/CenterContainer"]
unique_name_in_owner = true
layout_mode = 2
mouse_filter = 1
color = Color(0, 0.501961, 0.501961, 1)

[node name="GridContainer" type="GridContainer" parent="View/CenterContainer"]
unique_name_in_owner = true
layout_mode = 2
theme_override_constants/h_separation = 0
theme_override_constants/v_separation = 0
columns = 3

[node name="VBoxContainer" type="GridContainer" parent="View"]
anchors_preset = 3
anchor_left = 1.0
anchor_top = 1.0
anchor_right = 1.0
anchor_bottom = 1.0
offset_left = -40.0
offset_top = -40.0
grow_horizontal = 0
grow_vertical = 0
mouse_filter = 2
columns = 3

[node name="Label" type="Label" parent="View/VBoxContainer"]
layout_mode = 2
text = "LMB"
horizontal_alignment = 1

[node name="VSeparator" type="VSeparator" parent="View/VBoxContainer"]
layout_mode = 2

[node name="Label2" type="Label" parent="View/VBoxContainer"]
layout_mode = 2
text = "rearrange images"

[node name="Label3" type="Label" parent="View/VBoxContainer"]
layout_mode = 2
text = "RMB"
horizontal_alignment = 1

[node name="VSeparator2" type="VSeparator" parent="View/VBoxContainer"]
layout_mode = 2

[node name="Label4" type="Label" parent="View/VBoxContainer"]
layout_mode = 2
text = "delete images"

[node name="Label5" type="Label" parent="View/VBoxContainer"]
layout_mode = 2
text = "MMB"
horizontal_alignment = 1

[node name="VSeparator3" type="VSeparator" parent="View/VBoxContainer"]
layout_mode = 2

[node name="Label6" type="Label" parent="View/VBoxContainer"]
layout_mode = 2
text = "pan view"

[node name="CanvasLayer" type="CanvasLayer" parent="."]

[node name="Error" type="Label" parent="CanvasLayer"]
unique_name_in_owner = true
modulate = Color(1, 0, 0, 1)
anchors_preset = 12
anchor_top = 1.0
anchor_right = 1.0
anchor_bottom = 1.0
offset_top = -14.0
grow_horizontal = 2
grow_vertical = 0
size_flags_vertical = 0
horizontal_alignment = 1

[node name="Timer" type="Timer" parent="CanvasLayer"]
unique_name_in_owner = true
wait_time = 5.0
one_shot = true

[node name="SplitDialog" type="ConfirmationDialog" parent="."]
title = "Edit Spritesheet"
position = Vector2i(-500, 0)
size = Vector2i(272, 343)
visible = true

[node name="VBoxContainer" type="VBoxContainer" parent="SplitDialog"]
offset_left = 8.0
offset_top = 8.0
offset_right = 264.0
offset_bottom = 294.0

[node name="Label" type="Label" parent="SplitDialog/VBoxContainer"]
layout_mode = 2
text = "Split Frames"
horizontal_alignment = 1

[node name="HBoxContainer" type="HBoxContainer" parent="SplitDialog/VBoxContainer"]
layout_mode = 2
alignment = 1

[node name="SplitX" type="SpinBox" parent="SplitDialog/VBoxContainer/HBoxContainer"]
unique_name_in_owner = true
layout_mode = 2
min_value = 1.0
max_value = 1000.0
value = 1.0
select_all_on_focus = true

[node name="Label" type="Label" parent="SplitDialog/VBoxContainer/HBoxContainer"]
layout_mode = 2
text = "x"

[node name="SplitY" type="SpinBox" parent="SplitDialog/VBoxContainer/HBoxContainer"]
unique_name_in_owner = true
layout_mode = 2
min_value = 1.0
max_value = 1000.0
value = 1.0
select_all_on_focus = true

[node name="CenterContainer" type="CenterContainer" parent="SplitDialog/VBoxContainer"]
layout_mode = 2
size_flags_vertical = 3

[node name="PanelContainer" type="PanelContainer" parent="SplitDialog/VBoxContainer/CenterContainer"]
layout_mode = 2
theme_override_styles/panel = SubResource("StyleBoxTexture_kjgn5")

[node name="SplitPreview" type="TextureRect" parent="SplitDialog/VBoxContainer/CenterContainer/PanelContainer"]
unique_name_in_owner = true
layout_mode = 2

[node name="StashDialog" type="AcceptDialog" parent="."]
title = "Image Stash"
position = Vector2i(-500, 500)
size = Vector2i(309, 100)
visible = true

[node name="VBoxContainer" type="VBoxContainer" parent="StashDialog"]
offset_left = 8.0
offset_top = 8.0
offset_right = 301.0
offset_bottom = 51.0

[node name="Label" type="Label" parent="StashDialog/VBoxContainer"]
layout_mode = 2
text = "Click frame to re-add it to spritesheet."
horizontal_alignment = 1

[node name="StashImages" type="HFlowContainer" parent="StashDialog/VBoxContainer"]
unique_name_in_owner = true
layout_mode = 2

[connection signal="pressed" from="MarginContainer/VBoxContainer/Reload" to="." method="load_images"]
[connection signal="value_changed" from="MarginContainer/VBoxContainer/Columns" to="." method="columns_changed"]
[connection signal="toggled" from="MarginContainer/VBoxContainer/Grid" to="." method="toggle_auto"]
[connection signal="value_changed" from="MarginContainer/VBoxContainer/MarginH" to="." method="hmargin_changed"]
[connection signal="value_changed" from="MarginContainer/VBoxContainer/MarginV" to="." method="vmargin_changed"]
[connection signal="pressed" from="MarginContainer/VBoxContainer/Stash" to="StashDialog" method="popup_centered_ratio" binds= [0.5]]
[connection signal="pressed" from="MarginContainer/VBoxContainer/Button" to="." method="recenter"]
[connection signal="toggled" from="MarginContainer/VBoxContainer/DisplayGrid" to="." method="toggle_grid"]
[connection signal="text_changed" from="MarginContainer/VBoxContainer/HBoxContainer/CustomName" to="." method="update_save_button" unbinds=1]
[connection signal="pressed" from="MarginContainer/VBoxContainer/SavePNG" to="." method="save_png"]
[connection signal="hidden" from="CanvasLayer/Error" to="." method="error_hidden"]
[connection signal="timeout" from="CanvasLayer/Timer" to="CanvasLayer/Error" method="hide"]
[connection signal="confirmed" from="SplitDialog" to="." method="split_spritesheet"]
[connection signal="value_changed" from="SplitDialog/VBoxContainer/HBoxContainer/SplitX" to="." method="update_split_preview" unbinds=1]
[connection signal="value_changed" from="SplitDialog/VBoxContainer/HBoxContainer/SplitY" to="." method="update_split_preview" unbinds=1]
[connection signal="draw" from="SplitDialog/VBoxContainer/CenterContainer/PanelContainer/SplitPreview" to="." method="draw_split_preview"]
