1
0
Fork 0
mirror of https://github.com/Orama-Interactive/Pixelorama.git synced 2025-02-08 03:19:49 +00:00
Pixelorama/src/Palette/Palette.gd
Emmanouil Papadeas bd7d3b19cc Add a crop_image boolean parameter to Palette.convert_to_image()
Fixes some issues with the Palettize effect where the output would be different if the palette size changed and empty swatches were added, even if the colors themselves stayed the same.
2024-11-22 17:56:39 +02:00

369 lines
11 KiB
GDScript

class_name Palette
extends RefCounted
signal data_changed
const DEFAULT_WIDTH := 8
const DEFAULT_HEIGHT := 8
# Metadata
var name := "Custom Palette":
set(value):
name = value.strip_edges()
var comment := ""
var path := ""
## The width of the grid
var width := DEFAULT_WIDTH
## The height of the grid
var height := DEFAULT_HEIGHT
## Sparse colors dictionary of [int] and [PaletteColor]
## Actual color position in the palette is determined by its index
var colors := {}
## How many colors fit in palette grid
var colors_max := 0
class PaletteColor:
var color := Color(0, 0, 0, 0)
var index := -1
func _init(init_color := Color.BLACK, init_index := -1) -> void:
color = init_color
index = init_index
func duplicate() -> PaletteColor:
return PaletteColor.new(color, index)
func serialize() -> Dictionary:
return {"color": color, "index": index}
func deserialize(dict: Dictionary) -> void:
if dict.has("color"):
color = dict["color"]
if dict.has("index"):
color = dict["index"]
func _init(
_name := "Custom Palette", _width := DEFAULT_WIDTH, _height := DEFAULT_HEIGHT, _comment := ""
) -> void:
name = _name
comment = _comment
width = _width
height = _height
colors_max = _width * _height
colors = {}
func edit(new_name: String, new_width: int, new_height: int, new_comment: String) -> void:
var old_width := width
var old_height := height
width = new_width
height = new_height
name = new_name
comment = new_comment
var old_colors_max := colors_max
colors_max = width * height
if colors_max < old_colors_max:
# If size was reduced colors must be reindexed to fit into new smaller size
reindex_colors_on_size_reduce(true)
if old_width < new_width and height >= old_height:
# If width increases colors have to be reindexed so they keep same grid positions
# unless the height has become smaller and we have to re-position the colors
# so that they won't get erased
reindex_colors_on_width_increase(old_width)
func duplicate() -> Palette:
var new_palette := Palette.new(name, width, height, comment)
var new_colors := colors.duplicate(true)
new_palette.colors = new_colors
return new_palette
func _serialize() -> String:
var serialize_data := {"comment": comment, "colors": [], "width": width, "height": height}
for color in colors:
serialize_data.colors.push_back(colors[color].serialize())
return JSON.stringify(serialize_data, " ")
func deserialize(json_string: String) -> void:
var test_json_conv := JSON.new()
var err := test_json_conv.parse(json_string)
if err != OK: # If parse has errors
printerr("JSON palette import error")
printerr("Error: ", err)
printerr("Error Line: ", test_json_conv.get_error_line())
printerr("Error String: ", test_json_conv.get_error_message())
return
var data = test_json_conv.get_data()
if not typeof(data) == TYPE_DICTIONARY:
return
deserialize_from_dictionary(data)
func deserialize_from_dictionary(data: Dictionary) -> void:
if data.has("comment"):
comment = data.comment
if data.has("colors"):
for color_data in data.colors:
var color: Color
if typeof(color_data["color"]) == TYPE_STRING:
color = str_to_var("Color" + color_data["color"])
elif typeof(color_data["color"]) == TYPE_COLOR:
color = color_data["color"]
var index := color_data["index"] as int
var palette_color := PaletteColor.new(color, index)
colors[index] = palette_color
if data.has("width"):
width = data.width
if data.has("height"):
height = data.height
colors_max = width * height
func save_to_file() -> Error:
var file := FileAccess.open(path, FileAccess.WRITE)
if not is_instance_valid(file):
return FileAccess.get_open_error()
file.store_string(_serialize())
file.close()
return OK
## Iterates all colors from lowest index and reindexes them so they start at zero index
## Remove trailing removes all colors that are over colors_max limit and thus don't fit into grid
func reindex_colors_on_size_reduce(remove_trailing: bool) -> void:
var sorted_colors_indexes := colors.keys()
sorted_colors_indexes.sort()
var new_index := 0
for old_index: int in sorted_colors_indexes:
# Color cannot fit into grid anymore - erase it
if remove_trailing and new_index >= colors_max:
colors.erase(old_index)
# Move color to new lower index - erase it from its original index
elif new_index < old_index:
colors[new_index] = colors[old_index]
colors[new_index].index = new_index
colors.erase(old_index)
new_index += 1
## Adds difference of old and new width to color indexes
## so they remain on the same position as before resize
func reindex_colors_on_width_increase(old_width: int) -> void:
var sorted_colors_indices := colors.keys()
sorted_colors_indices.sort()
var new_colors := {}
for old_index: int in sorted_colors_indices:
var new_index := old_index + (width - old_width) * (old_index / old_width)
new_colors[new_index] = colors[old_index]
new_colors[new_index].index = new_index
colors = new_colors
## Adds new color to the first empty swatch
func add_color(new_color: Color, start_index := 0) -> void:
if start_index >= colors_max:
return
# If palette is full automatically increase the palette height
if is_full():
height += 1
# Find the first empty index since start index and insert a new color
for i in range(start_index, colors_max):
if not colors.has(i):
colors[i] = PaletteColor.new(new_color, i)
break
data_changed.emit()
## Returns color at index or null if no color exists
func get_color(index: int):
var palette_color: PaletteColor = colors.get(index)
if palette_color != null:
return palette_color.color
return null
## Changes color data
func set_color(index: int, new_color: Color) -> void:
if colors.has(index):
colors[index].color = new_color
data_changed.emit()
## Removes a color at the specified index
func remove_color(index: int) -> void:
colors.erase(index)
data_changed.emit()
## Inserts a color to the specified index
## If index is already occupied move the original color to right
func insert_color(index: int, new_color: Color) -> void:
var c := PaletteColor.new(new_color, index)
# If insert happens on non empty swatch recursively move the original color
# and every other color to its right one swatch to right
if colors.has(index):
_move_right(index)
colors[index] = c
data_changed.emit()
## Recursive function that moves every color to right until one of them is moved to empty swatch
func _move_right(index: int) -> void:
# Moving colors to right would overflow the size of the palette
# so increase its height automatically
if index + 1 == colors_max:
height += 1
colors_max = width * height
# If swatch to right to this color is not empty move that color right too
if colors.has(index + 1):
_move_right(index + 1)
colors[index + 1] = colors[index]
## Swaps two colors
func swap_colors(from_index: int, to_index: int) -> void:
var from_color: PaletteColor = colors.get(from_index)
var to_color: PaletteColor = colors.get(to_index)
if not from_color and to_color:
colors[from_index] = to_color
colors[from_index].index = from_index
colors.erase(to_index)
elif from_color and not to_color:
colors[to_index] = from_color
colors[to_index].index = to_index
colors.erase(from_index)
elif from_color and to_color:
colors[to_index] = from_color
colors[to_index].index = to_index
colors[from_index] = to_color
colors[from_index].index = from_index
data_changed.emit()
## Copies color
func copy_colors(from_index: int, to_index: int) -> void:
# Only allow copy of existing colors
if colors[from_index] != null:
colors[to_index] = colors[from_index].duplicate()
colors[to_index].index = to_index
data_changed.emit()
func reverse_colors() -> void:
var reversed_colors := colors.values()
reversed_colors.reverse()
colors.clear()
for i in reversed_colors.size():
reversed_colors[i].index = i
colors[i] = reversed_colors[i]
data_changed.emit()
func sort(option: Palettes.SortOptions) -> void:
var sorted_colors := colors.values()
var sort_method: Callable
match option:
Palettes.SortOptions.HUE:
sort_method = func(a: PaletteColor, b: PaletteColor): return a.color.h < b.color.h
Palettes.SortOptions.SATURATION:
sort_method = func(a: PaletteColor, b: PaletteColor): return a.color.s < b.color.s
Palettes.SortOptions.VALUE:
sort_method = func(a: PaletteColor, b: PaletteColor): return a.color.v < b.color.v
Palettes.SortOptions.LIGHTNESS:
# Code inspired from:
# gdlint: ignore=max-line-length
# https://github.com/bottosson/bottosson.github.io/blob/master/misc/colorpicker/colorconversion.js#L519
sort_method = func(a: PaletteColor, b: PaletteColor):
# function that returns OKHSL lightness
var lum: Callable = func(c: Color):
var l = 0.4122214708 * (c.r) + 0.5363325363 * (c.g) + 0.0514459929 * (c.b)
var m = 0.2119034982 * (c.r) + 0.6806995451 * (c.g) + 0.1073969566 * (c.b)
var s = 0.0883024619 * (c.r) + 0.2817188376 * (c.g) + 0.6299787005 * (c.b)
var l_cr = pow(l, 1 / 3.0)
var m_cr = pow(m, 1 / 3.0)
var s_cr = pow(s, 1 / 3.0)
var oklab_l = 0.2104542553 * l_cr + 0.7936177850 * m_cr - 0.0040720468 * s_cr
# calculating toe
var k_1 = 0.206
var k_2 = 0.03
var k_3 = (1 + k_1) / (1 + k_2)
return (
0.5
* (
k_3 * oklab_l
- k_1
+ sqrt(
(
(k_3 * oklab_l - k_1) * (k_3 * oklab_l - k_1)
+ 4 * k_2 * k_3 * oklab_l
)
)
)
)
return lum.call(a.color.srgb_to_linear()) < lum.call(b.color.srgb_to_linear())
Palettes.SortOptions.RED:
sort_method = func(a: PaletteColor, b: PaletteColor): return a.color.r < b.color.r
Palettes.SortOptions.GREEN:
sort_method = func(a: PaletteColor, b: PaletteColor): return a.color.g < b.color.g
Palettes.SortOptions.BLUE:
sort_method = func(a: PaletteColor, b: PaletteColor): return a.color.b < b.color.b
Palettes.SortOptions.ALPHA:
sort_method = func(a: PaletteColor, b: PaletteColor): return a.color.a < b.color.a
sorted_colors.sort_custom(sort_method)
colors.clear()
for i in sorted_colors.size():
sorted_colors[i].index = i
colors[i] = sorted_colors[i]
data_changed.emit()
## True if all swatches are occupied
func is_full() -> bool:
return colors.size() >= colors_max
## True if palette has no colors
func is_empty() -> bool:
return colors.size() == 0
func has_theme_color(color: Color) -> bool:
for palette_color in colors.values():
if palette_color.color == color:
return true
return false
static func strip_unvalid_characters(string_to_strip: String) -> String:
var regex := RegEx.new()
regex.compile("[^a-zA-Z0-9_]+")
return regex.sub(string_to_strip, "", true)
func convert_to_image(crop_image := true) -> Image:
var image := Image.create(colors_max, 1, false, Image.FORMAT_RGBA8)
for i in colors_max:
if colors.has(i):
image.set_pixel(i, 0, Color(colors[i].color.to_html()))
if crop_image:
image.copy_from(image.get_region(image.get_used_rect()))
return image