aboutsummaryrefslogtreecommitdiff
path: root/game/addons/zylann.hterrain/util
diff options
context:
space:
mode:
author Gone2Daly <71726742+Gone2Daly@users.noreply.github.com>2023-07-22 21:05:42 +0200
committer Gone2Daly <71726742+Gone2Daly@users.noreply.github.com>2023-07-22 21:05:42 +0200
commit71b3cd829f80de4c2cd3972d8bfd5ee470a5d180 (patch)
treeb4280fde6eef2ae6987648bc7bf8e00e9011bb7f /game/addons/zylann.hterrain/util
parentce9022d0df74d6c33db3686622be2050d873ab0b (diff)
init_testtest3d
Diffstat (limited to 'game/addons/zylann.hterrain/util')
-rw-r--r--game/addons/zylann.hterrain/util/direct_mesh_instance.gd65
-rw-r--r--game/addons/zylann.hterrain/util/direct_multimesh_instance.gd48
-rw-r--r--game/addons/zylann.hterrain/util/errors.gd58
-rw-r--r--game/addons/zylann.hterrain/util/grid.gd203
-rw-r--r--game/addons/zylann.hterrain/util/image_file_cache.gd291
-rw-r--r--game/addons/zylann.hterrain/util/logger.gd34
-rw-r--r--game/addons/zylann.hterrain/util/util.gd549
-rw-r--r--game/addons/zylann.hterrain/util/xyz_format.gd109
8 files changed, 1357 insertions, 0 deletions
diff --git a/game/addons/zylann.hterrain/util/direct_mesh_instance.gd b/game/addons/zylann.hterrain/util/direct_mesh_instance.gd
new file mode 100644
index 0000000..17c89c9
--- /dev/null
+++ b/game/addons/zylann.hterrain/util/direct_mesh_instance.gd
@@ -0,0 +1,65 @@
+@tool
+
+# Implementation of MeshInstance which doesn't use the scene tree
+
+var _mesh_instance := RID()
+# Need to keep a reference so that the mesh RID doesn't get freed
+var _mesh : Mesh
+
+
+func _init():
+ var rs = RenderingServer
+ _mesh_instance = rs.instance_create()
+ rs.instance_set_visible(_mesh_instance, true)
+
+
+func _notification(p_what: int):
+ if p_what == NOTIFICATION_PREDELETE:
+ if _mesh_instance != RID():
+ RenderingServer.free_rid(_mesh_instance)
+ _mesh_instance = RID()
+
+
+func enter_world(world: World3D):
+ assert(_mesh_instance != RID())
+ RenderingServer.instance_set_scenario(_mesh_instance, world.get_scenario())
+
+
+func exit_world():
+ assert(_mesh_instance != RID())
+ RenderingServer.instance_set_scenario(_mesh_instance, RID())
+
+
+func set_world(world: World3D):
+ if world != null:
+ enter_world(world)
+ else:
+ exit_world()
+
+
+func set_transform(world_transform: Transform3D):
+ assert(_mesh_instance != RID())
+ RenderingServer.instance_set_transform(_mesh_instance, world_transform)
+
+
+func set_mesh(mesh: Mesh):
+ assert(_mesh_instance != RID())
+ RenderingServer.instance_set_base(_mesh_instance, mesh.get_rid() if mesh != null else RID())
+ _mesh = mesh
+
+
+func set_material(material: Material):
+ assert(_mesh_instance != RID())
+ RenderingServer.instance_geometry_set_material_override( \
+ _mesh_instance, material.get_rid() if material != null else RID())
+
+
+func set_visible(visible: bool):
+ assert(_mesh_instance != RID())
+ RenderingServer.instance_set_visible(_mesh_instance, visible)
+
+
+func set_aabb(aabb: AABB):
+ assert(_mesh_instance != RID())
+ RenderingServer.instance_set_custom_aabb(_mesh_instance, aabb)
+
diff --git a/game/addons/zylann.hterrain/util/direct_multimesh_instance.gd b/game/addons/zylann.hterrain/util/direct_multimesh_instance.gd
new file mode 100644
index 0000000..dbb899b
--- /dev/null
+++ b/game/addons/zylann.hterrain/util/direct_multimesh_instance.gd
@@ -0,0 +1,48 @@
+@tool
+
+# Implementation of MultiMeshInstance which doesn't use the scene tree
+
+var _multimesh_instance := RID()
+
+
+func _init():
+ _multimesh_instance = RenderingServer.instance_create()
+
+
+func _notification(what: int):
+ if what == NOTIFICATION_PREDELETE:
+ RenderingServer.free_rid(_multimesh_instance)
+
+
+func set_world(world: World3D):
+ RenderingServer.instance_set_scenario(
+ _multimesh_instance, world.get_scenario() if world != null else RID())
+
+
+func set_visible(visible: bool):
+ RenderingServer.instance_set_visible(_multimesh_instance, visible)
+
+
+func set_transform(trans: Transform3D):
+ RenderingServer.instance_set_transform(_multimesh_instance, trans)
+
+
+func set_multimesh(mm: MultiMesh):
+ RenderingServer.instance_set_base(_multimesh_instance, mm.get_rid() if mm != null else RID())
+
+
+func set_material_override(material: Material):
+ RenderingServer.instance_geometry_set_material_override( \
+ _multimesh_instance, material.get_rid() if material != null else RID())
+
+
+func set_aabb(aabb: AABB):
+ RenderingServer.instance_set_custom_aabb(_multimesh_instance, aabb)
+
+
+func set_layer_mask(mask: int):
+ RenderingServer.instance_set_layer_mask(_multimesh_instance, mask)
+
+
+func set_cast_shadow(cast_shadow: int):
+ RenderingServer.instance_geometry_set_cast_shadows_setting(_multimesh_instance, cast_shadow)
diff --git a/game/addons/zylann.hterrain/util/errors.gd b/game/addons/zylann.hterrain/util/errors.gd
new file mode 100644
index 0000000..c6a2c63
--- /dev/null
+++ b/game/addons/zylann.hterrain/util/errors.gd
@@ -0,0 +1,58 @@
+@tool
+
+# Taken from https://docs.godotengine.org/en/3.0/classes/class_@globalscope.html#enum-globalscope-error
+const _names = {
+ OK: "ok",
+ FAILED: "Generic error.",
+ ERR_UNAVAILABLE: "Unavailable error",
+ ERR_UNCONFIGURED: "Unconfigured error",
+ ERR_UNAUTHORIZED: "Unauthorized error",
+ ERR_PARAMETER_RANGE_ERROR: "Parameter range error",
+ ERR_OUT_OF_MEMORY: "Out of memory (OOM) error",
+ ERR_FILE_NOT_FOUND: "File Not found error",
+ ERR_FILE_BAD_DRIVE: "File Bad drive error",
+ ERR_FILE_BAD_PATH: "File Bad path error",
+ ERR_FILE_NO_PERMISSION: "File No permission error",
+ ERR_FILE_ALREADY_IN_USE: "File Already in use error",
+ ERR_FILE_CANT_OPEN: "File Can't open error",
+ ERR_FILE_CANT_WRITE: "File Can't write error",
+ ERR_FILE_CANT_READ: "File Can't read error",
+ ERR_FILE_UNRECOGNIZED: "File Unrecognized error",
+ ERR_FILE_CORRUPT: "File Corrupt error",
+ ERR_FILE_MISSING_DEPENDENCIES: "File Missing dependencies error",
+ ERR_FILE_EOF: "File End of file (EOF) error",
+ ERR_CANT_OPEN: "Can't open error",
+ ERR_CANT_CREATE: "Can't create error",
+ ERR_QUERY_FAILED: "Query failed error",
+ ERR_ALREADY_IN_USE: "Already in use error",
+ ERR_LOCKED: "Locked error",
+ ERR_TIMEOUT: "Timeout error",
+ ERR_CANT_CONNECT: "Can't connect",
+ ERR_CANT_RESOLVE: "Can't resolve",
+ ERR_CONNECTION_ERROR: "Connection error",
+ ERR_CANT_ACQUIRE_RESOURCE: "Can't acquire resource error",
+ ERR_CANT_FORK: "Can't fork",
+ ERR_INVALID_DATA: "Invalid data error",
+ ERR_INVALID_PARAMETER: "Invalid parameter error",
+ ERR_ALREADY_EXISTS: "Already exists error",
+ ERR_DOES_NOT_EXIST: "Does not exist error",
+ ERR_DATABASE_CANT_READ: "Database Read error",
+ ERR_DATABASE_CANT_WRITE: "Database Write error",
+ ERR_COMPILATION_FAILED: "Compilation failed error",
+ ERR_METHOD_NOT_FOUND: "Method not found error",
+ ERR_LINK_FAILED: "Linking failed error",
+ ERR_SCRIPT_FAILED: "Script failed error",
+ ERR_CYCLIC_LINK: "Cycling link (import cycle) error",
+ ERR_INVALID_DECLARATION: "Invalid declaration",
+ ERR_DUPLICATE_SYMBOL: "Duplicate symbol",
+ ERR_PARSE_ERROR: "Parse error",
+ ERR_BUSY: "Busy error",
+ ERR_SKIP: "Skip error",
+ ERR_HELP: "Help error",
+ ERR_BUG: "Bug error",
+ ERR_PRINTER_ON_FIRE: "The printer is on fire"
+}
+
+static func get_message(err_code: int):
+ return str("[", err_code, "]: ", _names[err_code])
+
diff --git a/game/addons/zylann.hterrain/util/grid.gd b/game/addons/zylann.hterrain/util/grid.gd
new file mode 100644
index 0000000..b28e83b
--- /dev/null
+++ b/game/addons/zylann.hterrain/util/grid.gd
@@ -0,0 +1,203 @@
+
+# Note: `tool` is optional but without it there are no error reporting in the editor
+@tool
+
+# TODO Remove grid_ prefixes, context is already given by the script itself
+
+
+# Performs a positive integer division rounded to upper (4/2 = 2, 5/3 = 2)
+static func up_div(a: int, b: int):
+ if a % b != 0:
+ return a / b + 1
+ return a / b
+
+
+# Creates a 2D array as an array of arrays.
+# if v is provided, all cells will contain the same value.
+# if v is a funcref, it will be executed to fill the grid cell per cell.
+static func create_grid(w: int, h: int, v=null):
+ var is_create_func = typeof(v) == TYPE_CALLABLE
+ var grid := []
+ grid.resize(h)
+ for y in range(grid.size()):
+ var row := []
+ row.resize(w)
+ if is_create_func:
+ for x in range(row.size()):
+ row[x] = v.call(x, y)
+ else:
+ for x in range(row.size()):
+ row[x] = v
+ grid[y] = row
+ return grid
+
+
+# Creates a 2D array that is a copy of another 2D array
+static func clone_grid(other_grid):
+ var grid := []
+ grid.resize(other_grid.size())
+ for y in range(0, grid.size()):
+ var row := []
+ var other_row = other_grid[y]
+ row.resize(other_row.size())
+ grid[y] = row
+ for x in range(0, row.size()):
+ row[x] = other_row[x]
+ return grid
+
+
+# Resizes a 2D array and allows to set or call functions for each deleted and created cells.
+# This is especially useful if cells contain objects and you don't want to loose existing data.
+static func resize_grid(grid, new_width, new_height, create_func=null, delete_func=null):
+ # Check parameters
+ assert(new_width >= 0 and new_height >= 0)
+ assert(grid != null)
+ if delete_func != null:
+ assert(typeof(delete_func) == TYPE_CALLABLE)
+ # `create_func` can also be a default value
+ var is_create_func = typeof(create_func) == TYPE_CALLABLE
+
+ # Get old size (supposed to be rectangular!)
+ var old_height = grid.size()
+ var old_width = 0
+ if grid.size() != 0:
+ old_width = grid[0].size()
+
+ # Delete old rows
+ if new_height < old_height:
+ if delete_func != null:
+ for y in range(new_height, grid.size()):
+ var row = grid[y]
+ for x in len(row):
+ var elem = row[x]
+ delete_func.call(elem)
+ grid.resize(new_height)
+
+ # Delete old columns
+ if new_width < old_width:
+ for y in len(grid):
+ var row = grid[y]
+ if delete_func != null:
+ for x in range(new_width, row.size()):
+ var elem = row[x]
+ delete_func.call(elem)
+ row.resize(new_width)
+
+ # Create new columns
+ if new_width > old_width:
+ for y in len(grid):
+ var row = grid[y]
+ row.resize(new_width)
+ if is_create_func:
+ for x in range(old_width, new_width):
+ row[x] = create_func.call(x,y)
+ else:
+ for x in range(old_width, new_width):
+ row[x] = create_func
+
+ # Create new rows
+ if new_height > old_height:
+ grid.resize(new_height)
+ for y in range(old_height, new_height):
+ var row = []
+ row.resize(new_width)
+ grid[y] = row
+ if is_create_func:
+ for x in new_width:
+ row[x] = create_func.call(x,y)
+ else:
+ for x in new_width:
+ row[x] = create_func
+
+ # Debug test check
+ assert(grid.size() == new_height)
+ for y in len(grid):
+ assert(len(grid[y]) == new_width)
+
+
+# Retrieves the minimum and maximum values from a grid
+static func grid_min_max(grid):
+ if grid.size() == 0 or grid[0].size() == 0:
+ return [0,0]
+ var vmin = grid[0][0]
+ var vmax = vmin
+ for y in len(grid):
+ var row = grid[y]
+ for x in len(row):
+ var v = row[x]
+ if v > vmax:
+ vmax = v
+ elif v < vmin:
+ vmin = v
+ return [vmin, vmax]
+
+
+# Copies a sub-region of a grid as a new grid. No boundary check!
+static func grid_extract_area(src_grid, x0, y0, w, h):
+ var dst = create_grid(w, h)
+ for y in h:
+ var dst_row = dst[y]
+ var src_row = src_grid[y0+y]
+ for x in w:
+ dst_row[x] = src_row[x0+x]
+ return dst
+
+
+# Extracts data and crops the result if the requested rect crosses the bounds
+static func grid_extract_area_safe_crop(src_grid, x0, y0, w, h):
+ # Return empty is completely out of bounds
+ var gw = src_grid.size()
+ if gw == 0:
+ return []
+ var gh = src_grid[0].size()
+ if x0 >= gw or y0 >= gh:
+ return []
+
+ # Crop min pos
+ if x0 < 0:
+ w += x0
+ x0 = 0
+ if y0 < 0:
+ h += y0
+ y0 = 0
+
+ # Crop max pos
+ if x0 + w >= gw:
+ w = gw-x0
+ if y0 + h >= gh:
+ h = gh-y0
+
+ return grid_extract_area(src_grid, x0, y0, w, h)
+
+
+# Sets values from a grid inside another grid. No boundary check!
+static func grid_paste(src_grid, dst_grid, x0, y0):
+ for y in range(0, src_grid.size()):
+ var src_row = src_grid[y]
+ var dst_row = dst_grid[y0+y]
+ for x in range(0, src_row.size()):
+ dst_row[x0+x] = src_row[x]
+
+
+# Tests if two grids are the same size and contain the same values
+static func grid_equals(a, b):
+ if a.size() != b.size():
+ return false
+ for y in a.size():
+ var a_row = a[y]
+ var b_row = b[y]
+ if a_row.size() != b_row.size():
+ return false
+ for x in b_row.size():
+ if a_row[x] != b_row[x]:
+ return false
+ return true
+
+
+static func grid_get_or_default(grid, x, y, defval=null):
+ if y >= 0 and y < len(grid):
+ var row = grid[y]
+ if x >= 0 and x < len(row):
+ return row[x]
+ return defval
+
diff --git a/game/addons/zylann.hterrain/util/image_file_cache.gd b/game/addons/zylann.hterrain/util/image_file_cache.gd
new file mode 100644
index 0000000..e39e4cd
--- /dev/null
+++ b/game/addons/zylann.hterrain/util/image_file_cache.gd
@@ -0,0 +1,291 @@
+@tool
+
+# Used to store temporary images on disk.
+# This is useful for undo/redo as image edition can quickly fill up memory.
+
+# Image data is stored in archive files together,
+# because when dealing with many images it speeds up filesystem I/O on Windows.
+# If the file exceeds a predefined size, a new one is created.
+# Writing to disk is performed from a thread, to leave the main thread responsive.
+# However if you want to obtain an image back while it didn't save yet, the main thread will block.
+# When the application or plugin is closed, the files get cleared.
+
+const HT_Logger = preload("./logger.gd")
+const HT_Errors = preload("./errors.gd")
+
+const CACHE_FILE_SIZE_THRESHOLD = 1048576
+# For debugging
+const USE_THREAD = true
+
+var _cache_dir := ""
+var _next_id := 0
+var _session_id := ""
+var _cache_image_info := {}
+var _logger = HT_Logger.get_for(self)
+var _current_cache_file_index := 0
+var _cache_file_offset := 0
+
+var _saving_thread := Thread.new()
+var _save_queue := []
+var _save_queue_mutex := Mutex.new()
+var _save_semaphore := Semaphore.new()
+var _save_thread_running := false
+
+
+func _init(cache_dir: String):
+ assert(cache_dir != "")
+ _cache_dir = cache_dir
+ var rng := RandomNumberGenerator.new()
+ rng.randomize()
+ for i in 16:
+ _session_id += str(rng.randi() % 10)
+ _logger.debug(str("Image cache session ID: ", _session_id))
+ if not DirAccess.dir_exists_absolute(_cache_dir):
+ var err := DirAccess.make_dir_absolute(_cache_dir)
+ if err != OK:
+ _logger.error("Could not create directory {0}: {1}" \
+ .format([_cache_dir, HT_Errors.get_message(err)]))
+ _save_thread_running = true
+ if USE_THREAD:
+ _saving_thread.start(_save_thread_func)
+
+
+# TODO Cannot cleanup the cache in destructor!
+# Godot doesn't allow me to call clear()...
+# https://github.com/godotengine/godot/issues/31166
+func _notification(what: int):
+ if what == NOTIFICATION_PREDELETE:
+ #clear()
+ _save_thread_running = false
+ _save_semaphore.post()
+ if USE_THREAD:
+ _saving_thread.wait_to_finish()
+
+
+func _create_new_cache_file(fpath: String):
+ var f := FileAccess.open(fpath, FileAccess.WRITE)
+ if f == null:
+ var err = FileAccess.get_open_error()
+ _logger.error("Failed to create new cache file {0}: {1}" \
+ .format([fpath, HT_Errors.get_message(err)]))
+ return
+
+
+func _get_current_cache_file_name() -> String:
+ return _cache_dir.path_join(str(_session_id, "_", _current_cache_file_index, ".cache"))
+
+
+func save_image(im: Image) -> int:
+ assert(im != null)
+ if im.has_mipmaps():
+ # TODO Add support for this? Didn't need it so far
+ _logger.error("Caching an image with mipmaps, this isn't supported")
+
+ var fpath := _get_current_cache_file_name()
+ if _next_id == 0:
+ # First file
+ _create_new_cache_file(fpath)
+
+ var id := _next_id
+ _next_id += 1
+
+ var item := {
+ # Duplicate the image so we are sure nothing funny will happen to it
+ # while the thread saves it
+ "image": im.duplicate(),
+ "path": fpath,
+ "data_offset": _cache_file_offset,
+ "saved": false
+ }
+
+ _cache_file_offset += _get_image_data_size(im)
+ if _cache_file_offset >= CACHE_FILE_SIZE_THRESHOLD:
+ _cache_file_offset = 0
+ _current_cache_file_index += 1
+ _create_new_cache_file(_get_current_cache_file_name())
+
+ _cache_image_info[id] = item
+
+ _save_queue_mutex.lock()
+ _save_queue.append(item)
+ _save_queue_mutex.unlock()
+
+ _save_semaphore.post()
+
+ if not USE_THREAD:
+ var before = Time.get_ticks_msec()
+ while len(_save_queue) > 0:
+ _save_thread_func()
+ if Time.get_ticks_msec() - before > 10_000:
+ _logger.error("Taking to long to empty save queue in non-threaded mode!")
+
+ return id
+
+
+static func _get_image_data_size(im: Image) -> int:
+ return 1 + 4 + 4 + 4 + len(im.get_data())
+
+
+static func _write_image(f: FileAccess, im: Image):
+ f.store_8(im.get_format())
+ f.store_32(im.get_width())
+ f.store_32(im.get_height())
+ var data : PackedByteArray = im.get_data()
+ f.store_32(len(data))
+ f.store_buffer(data)
+
+
+static func _read_image(f: FileAccess) -> Image:
+ var format := f.get_8()
+ var width := f.get_32()
+ var height := f.get_32()
+ var data_size := f.get_32()
+ var data := f.get_buffer(data_size)
+ var im := Image.create_from_data(width, height, false, format, data)
+ return im
+
+
+func load_image(id: int) -> Image:
+ var info := _cache_image_info[id] as Dictionary
+
+ var timeout := 5.0
+ var time_before := Time.get_ticks_msec()
+ # We could just grab `image`, because the thread only reads it.
+ # However it's still not safe to do that if we write or even lock it,
+ # so we have to assume it still has ownership of it.
+ while not info.saved:
+ OS.delay_msec(8.0)
+ _logger.debug("Waiting for cached image {0}...".format([id]))
+ if Time.get_ticks_msec() - time_before > timeout:
+ _logger.error("Could not get image {0} from cache. Something went wrong.".format([id]))
+ return null
+
+ var fpath := info.path as String
+
+ var f := FileAccess.open(fpath, FileAccess.READ)
+ if f == null:
+ var err := FileAccess.get_open_error()
+ _logger.error("Could not load cached image from {0}: {1}" \
+ .format([fpath, HT_Errors.get_message(err)]))
+ return null
+
+ f.seek(info.data_offset)
+ var im = _read_image(f)
+ f = null # close file
+
+ assert(im != null)
+ return im
+
+
+func clear():
+ _logger.debug("Clearing image cache")
+
+ var dir := DirAccess.open(_cache_dir)
+ if dir == null:
+ #var err = DirAccess.get_open_error()
+ _logger.error("Could not open image file cache directory '{0}'" \
+ .format([_cache_dir]))
+ return
+
+ dir.include_hidden = false
+ dir.include_navigational = false
+
+ var err := dir.list_dir_begin()
+ if err != OK:
+ _logger.error("Could not start list_dir_begin in '{0}'".format([_cache_dir]))
+ return
+
+ # Delete all cache files
+ while true:
+ var fpath := dir.get_next()
+ if fpath == "":
+ break
+ if fpath.ends_with(".cache"):
+ _logger.debug(str("Deleting ", fpath))
+ err = dir.remove(fpath)
+ if err != OK:
+ _logger.error("Failed to delete cache file '{0}': {1}" \
+ .format([_cache_dir.path_join(fpath), HT_Errors.get_message(err)]))
+
+ _cache_image_info.clear()
+
+
+func _save_thread_func():
+ # Threads keep a reference to the object of the function they run.
+ # So if the object is a Reference, and that reference owns the thread... we get a cycle.
+ # We can break the cycle by removing 1 to the count inside the thread.
+ # The thread's reference will never die unexpectedly because we stop and destroy the thread
+ # in the destructor of the reference.
+ # If that workaround explodes one day, another way could be to use an intermediary instance
+ # extending Object, and run a function on that instead.
+ #
+ # I added this in Godot 3, and it seems to still be relevant in Godot 4 because if I don't
+ # do it, objects are leaking.
+ #
+ # BUT it seems to end up triggering a crash in debug Godot builds due to unrefing RefCounted
+ # with refcount == 0, so I guess it's wrong now?
+ # So basically, either I do it and I risk a crash,
+ # or I don't do it and then it causes a leak...
+ # TODO Make this shit use `Object`
+ #
+ # if USE_THREAD:
+ # unreference()
+
+ while _save_thread_running:
+ _save_queue_mutex.lock()
+ var to_save := _save_queue.duplicate(false)
+ _save_queue.clear()
+ _save_queue_mutex.unlock()
+
+ if len(to_save) == 0:
+ if USE_THREAD:
+ _save_semaphore.wait()
+ continue
+
+ var f : FileAccess
+ var path := ""
+
+ for item in to_save:
+ # Keep re-using the same file if we did not change path.
+ # It makes I/Os faster.
+ if item.path != path:
+ # Close previous file
+ f = null
+
+ path = item.path
+
+ f = FileAccess.open(path, FileAccess.READ_WRITE)
+ if f == null:
+ var err := FileAccess.get_open_error()
+ call_deferred("_on_error", "Could not open file {0}: {1}" \
+ .format([path, HT_Errors.get_message(err)]))
+ path = ""
+ continue
+
+ f.seek(item.data_offset)
+ _write_image(f, item.image)
+ # Notify main thread.
+ # The thread does not modify data, only reads it.
+ call_deferred("_on_image_saved", item)
+
+ # Workaround some weird behavior in Godot 4:
+ # when the next loop runs, `f` IS NOT CLEANED UP. A reference is still held before `var f`
+ # is reached, which means the file is still locked while the thread is waiting on the
+ # semaphore... so I have to explicitely "close" the file here.
+ f = null
+
+ if not USE_THREAD:
+ break
+
+
+func _on_error(msg: String):
+ _logger.error(msg)
+
+
+func _on_image_saved(item: Dictionary):
+ _logger.debug(str("Saved ", item.path))
+ item.saved = true
+ # Should remove image from memory (for usually being last reference)
+ item.image = null
+
+
diff --git a/game/addons/zylann.hterrain/util/logger.gd b/game/addons/zylann.hterrain/util/logger.gd
new file mode 100644
index 0000000..fcc78a3
--- /dev/null
+++ b/game/addons/zylann.hterrain/util/logger.gd
@@ -0,0 +1,34 @@
+@tool
+
+class HT_LoggerBase:
+ var _context := ""
+
+ func _init(p_context):
+ _context = p_context
+
+ func debug(msg: String):
+ pass
+
+ func warn(msg: String):
+ push_warning("{0}: {1}".format([_context, msg]))
+
+ func error(msg: String):
+ push_error("{0}: {1}".format([_context, msg]))
+
+
+class HT_LoggerVerbose extends HT_LoggerBase:
+ func _init(p_context: String):
+ super(p_context)
+
+ func debug(msg: String):
+ print(_context, ": ", msg)
+
+
+static func get_for(owner: Object) -> HT_LoggerBase:
+ # Note: don't store the owner. If it's a Reference, it could create a cycle
+ var script : Script = owner.get_script()
+ var context := script.resource_path.get_file()
+ if OS.is_stdout_verbose():
+ return HT_LoggerVerbose.new(context)
+ return HT_LoggerBase.new(context)
+
diff --git a/game/addons/zylann.hterrain/util/util.gd b/game/addons/zylann.hterrain/util/util.gd
new file mode 100644
index 0000000..e2cd32c
--- /dev/null
+++ b/game/addons/zylann.hterrain/util/util.gd
@@ -0,0 +1,549 @@
+@tool
+
+const HT_Errors = preload("./errors.gd")
+
+
+# Godot has this internally but doesn't expose it
+static func next_power_of_two(x: int) -> int:
+ x -= 1
+ x |= x >> 1
+ x |= x >> 2
+ x |= x >> 4
+ x |= x >> 8
+ x |= x >> 16
+ x += 1
+ return x
+
+
+# CubeMesh doesn't have a wireframe option
+static func create_wirecube_mesh(color = Color(1,1,1)) -> Mesh:
+ var positions := PackedVector3Array([
+ Vector3(0, 0, 0),
+ Vector3(1, 0, 0),
+ Vector3(1, 0, 1),
+ Vector3(0, 0, 1),
+ Vector3(0, 1, 0),
+ Vector3(1, 1, 0),
+ Vector3(1, 1, 1),
+ Vector3(0, 1, 1),
+ ])
+ var colors := PackedColorArray([
+ color, color, color, color,
+ color, color, color, color,
+ ])
+ var indices := PackedInt32Array([
+ 0, 1,
+ 1, 2,
+ 2, 3,
+ 3, 0,
+
+ 4, 5,
+ 5, 6,
+ 6, 7,
+ 7, 4,
+
+ 0, 4,
+ 1, 5,
+ 2, 6,
+ 3, 7
+ ])
+ var arrays := []
+ arrays.resize(Mesh.ARRAY_MAX)
+ arrays[Mesh.ARRAY_VERTEX] = positions
+ arrays[Mesh.ARRAY_COLOR] = colors
+ arrays[Mesh.ARRAY_INDEX] = indices
+ var mesh := ArrayMesh.new()
+ mesh.add_surface_from_arrays(Mesh.PRIMITIVE_LINES, arrays)
+ return mesh
+
+
+static func integer_square_root(x: int) -> int:
+ assert(typeof(x) == TYPE_INT)
+ var r := int(roundf(sqrt(x)))
+ if r * r == x:
+ return r
+ # Does not exist
+ return -1
+
+
+# Formats integer using a separator between each 3-digit group
+static func format_integer(n: int, sep := ",") -> String:
+ assert(typeof(n) == TYPE_INT)
+
+ var negative := false
+ if n < 0:
+ negative = true
+ n = -n
+
+ var s = ""
+ while n >= 1000:
+ s = str(sep, str(n % 1000).pad_zeros(3), s)
+ n /= 1000
+
+ if negative:
+ return str("-", str(n), s)
+ else:
+ return str(str(n), s)
+
+
+# Goes up all parents until a node of the given class is found
+static func get_node_in_parents(node: Node, klass) -> Node:
+ while node != null:
+ node = node.get_parent()
+ if node != null and is_instance_of(node, klass):
+ return node
+ return null
+
+
+# Goes down all children until a node of the given class is found
+static func find_first_node(node: Node, klass) -> Node:
+ if is_instance_of(node, klass):
+ return node
+ for i in node.get_child_count():
+ var child := node.get_child(i)
+ var found_node := find_first_node(child, klass)
+ if found_node != null:
+ return found_node
+ return null
+
+
+static func is_in_edited_scene(node: Node) -> bool:
+ if not node.is_inside_tree():
+ return false
+ var edited_scene := node.get_tree().edited_scene_root
+ if node == edited_scene:
+ return true
+ return edited_scene != null and edited_scene.is_ancestor_of(node)
+
+
+# Get an extended or cropped version of an image,
+# with optional anchoring to decide in which direction to extend or crop.
+# New pixels are filled with the provided fill color.
+static func get_cropped_image(src: Image, width: int, height: int,
+ fill_color=null, anchor=Vector2(-1, -1)) -> Image:
+
+ width = int(width)
+ height = int(height)
+ if width == src.get_width() and height == src.get_height():
+ return src
+ var im := Image.create(width, height, false, src.get_format())
+ if fill_color != null:
+ im.fill(fill_color)
+ var p = get_cropped_image_params(
+ src.get_width(), src.get_height(), width, height, anchor)
+ im.blit_rect(src, p.src_rect, p.dst_pos)
+ return im
+
+
+static func get_cropped_image_params(src_w: int, src_h: int, dst_w: int, dst_h: int,
+ anchor: Vector2) -> Dictionary:
+
+ var rel_anchor := (anchor + Vector2(1, 1)) / 2.0
+
+ var dst_x := (dst_w - src_w) * rel_anchor.x
+ var dst_y := (dst_h - src_h) * rel_anchor.y
+
+ var src_x := 0
+ var src_y := 0
+
+ if dst_x < 0:
+ src_x -= dst_x
+ src_w -= dst_x
+ dst_x = 0
+
+ if dst_y < 0:
+ src_y -= dst_y
+ src_h -= dst_y
+ dst_y = 0
+
+ if dst_x + src_w >= dst_w:
+ src_w = dst_w - dst_x
+
+ if dst_y + src_h >= dst_h:
+ src_h = dst_h - dst_y
+
+ return {
+ "src_rect": Rect2i(src_x, src_y, src_w, src_h),
+ "dst_pos": Vector2i(dst_x, dst_y)
+ }
+
+# TODO Workaround for https://github.com/godotengine/godot/issues/24488
+# TODO Simplify in Godot 3.1 if that's still not fixed,
+# using https://github.com/godotengine/godot/pull/21806
+# And actually that function does not even work.
+#static func get_shader_param_or_default(mat: Material, name: String):
+# assert(mat.shader != null)
+# var v = mat.get_shader_param(name)
+# if v != null:
+# return v
+# var params = VisualServer.shader_get_param_list(mat.shader)
+# for p in params:
+# if p.name == name:
+# match p.type:
+# TYPE_OBJECT:
+# return null
+# # I should normally check default values,
+# # however they are not accessible
+# TYPE_BOOL:
+# return false
+# TYPE_REAL:
+# return 0.0
+# TYPE_VECTOR2:
+# return Vector2()
+# TYPE_VECTOR3:
+# return Vector3()
+# TYPE_COLOR:
+# return Color()
+# return null
+
+
+# Generic way to apply editor scale to a plugin UI scene.
+# It is slower than doing it manually on specific controls.
+# Takes a node as root because since Godot 4 Window dialogs are no longer Controls.
+static func apply_dpi_scale(root: Node, dpi_scale: float):
+ if dpi_scale == 1.0:
+ return
+ var to_process := [root]
+ while len(to_process) > 0:
+ var node : Node = to_process[-1]
+ to_process.pop_back()
+ if node is Window:
+ node.size = Vector2(node.size) * dpi_scale
+ elif node is Viewport or node is SubViewport:
+ continue
+ elif node is Control:
+ if node.custom_minimum_size != Vector2(0, 0):
+ node.custom_minimum_size = node.custom_minimum_size * dpi_scale
+ var parent = node.get_parent()
+ if parent != null:
+ if not (parent is Container):
+ node.offset_bottom *= dpi_scale
+ node.offset_left *= dpi_scale
+ node.offset_top *= dpi_scale
+ node.offset_right *= dpi_scale
+ for i in node.get_child_count():
+ to_process.append(node.get_child(i))
+
+
+# TODO AABB has `intersects_segment` but doesn't provide the hit point
+# So we have to rely on a less efficient method.
+# Returns a list of intersections between an AABB and a segment, sorted
+# by distance to the beginning of the segment.
+static func get_aabb_intersection_with_segment(aabb: AABB,
+ segment_begin: Vector3, segment_end: Vector3) -> Array:
+
+ var hits := []
+
+ if not aabb.intersects_segment(segment_begin, segment_end):
+ return hits
+
+ var hit
+
+ var x_rect := Rect2(aabb.position.y, aabb.position.z, aabb.size.y, aabb.size.z)
+
+ hit = Plane(Vector3(1, 0, 0), aabb.position.x) \
+ .intersects_segment(segment_begin, segment_end)
+ if hit != null and x_rect.has_point(Vector2(hit.y, hit.z)):
+ hits.append(hit)
+
+ hit = Plane(Vector3(1, 0, 0), aabb.end.x) \
+ .intersects_segment(segment_begin, segment_end)
+ if hit != null and x_rect.has_point(Vector2(hit.y, hit.z)):
+ hits.append(hit)
+
+ var y_rect := Rect2(aabb.position.x, aabb.position.z, aabb.size.x, aabb.size.z)
+
+ hit = Plane(Vector3(0, 1, 0), aabb.position.y) \
+ .intersects_segment(segment_begin, segment_end)
+ if hit != null and y_rect.has_point(Vector2(hit.x, hit.z)):
+ hits.append(hit)
+
+ hit = Plane(Vector3(0, 1, 0), aabb.end.y) \
+ .intersects_segment(segment_begin, segment_end)
+ if hit != null and y_rect.has_point(Vector2(hit.x, hit.z)):
+ hits.append(hit)
+
+ var z_rect := Rect2(aabb.position.x, aabb.position.y, aabb.size.x, aabb.size.y)
+
+ hit = Plane(Vector3(0, 0, 1), aabb.position.z) \
+ .intersects_segment(segment_begin, segment_end)
+ if hit != null and z_rect.has_point(Vector2(hit.x, hit.y)):
+ hits.append(hit)
+
+ hit = Plane(Vector3(0, 0, 1), aabb.end.z) \
+ .intersects_segment(segment_begin, segment_end)
+ if hit != null and z_rect.has_point(Vector2(hit.x, hit.y)):
+ hits.append(hit)
+
+ if len(hits) == 2:
+ # The segment has two hit points. Sort them by distance
+ var d0 = hits[0].distance_squared_to(segment_begin)
+ var d1 = hits[1].distance_squared_to(segment_begin)
+ if d0 > d1:
+ var temp = hits[0]
+ hits[0] = hits[1]
+ hits[1] = temp
+ else:
+ assert(len(hits) < 2)
+
+ return hits
+
+
+class HT_GridRaytraceResult2D:
+ var hit_cell_pos: Vector2
+ var prev_cell_pos: Vector2
+
+
+# Iterates through a virtual 2D grid of unit-sized square cells,
+# and executes an action on each cell intersecting the given segment,
+# ordered from begin to end.
+# One of my most re-used pieces of code :)
+#
+# Initially inspired by http://www.cse.yorku.ca/~amana/research/grid.pdf
+#
+# Ported from https://github.com/bulletphysics/bullet3/blob/
+# 687780af6b491056700cfb22cab57e61aeec6ab8/src/BulletCollision/CollisionShapes/
+# btHeightfieldTerrainShape.cpp#L418
+#
+static func grid_raytrace_2d(ray_origin: Vector2, ray_direction: Vector2,
+ quad_predicate: Callable, max_distance: float) -> HT_GridRaytraceResult2D:
+
+ if max_distance < 0.0001:
+ # Consider the ray is too small to hit anything
+ return null
+
+ var xi_step := 0
+ if ray_direction.x > 0:
+ xi_step = 1
+ elif ray_direction.x < 0:
+ xi_step = -1
+
+ var yi_step := 0
+ if ray_direction.y > 0:
+ yi_step = 1
+ elif ray_direction.y < 0:
+ yi_step = -1
+
+ var infinite := 9999999.0
+
+ var param_delta_x := infinite
+ if xi_step != 0:
+ param_delta_x = 1.0 / absf(ray_direction.x)
+
+ var param_delta_y := infinite
+ if yi_step != 0:
+ param_delta_y = 1.0 / absf(ray_direction.y)
+
+ # pos = param * dir
+ # At which value of `param` we will cross a x-axis lane?
+ var param_cross_x := infinite
+ # At which value of `param` we will cross a y-axis lane?
+ var param_cross_y := infinite
+
+ # param_cross_x and param_cross_z are initialized as being the first cross
+ # X initialization
+ if xi_step != 0:
+ if xi_step == 1:
+ param_cross_x = (ceilf(ray_origin.x) - ray_origin.x) * param_delta_x
+ else:
+ param_cross_x = (ray_origin.x - floorf(ray_origin.x)) * param_delta_x
+ else:
+ # Will never cross on X
+ param_cross_x = infinite
+
+ # Y initialization
+ if yi_step != 0:
+ if yi_step == 1:
+ param_cross_y = (ceilf(ray_origin.y) - ray_origin.y) * param_delta_y
+ else:
+ param_cross_y = (ray_origin.y - floorf(ray_origin.y)) * param_delta_y
+ else:
+ # Will never cross on Y
+ param_cross_y = infinite
+
+ var x := int(floorf(ray_origin.x))
+ var y := int(floorf(ray_origin.y))
+
+ # Workaround cases where the ray starts at an integer position
+ if param_cross_x == 0.0:
+ param_cross_x += param_delta_x
+ # If going backwards, we should ignore the position we would get by the above flooring,
+ # because the ray is not heading in that direction
+ if xi_step == -1:
+ x -= 1
+
+ if param_cross_y == 0.0:
+ param_cross_y += param_delta_y
+ if yi_step == -1:
+ y -= 1
+
+ var prev_x := x
+ var prev_y := y
+ var param := 0.0
+ var prev_param := 0.0
+
+ while true:
+ prev_x = x
+ prev_y = y
+ prev_param = param
+
+ if param_cross_x < param_cross_y:
+ # X lane
+ x += xi_step
+ # Assign before advancing the param,
+ # to be in sync with the initialization step
+ param = param_cross_x
+ param_cross_x += param_delta_x
+
+ else:
+ # Y lane
+ y += yi_step
+ param = param_cross_y
+ param_cross_y += param_delta_y
+
+ if param > max_distance:
+ param = max_distance
+ # quad coordinates, enter param, exit/end param
+ if quad_predicate.call(prev_x, prev_y, prev_param, param):
+ var res := HT_GridRaytraceResult2D.new()
+ res.hit_cell_pos = Vector2(x, y)
+ res.prev_cell_pos = Vector2(prev_x, prev_y)
+ return res
+ else:
+ break
+
+ elif quad_predicate.call(prev_x, prev_y, prev_param, param):
+ var res := HT_GridRaytraceResult2D.new()
+ res.hit_cell_pos = Vector2(x, y)
+ res.prev_cell_pos = Vector2(prev_x, prev_y)
+ return res
+
+ return null
+
+
+static func get_segment_clipped_by_rect(rect: Rect2,
+ segment_begin: Vector2, segment_end: Vector2) -> Array:
+
+ # /
+ # A-----/---B A-----+---B
+ # | / | => | / |
+ # | / | | / |
+ # C--/------D C--+------D
+ # /
+
+ if rect.has_point(segment_begin) and rect.has_point(segment_end):
+ return [segment_begin, segment_end]
+
+ var a := rect.position
+ var b := Vector2(rect.end.x, rect.position.y)
+ var c := Vector2(rect.position.x, rect.end.y)
+ var d := rect.end
+
+ var ab = Geometry2D.segment_intersects_segment(segment_begin, segment_end, a, b)
+ var cd = Geometry2D.segment_intersects_segment(segment_begin, segment_end, c, d)
+ var ac = Geometry2D.segment_intersects_segment(segment_begin, segment_end, a, c)
+ var bd = Geometry2D.segment_intersects_segment(segment_begin, segment_end, b, d)
+
+ var hits = []
+ if ab != null:
+ hits.append(ab)
+ if cd != null:
+ hits.append(cd)
+ if ac != null:
+ hits.append(ac)
+ if bd != null:
+ hits.append(bd)
+
+ # Now we need to order the hits from begin to end
+ if len(hits) == 1:
+ if rect.has_point(segment_begin):
+ hits = [segment_begin, hits[0]]
+ elif rect.has_point(segment_end):
+ hits = [hits[0], segment_end]
+ else:
+ # TODO This has a tendency to happen with integer coordinates...
+ # How can you get only 1 hit and have no end of the segment
+ # inside of the rectangle? Float precision shit? Assume no hit...
+ return []
+
+ elif len(hits) == 2:
+ var d0 = hits[0].distance_squared_to(segment_begin)
+ var d1 = hits[1].distance_squared_to(segment_begin)
+ if d0 > d1:
+ hits = [hits[1], hits[0]]
+
+ return hits
+
+
+static func get_pixel_clamped(im: Image, x: int, y: int) -> Color:
+ x = clampi(x, 0, im.get_width() - 1)
+ y = clampi(y, 0, im.get_height() - 1)
+ return im.get_pixel(x, y)
+
+
+static func update_configuration_warning(node: Node, recursive: bool):
+ if not Engine.is_editor_hint():
+ return
+ node.update_configuration_warnings()
+ if recursive:
+ for i in node.get_child_count():
+ var child = node.get_child(i)
+ update_configuration_warning(child, true)
+
+
+static func write_import_file(settings: Dictionary, imp_fpath: String, logger) -> bool:
+ # TODO Should use ConfigFile instead
+ var f := FileAccess.open(imp_fpath, FileAccess.WRITE)
+ if f == null:
+ var err = FileAccess.get_open_error()
+ logger.error("Could not open '{0}' for write, error {1}" \
+ .format([imp_fpath, HT_Errors.get_message(err)]))
+ return false
+
+ for section in settings:
+ f.store_line(str("[", section, "]"))
+ f.store_line("")
+ var params = settings[section]
+ for key in params:
+ var v = params[key]
+ var sv
+ match typeof(v):
+ TYPE_STRING:
+ sv = str('"', v.replace('"', '\"'), '"')
+ TYPE_BOOL:
+ sv = "true" if v else "false"
+ _:
+ sv = str(v)
+ f.store_line(str(key, "=", sv))
+ f.store_line("")
+
+ return true
+
+
+static func update_texture_partial(
+ tex: ImageTexture, im: Image, src_rect: Rect2i, dst_pos: Vector2i):
+
+ # ..ooo@@@XXX%%%xx..
+ # .oo@@XXX%x%xxx.. ` .
+ # .o@XX%%xx.. ` .
+ # o@X%.. ..ooooooo
+ # .@X%x. ..o@@^^ ^^@@o
+ # .ooo@@@@@@ooo.. ..o@@^ @X%
+ # o@@^^^ ^^^@@@ooo.oo@@^ %
+ # xzI -*-- ^^^o^^ --*- %
+ # @@@o ooooooo^@@^o^@X^@oooooo .X%x
+ # I@@@@@@@@@XX%%xx ( o@o )X%x@ROMBASED@@@X%x
+ # I@@@@XX%%xx oo@@@@X% @@X%x ^^^@@@@@@@X%x
+ # @X%xx o@@@@@@@X% @@XX%%x ) ^^@X%x
+ # ^ xx o@@@@@@@@Xx ^ @XX%%x xxx
+ # o@@^^^ooo I^^ I^o ooo . x
+ # oo @^ IX I ^X @^ oo
+ # IX U . V IX
+ # V . . V
+ #
+
+ # TODO Optimize: Godot 4 has lost the ability to update textures partially!
+ var fuck = tex.get_image()
+ fuck.blit_rect(im, src_rect, dst_pos)
+ tex.update(fuck)
+
diff --git a/game/addons/zylann.hterrain/util/xyz_format.gd b/game/addons/zylann.hterrain/util/xyz_format.gd
new file mode 100644
index 0000000..86e4a1a
--- /dev/null
+++ b/game/addons/zylann.hterrain/util/xyz_format.gd
@@ -0,0 +1,109 @@
+@tool
+
+# XYZ files are text files containing a list of 3D points.
+# They can be found in GIS software as an export format for heightmaps.
+# In order to turn it into a heightmap we may calculate bounds first
+# to find the origin and then set points in an image.
+
+
+class HT_XYZBounds:
+ # Note: it is important for these to be double-precision floats,
+ # GIS data can have large coordinates
+ var min_x := 0.0
+ var min_y := 0.0
+
+ var max_x := 0.0
+ var max_y := 0.0
+
+ var line_count := 0
+
+ var image_width := 0
+ var image_height := 0
+
+
+# TODO `split_float` returns 32-bit floats, despite internally parsing doubles...
+# Despite that, I still use it here because it doesn't seem to cause issues and is faster.
+# If it becomes an issue, we'll have to switch to `split` and casting to `float`.
+
+static func load_bounds(f: FileAccess) -> HT_XYZBounds:
+ # It is faster to get line and split floats than using CSV functions
+ var line := f.get_line()
+ var floats := line.split_floats(" ")
+
+ # We only care about X and Y, it makes less operations to do in the loop.
+ # Z is the height and will remain as-is at the end.
+ var min_pos_x := floats[0]
+ var min_pos_y := floats[1]
+
+ var max_pos_x := min_pos_x
+ var max_pos_y := min_pos_y
+
+ # Start at 1 because we just read the first line
+ var line_count := 1
+
+ # We know the file is a series of float triplets
+ while not f.eof_reached():
+ line = f.get_line()
+
+ # The last line can be empty
+ if len(line) < 2:
+ break
+
+ floats = line.split_floats(" ")
+
+ var pos_x := floats[0]
+ var pos_y := floats[1]
+
+ min_pos_x = minf(min_pos_x, pos_x)
+ min_pos_y = minf(min_pos_y, pos_y)
+
+ max_pos_x = maxf(max_pos_x, pos_x)
+ max_pos_y = maxf(max_pos_y, pos_y)
+
+ line_count += 1
+
+ var bounds := HT_XYZBounds.new()
+ bounds.min_x = min_pos_x
+ bounds.min_y = min_pos_y
+ bounds.max_x = max_pos_x
+ bounds.max_y = max_pos_y
+ bounds.line_count = line_count
+ bounds.image_width = int(max_pos_x - min_pos_x) + 1
+ bounds.image_height = int(max_pos_y - min_pos_y) + 1
+ return bounds
+
+
+# Loads points into an image with existing dimensions and format.
+# `f` must be positioned at the beginning of the series of points.
+# If `bounds` is `null`, it will be computed.
+static func load_heightmap(f: FileAccess, dst_image: Image, bounds: HT_XYZBounds):
+ # We are not going to read the entire file directly in memory, because it can be really big.
+ # Instead we'll parse it directly and the only thing we retain in memory is the heightmap.
+ # This can be really slow on big files. If we can assume the file is square and points
+ # separated by 1 unit each in a grid pattern, it could be a bit faster, but
+ # parsing points from text really is the main bottleneck (40 seconds to load a 2000x2000 file!).
+
+ # Bounds can be precalculated
+ if bounds == null:
+ var file_begin := f.get_position()
+ bounds = load_bounds(f)
+ f.seek(file_begin)
+
+ # Put min coordinates on the GDScript stack so they are faster to access
+ var min_pos_x := bounds.min_x
+ var min_pos_y := bounds.min_y
+ var line_count := bounds.line_count
+
+ for i in line_count:
+ var line := f.get_line()
+ var floats := line.split_floats(" ")
+ var x := int(floats[0] - min_pos_x)
+ var y := int(floats[1] - min_pos_y)
+
+ # Make sure the coordinate is inside the image,
+ # due to float imprecision or potentially non-grid-aligned points.
+ # Could use `Rect2` to check faster but it uses floats.
+ # `Rect2i` would be better but is only available in Godot 4.
+ if x >= 0 and y >= 0 and x < dst_image.get_width() and y < dst_image.get_height():
+ dst_image.set_pixel(x, y, Color(floats[2], 0, 0))
+