aboutsummaryrefslogtreecommitdiff
path: root/game/addons/zylann.hterrain/tools
diff options
context:
space:
mode:
Diffstat (limited to 'game/addons/zylann.hterrain/tools')
-rw-r--r--game/addons/zylann.hterrain/tools/about/about_dialog.gd28
-rw-r--r--game/addons/zylann.hterrain/tools/about/about_dialog.tscn85
-rw-r--r--game/addons/zylann.hterrain/tools/brush/brush.gd217
-rw-r--r--game/addons/zylann.hterrain/tools/brush/brush_editor.gd234
-rw-r--r--game/addons/zylann.hterrain/tools/brush/brush_editor.tscn130
-rw-r--r--game/addons/zylann.hterrain/tools/brush/decal.gd121
-rw-r--r--game/addons/zylann.hterrain/tools/brush/decal.gdshader41
-rw-r--r--game/addons/zylann.hterrain/tools/brush/no_blend.gdshader6
-rw-r--r--game/addons/zylann.hterrain/tools/brush/no_blend_rf.gdshader9
-rw-r--r--game/addons/zylann.hterrain/tools/brush/painter.gd399
-rw-r--r--game/addons/zylann.hterrain/tools/brush/settings_dialog/brush_settings_dialog.gd280
-rw-r--r--game/addons/zylann.hterrain/tools/brush/settings_dialog/brush_settings_dialog.tscn211
-rw-r--r--game/addons/zylann.hterrain/tools/brush/settings_dialog/preview_painter.gd41
-rw-r--r--game/addons/zylann.hterrain/tools/brush/settings_dialog/preview_scratchpad.gd70
-rw-r--r--game/addons/zylann.hterrain/tools/brush/settings_dialog/preview_scratchpad.tscn22
-rw-r--r--game/addons/zylann.hterrain/tools/brush/shaders/alpha.gdshader19
-rw-r--r--game/addons/zylann.hterrain/tools/brush/shaders/color.gdshader68
-rw-r--r--game/addons/zylann.hterrain/tools/brush/shaders/erode.gdshader64
-rw-r--r--game/addons/zylann.hterrain/tools/brush/shaders/flatten.gdshader22
-rw-r--r--game/addons/zylann.hterrain/tools/brush/shaders/level.gdshader45
-rw-r--r--game/addons/zylann.hterrain/tools/brush/shaders/raise.gdshader22
-rw-r--r--game/addons/zylann.hterrain/tools/brush/shaders/smooth.gdshader34
-rw-r--r--game/addons/zylann.hterrain/tools/brush/shaders/splat16.gdshader81
-rw-r--r--game/addons/zylann.hterrain/tools/brush/shaders/splat4.gdshader63
-rw-r--r--game/addons/zylann.hterrain/tools/brush/shaders/splat_indexed.gdshader89
-rw-r--r--game/addons/zylann.hterrain/tools/brush/shapes/acrylic1.exrbin0 -> 12881 bytes
-rw-r--r--game/addons/zylann.hterrain/tools/brush/shapes/acrylic1.exr.import34
-rw-r--r--game/addons/zylann.hterrain/tools/brush/shapes/round0.exrbin0 -> 11022 bytes
-rw-r--r--game/addons/zylann.hterrain/tools/brush/shapes/round0.exr.import34
-rw-r--r--game/addons/zylann.hterrain/tools/brush/shapes/round1.exrbin0 -> 17380 bytes
-rw-r--r--game/addons/zylann.hterrain/tools/brush/shapes/round1.exr.import34
-rw-r--r--game/addons/zylann.hterrain/tools/brush/shapes/round2.exrbin0 -> 10895 bytes
-rw-r--r--game/addons/zylann.hterrain/tools/brush/shapes/round2.exr.import34
-rw-r--r--game/addons/zylann.hterrain/tools/brush/shapes/round3.exrbin0 -> 8906 bytes
-rw-r--r--game/addons/zylann.hterrain/tools/brush/shapes/round3.exr.import34
-rw-r--r--game/addons/zylann.hterrain/tools/brush/shapes/smoke.exrbin0 -> 10348 bytes
-rw-r--r--game/addons/zylann.hterrain/tools/brush/shapes/smoke.exr.import34
-rw-r--r--game/addons/zylann.hterrain/tools/brush/shapes/texture1.exrbin0 -> 17201 bytes
-rw-r--r--game/addons/zylann.hterrain/tools/brush/shapes/texture1.exr.import34
-rw-r--r--game/addons/zylann.hterrain/tools/brush/shapes/thing.exrbin0 -> 25254 bytes
-rw-r--r--game/addons/zylann.hterrain/tools/brush/shapes/thing.exr.import34
-rw-r--r--game/addons/zylann.hterrain/tools/brush/shapes/vegetation1.exrbin0 -> 12597 bytes
-rw-r--r--game/addons/zylann.hterrain/tools/brush/shapes/vegetation1.exr.import34
-rw-r--r--game/addons/zylann.hterrain/tools/brush/terrain_painter.gd573
-rw-r--r--game/addons/zylann.hterrain/tools/bump2normal_tex.gdshader25
-rw-r--r--game/addons/zylann.hterrain/tools/detail_editor/detail_editor.gd200
-rw-r--r--game/addons/zylann.hterrain/tools/detail_editor/detail_editor.tscn48
-rw-r--r--game/addons/zylann.hterrain/tools/exporter/export_image_dialog.gd221
-rw-r--r--game/addons/zylann.hterrain/tools/exporter/export_image_dialog.tscn125
-rw-r--r--game/addons/zylann.hterrain/tools/generate_mesh_dialog.gd54
-rw-r--r--game/addons/zylann.hterrain/tools/generate_mesh_dialog.tscn69
-rw-r--r--game/addons/zylann.hterrain/tools/generator/generator_dialog.gd562
-rw-r--r--game/addons/zylann.hterrain/tools/generator/generator_dialog.tscn84
-rw-r--r--game/addons/zylann.hterrain/tools/generator/shaders/bump2normal.gdshader27
-rw-r--r--game/addons/zylann.hterrain/tools/generator/shaders/erode.gdshader76
-rw-r--r--game/addons/zylann.hterrain/tools/generator/shaders/perlin_noise.gdshader211
-rw-r--r--game/addons/zylann.hterrain/tools/generator/texture_generator.gd331
-rw-r--r--game/addons/zylann.hterrain/tools/generator/texture_generator_pass.gd43
-rw-r--r--game/addons/zylann.hterrain/tools/globalmap_baker.gd168
-rw-r--r--game/addons/zylann.hterrain/tools/icons/empty.pngbin0 -> 832 bytes
-rw-r--r--game/addons/zylann.hterrain/tools/icons/empty.png.import34
-rw-r--r--game/addons/zylann.hterrain/tools/icons/icon_anchor_bottom.svg66
-rw-r--r--game/addons/zylann.hterrain/tools/icons/icon_anchor_bottom.svg.import37
-rw-r--r--game/addons/zylann.hterrain/tools/icons/icon_anchor_bottom_left.svg66
-rw-r--r--game/addons/zylann.hterrain/tools/icons/icon_anchor_bottom_left.svg.import37
-rw-r--r--game/addons/zylann.hterrain/tools/icons/icon_anchor_bottom_right.svg66
-rw-r--r--game/addons/zylann.hterrain/tools/icons/icon_anchor_bottom_right.svg.import37
-rw-r--r--game/addons/zylann.hterrain/tools/icons/icon_anchor_center.svg65
-rw-r--r--game/addons/zylann.hterrain/tools/icons/icon_anchor_center.svg.import37
-rw-r--r--game/addons/zylann.hterrain/tools/icons/icon_anchor_left.svg66
-rw-r--r--game/addons/zylann.hterrain/tools/icons/icon_anchor_left.svg.import37
-rw-r--r--game/addons/zylann.hterrain/tools/icons/icon_anchor_right.svg66
-rw-r--r--game/addons/zylann.hterrain/tools/icons/icon_anchor_right.svg.import37
-rw-r--r--game/addons/zylann.hterrain/tools/icons/icon_anchor_top.svg66
-rw-r--r--game/addons/zylann.hterrain/tools/icons/icon_anchor_top.svg.import37
-rw-r--r--game/addons/zylann.hterrain/tools/icons/icon_anchor_top_left.svg66
-rw-r--r--game/addons/zylann.hterrain/tools/icons/icon_anchor_top_left.svg.import37
-rw-r--r--game/addons/zylann.hterrain/tools/icons/icon_anchor_top_right.svg66
-rw-r--r--game/addons/zylann.hterrain/tools/icons/icon_anchor_top_right.svg.import37
-rw-r--r--game/addons/zylann.hterrain/tools/icons/icon_detail_layer_node.svg90
-rw-r--r--game/addons/zylann.hterrain/tools/icons/icon_detail_layer_node.svg.import37
-rw-r--r--game/addons/zylann.hterrain/tools/icons/icon_grass.svg90
-rw-r--r--game/addons/zylann.hterrain/tools/icons/icon_grass.svg.import37
-rw-r--r--game/addons/zylann.hterrain/tools/icons/icon_heightmap_color.svg90
-rw-r--r--game/addons/zylann.hterrain/tools/icons/icon_heightmap_color.svg.import37
-rw-r--r--game/addons/zylann.hterrain/tools/icons/icon_heightmap_data.svg61
-rw-r--r--game/addons/zylann.hterrain/tools/icons/icon_heightmap_data.svg.import37
-rw-r--r--game/addons/zylann.hterrain/tools/icons/icon_heightmap_erode.svg102
-rw-r--r--game/addons/zylann.hterrain/tools/icons/icon_heightmap_erode.svg.import37
-rw-r--r--game/addons/zylann.hterrain/tools/icons/icon_heightmap_flatten.svg116
-rw-r--r--game/addons/zylann.hterrain/tools/icons/icon_heightmap_flatten.svg.import37
-rw-r--r--game/addons/zylann.hterrain/tools/icons/icon_heightmap_level.svg72
-rw-r--r--game/addons/zylann.hterrain/tools/icons/icon_heightmap_level.svg.import37
-rw-r--r--game/addons/zylann.hterrain/tools/icons/icon_heightmap_lower.svg66
-rw-r--r--game/addons/zylann.hterrain/tools/icons/icon_heightmap_lower.svg.import37
-rw-r--r--game/addons/zylann.hterrain/tools/icons/icon_heightmap_mask.svg66
-rw-r--r--game/addons/zylann.hterrain/tools/icons/icon_heightmap_mask.svg.import37
-rw-r--r--game/addons/zylann.hterrain/tools/icons/icon_heightmap_node.svg61
-rw-r--r--game/addons/zylann.hterrain/tools/icons/icon_heightmap_node.svg.import37
-rw-r--r--game/addons/zylann.hterrain/tools/icons/icon_heightmap_node_large.svg61
-rw-r--r--game/addons/zylann.hterrain/tools/icons/icon_heightmap_node_large.svg.import37
-rw-r--r--game/addons/zylann.hterrain/tools/icons/icon_heightmap_paint.svg5
-rw-r--r--game/addons/zylann.hterrain/tools/icons/icon_heightmap_paint.svg.import37
-rw-r--r--game/addons/zylann.hterrain/tools/icons/icon_heightmap_raise.svg66
-rw-r--r--game/addons/zylann.hterrain/tools/icons/icon_heightmap_raise.svg.import37
-rw-r--r--game/addons/zylann.hterrain/tools/icons/icon_heightmap_smooth.svg72
-rw-r--r--game/addons/zylann.hterrain/tools/icons/icon_heightmap_smooth.svg.import37
-rw-r--r--game/addons/zylann.hterrain/tools/icons/icon_heightmap_unmask.svg68
-rw-r--r--game/addons/zylann.hterrain/tools/icons/icon_heightmap_unmask.svg.import37
-rw-r--r--game/addons/zylann.hterrain/tools/icons/icon_long_arrow_down.svg66
-rw-r--r--game/addons/zylann.hterrain/tools/icons/icon_long_arrow_down.svg.import37
-rw-r--r--game/addons/zylann.hterrain/tools/icons/icon_long_arrow_right.svg66
-rw-r--r--game/addons/zylann.hterrain/tools/icons/icon_long_arrow_right.svg.import37
-rw-r--r--game/addons/zylann.hterrain/tools/icons/icon_minimap_out_of_range_position.svg66
-rw-r--r--game/addons/zylann.hterrain/tools/icons/icon_minimap_out_of_range_position.svg.import37
-rw-r--r--game/addons/zylann.hterrain/tools/icons/icon_minimap_position.svg66
-rw-r--r--game/addons/zylann.hterrain/tools/icons/icon_minimap_position.svg.import37
-rw-r--r--game/addons/zylann.hterrain/tools/icons/icon_small_circle.svg68
-rw-r--r--game/addons/zylann.hterrain/tools/icons/icon_small_circle.svg.import37
-rw-r--r--game/addons/zylann.hterrain/tools/icons/white.pngbin0 -> 135 bytes
-rw-r--r--game/addons/zylann.hterrain/tools/icons/white.png.import35
-rw-r--r--game/addons/zylann.hterrain/tools/importer/importer_dialog.gd312
-rw-r--r--game/addons/zylann.hterrain/tools/importer/importer_dialog.tscn87
-rw-r--r--game/addons/zylann.hterrain/tools/inspector/inspector.gd479
-rw-r--r--game/addons/zylann.hterrain/tools/inspector/inspector.tscn15
-rw-r--r--game/addons/zylann.hterrain/tools/load_texture_dialog.gd22
-rw-r--r--game/addons/zylann.hterrain/tools/minimap/minimap.gd140
-rw-r--r--game/addons/zylann.hterrain/tools/minimap/minimap.tscn56
-rw-r--r--game/addons/zylann.hterrain/tools/minimap/minimap_normal.gdshader24
-rw-r--r--game/addons/zylann.hterrain/tools/minimap/minimap_overlay.gd24
-rw-r--r--game/addons/zylann.hterrain/tools/minimap/ratio_container.gd32
-rw-r--r--game/addons/zylann.hterrain/tools/normalmap_baker.gd148
-rw-r--r--game/addons/zylann.hterrain/tools/packed_textures/packed_texture_util.gd86
-rw-r--r--game/addons/zylann.hterrain/tools/panel.gd75
-rw-r--r--game/addons/zylann.hterrain/tools/panel.tscn58
-rw-r--r--game/addons/zylann.hterrain/tools/plugin.gd884
-rw-r--r--game/addons/zylann.hterrain/tools/preview_generator.gd65
-rw-r--r--game/addons/zylann.hterrain/tools/progress_window.gd32
-rw-r--r--game/addons/zylann.hterrain/tools/progress_window.tscn22
-rw-r--r--game/addons/zylann.hterrain/tools/resize_dialog/resize_dialog.gd174
-rw-r--r--game/addons/zylann.hterrain/tools/resize_dialog/resize_dialog.tscn159
-rw-r--r--game/addons/zylann.hterrain/tools/terrain_preview.gd143
-rw-r--r--game/addons/zylann.hterrain/tools/terrain_preview.tscn38
-rw-r--r--game/addons/zylann.hterrain/tools/texture_editor/display_alpha.gdshader6
-rw-r--r--game/addons/zylann.hterrain/tools/texture_editor/display_alpha_material.tres9
-rw-r--r--game/addons/zylann.hterrain/tools/texture_editor/display_alpha_slice.gdshader9
-rw-r--r--game/addons/zylann.hterrain/tools/texture_editor/display_color.gdshader7
-rw-r--r--game/addons/zylann.hterrain/tools/texture_editor/display_color_material.tres6
-rw-r--r--game/addons/zylann.hterrain/tools/texture_editor/display_color_slice.gdshader9
-rw-r--r--game/addons/zylann.hterrain/tools/texture_editor/display_normal.gdshader28
-rw-r--r--game/addons/zylann.hterrain/tools/texture_editor/flow_container.gd41
-rw-r--r--game/addons/zylann.hterrain/tools/texture_editor/set_editor/source_file_item_editor.gd60
-rw-r--r--game/addons/zylann.hterrain/tools/texture_editor/set_editor/source_file_item_editor.tscn29
-rw-r--r--game/addons/zylann.hterrain/tools/texture_editor/set_editor/texture_set_editor.gd529
-rw-r--r--game/addons/zylann.hterrain/tools/texture_editor/set_editor/texture_set_editor.tscn194
-rw-r--r--game/addons/zylann.hterrain/tools/texture_editor/set_editor/texture_set_import_editor.gd920
-rw-r--r--game/addons/zylann.hterrain/tools/texture_editor/set_editor/texture_set_import_editor.tscn218
-rw-r--r--game/addons/zylann.hterrain/tools/texture_editor/texture_editor.gd134
-rw-r--r--game/addons/zylann.hterrain/tools/texture_editor/texture_editor.tscn48
-rw-r--r--game/addons/zylann.hterrain/tools/texture_editor/texture_list.gd79
-rw-r--r--game/addons/zylann.hterrain/tools/texture_editor/texture_list.tscn20
-rw-r--r--game/addons/zylann.hterrain/tools/texture_editor/texture_list_item.gd72
-rw-r--r--game/addons/zylann.hterrain/tools/texture_editor/texture_list_item.tscn25
-rw-r--r--game/addons/zylann.hterrain/tools/util/dialog_fitter.gd53
-rw-r--r--game/addons/zylann.hterrain/tools/util/dialog_fitter.tscn10
-rw-r--r--game/addons/zylann.hterrain/tools/util/editor_util.gd104
-rw-r--r--game/addons/zylann.hterrain/tools/util/interval_slider.gd197
-rw-r--r--game/addons/zylann.hterrain/tools/util/resource_importer_texture.gd19
-rw-r--r--game/addons/zylann.hterrain/tools/util/resource_importer_texture_layered.gd9
-rw-r--r--game/addons/zylann.hterrain/tools/util/result.gd36
-rw-r--r--game/addons/zylann.hterrain/tools/util/rich_text_label_hyperlinks.gd11
-rw-r--r--game/addons/zylann.hterrain/tools/util/spin_slider.gd385
-rw-r--r--game/addons/zylann.hterrain/tools/util/spin_slider.tscn13
173 files changed, 15032 insertions, 0 deletions
diff --git a/game/addons/zylann.hterrain/tools/about/about_dialog.gd b/game/addons/zylann.hterrain/tools/about/about_dialog.gd
new file mode 100644
index 0000000..20ec8f8
--- /dev/null
+++ b/game/addons/zylann.hterrain/tools/about/about_dialog.gd
@@ -0,0 +1,28 @@
+@tool
+extends AcceptDialog
+
+const HT_Util = preload("../../util/util.gd")
+const HT_Logger = preload("../../util/logger.gd")
+const HT_Errors = preload("../../util/errors.gd")
+
+const PLUGIN_CFG_PATH = "res://addons/zylann.hterrain/plugin.cfg"
+
+
+@onready var _about_rich_text_label : RichTextLabel = $VB/HB2/TC/About
+
+var _logger = HT_Logger.get_for(self)
+
+
+func _ready():
+ if HT_Util.is_in_edited_scene(self):
+ return
+
+ var plugin_cfg = ConfigFile.new()
+ var err := plugin_cfg.load(PLUGIN_CFG_PATH)
+ if err != OK:
+ _logger.error("Could not load {0}: {1}" \
+ .format([PLUGIN_CFG_PATH, HT_Errors.get_message(err)]))
+ return
+ var version = plugin_cfg.get_value("plugin", "version", "--.--.--")
+
+ _about_rich_text_label.text = _about_rich_text_label.text.format({"version": version})
diff --git a/game/addons/zylann.hterrain/tools/about/about_dialog.tscn b/game/addons/zylann.hterrain/tools/about/about_dialog.tscn
new file mode 100644
index 0000000..efeda7a
--- /dev/null
+++ b/game/addons/zylann.hterrain/tools/about/about_dialog.tscn
@@ -0,0 +1,85 @@
+[gd_scene load_steps=4 format=3 uid="uid://cvuubd08805oa"]
+
+[ext_resource type="Script" path="res://addons/zylann.hterrain/tools/about/about_dialog.gd" id="1"]
+[ext_resource type="Texture2D" uid="uid://sdaddk8wxjin" path="res://addons/zylann.hterrain/tools/icons/icon_heightmap_node_large.svg" id="2"]
+[ext_resource type="Script" path="res://addons/zylann.hterrain/tools/util/rich_text_label_hyperlinks.gd" id="3"]
+
+[node name="AboutDialog" type="AcceptDialog"]
+size = Vector2i(516, 357)
+script = ExtResource("1")
+
+[node name="VB" type="VBoxContainer" parent="."]
+custom_minimum_size = Vector2(500, 300)
+anchors_preset = 15
+anchor_right = 1.0
+anchor_bottom = 1.0
+offset_left = 8.0
+offset_top = 8.0
+offset_right = -8.0
+offset_bottom = -49.0
+
+[node name="HB2" type="HBoxContainer" parent="VB"]
+layout_mode = 2
+size_flags_vertical = 3
+
+[node name="TextureRect" type="TextureRect" parent="VB/HB2"]
+layout_mode = 2
+texture = ExtResource("2")
+stretch_mode = 2
+
+[node name="TC" type="TabContainer" parent="VB/HB2"]
+layout_mode = 2
+size_flags_horizontal = 3
+
+[node name="About" type="RichTextLabel" parent="VB/HB2/TC"]
+layout_mode = 2
+size_flags_horizontal = 3
+size_flags_vertical = 3
+bbcode_enabled = true
+text = "Version: {version}
+Author: Marc Gilleron
+Repository: https://github.com/Zylann/godot_heightmap_plugin
+Issue tracker: https://github.com/Zylann/godot_heightmap_plugin/issues
+
+Gold supporters:
+
+Aaron Franke (aaronfranke)
+
+Silver supporters:
+
+TheConceptBoy
+Chris Bolton (yochrisbolton)
+Gamerfiend (Snowminx)
+greenlion (Justin Swanhart)
+segfault-god (jp.owo.Manda)
+RonanZe
+Phyronnaz
+NoFr1ends (Lynx)
+
+Bronze supporters:
+
+rcorre (Ryan Roden-Corrent)
+duchainer (Raphaël Duchaîne)
+MadMartian
+stackdump (stackdump.eth)
+Treer
+MrGreaterThan
+lenis0012
+"
+script = ExtResource("3")
+
+[node name="License" type="RichTextLabel" parent="VB/HB2/TC"]
+visible = false
+layout_mode = 2
+text = "Copyright (c) 2016-2023 Marc Gilleron
+
+Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the \"Software\"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
+
+The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED \"AS IS\", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
+"
+
+[node name="HB" type="HBoxContainer" parent="VB"]
+layout_mode = 2
+alignment = 1
diff --git a/game/addons/zylann.hterrain/tools/brush/brush.gd b/game/addons/zylann.hterrain/tools/brush/brush.gd
new file mode 100644
index 0000000..73d447f
--- /dev/null
+++ b/game/addons/zylann.hterrain/tools/brush/brush.gd
@@ -0,0 +1,217 @@
+@tool
+
+# Brush properties (shape, transform, timing and opacity).
+# Other attributes like color, height or texture index are tool-specific,
+# while brush properties apply to all of them.
+# This is separate from Painter because it could apply to multiple Painters at once.
+
+const HT_Errors = preload("../../util/errors.gd")
+const HT_Painter = preload("./painter.gd")
+
+const SHAPES_DIR = "addons/zylann.hterrain/tools/brush/shapes"
+const DEFAULT_BRUSH_TEXTURE_PATH = SHAPES_DIR + "/round2.exr"
+# Reasonable size for sliders to be usable
+const MAX_SIZE_FOR_SLIDERS = 500
+# Absolute size limit. Terrains can't be larger than that, and it will be very slow to paint
+const MAX_SIZE = 4000
+
+signal size_changed(new_size)
+signal shapes_changed
+signal shape_index_changed
+
+var _size := 32
+var _opacity := 1.0
+var _random_rotation := false
+var _pressure_enabled := false
+var _pressure_over_scale := 0.5
+var _pressure_over_opacity := 0.5
+# TODO Rename stamp_*?
+var _frequency_distance := 0.0
+var _frequency_time_ms := 0
+# Array of greyscale textures
+var _shapes : Array[Texture2D] = []
+
+var _shape_index := 0
+var _shape_cycling_enabled := false
+var _prev_position := Vector2(-999, -999)
+var _prev_time_ms := 0
+
+
+func set_size(size: int):
+ if size < 1:
+ size = 1
+ if size != _size:
+ _size = size
+ size_changed.emit(_size)
+
+
+func get_size() -> int:
+ return _size
+
+
+func set_opacity(opacity: float):
+ _opacity = clampf(opacity, 0.0, 1.0)
+
+
+func get_opacity() -> float:
+ return _opacity
+
+
+func set_random_rotation_enabled(enabled: bool):
+ _random_rotation = enabled
+
+
+func is_random_rotation_enabled() -> bool:
+ return _random_rotation
+
+
+func set_pressure_enabled(enabled: bool):
+ _pressure_enabled = enabled
+
+
+func is_pressure_enabled() -> bool:
+ return _pressure_enabled
+
+
+func set_pressure_over_scale(amount: float):
+ _pressure_over_scale = clampf(amount, 0.0, 1.0)
+
+
+func get_pressure_over_scale() -> float:
+ return _pressure_over_scale
+
+
+func set_pressure_over_opacity(amount: float):
+ _pressure_over_opacity = clampf(amount, 0.0, 1.0)
+
+
+func get_pressure_over_opacity() -> float:
+ return _pressure_over_opacity
+
+
+func set_frequency_distance(d: float):
+ _frequency_distance = maxf(d, 0.0)
+
+
+func get_frequency_distance() -> float:
+ return _frequency_distance
+
+
+func set_frequency_time_ms(t: int):
+ if t < 0:
+ t = 0
+ _frequency_time_ms = t
+
+
+func get_frequency_time_ms() -> int:
+ return _frequency_time_ms
+
+
+func set_shapes(shapes: Array[Texture2D]):
+ assert(len(shapes) >= 1)
+ for s in shapes:
+ assert(s != null)
+ assert(s is Texture2D)
+ _shapes = shapes.duplicate(false)
+ if _shape_index >= len(_shapes):
+ _shape_index = len(_shapes) - 1
+ shapes_changed.emit()
+
+
+func get_shapes() -> Array[Texture2D]:
+ return _shapes.duplicate(false)
+
+
+func get_shape(i: int) -> Texture2D:
+ return _shapes[i]
+
+
+func get_shape_index() -> int:
+ return _shape_index
+
+
+func set_shape_index(i: int):
+ assert(i >= 0)
+ assert(i < len(_shapes))
+ _shape_index = i
+ shape_index_changed.emit()
+
+
+func set_shape_cycling_enabled(enable: bool):
+ _shape_cycling_enabled = enable
+
+
+func is_shape_cycling_enabled() -> bool:
+ return _shape_cycling_enabled
+
+
+static func load_shape_from_image_file(fpath: String, logger, retries := 1) -> Texture2D:
+ var im := Image.new()
+ var err := im.load(fpath)
+ if err != OK:
+ if retries > 0:
+ # TODO There is a bug with Godot randomly being unable to load images.
+ # See https://github.com/Zylann/godot_heightmap_plugin/issues/219
+ # Attempting to workaround this by retrying (I suspect it's because of non-initialized
+ # variable in Godot's C++ code...)
+ logger.error("Could not load image at '{0}', error {1}. Retrying..." \
+ .format([fpath, HT_Errors.get_message(err)]))
+ return load_shape_from_image_file(fpath, logger, retries - 1)
+ else:
+ logger.error("Could not load image at '{0}', error {1}" \
+ .format([fpath, HT_Errors.get_message(err)]))
+ return null
+ var tex := ImageTexture.create_from_image(im)
+ return tex
+
+
+# Call this while handling mouse or pen input.
+# If it returns false, painting should not run.
+func configure_paint_input(painters: Array[HT_Painter], position: Vector2, pressure: float) -> bool:
+ assert(len(_shapes) != 0)
+
+ # DEBUG
+ #pressure = 0.5 + 0.5 * sin(OS.get_ticks_msec() / 200.0)
+
+ if position.distance_to(_prev_position) < _frequency_distance:
+ return false
+ var now := Time.get_ticks_msec()
+ if (now - _prev_time_ms) < _frequency_time_ms:
+ return false
+ _prev_position = position
+ _prev_time_ms = now
+
+ for painter_index in len(painters):
+ var painter : HT_Painter = painters[painter_index]
+
+ if _random_rotation:
+ painter.set_brush_rotation(randf_range(-PI, PI))
+ else:
+ painter.set_brush_rotation(0.0)
+
+ painter.set_brush_texture(_shapes[_shape_index])
+ painter.set_brush_size(_size)
+
+ if _pressure_enabled:
+ painter.set_brush_scale(lerpf(1.0, pressure, _pressure_over_scale))
+ painter.set_brush_opacity(_opacity * lerpf(1.0, pressure, _pressure_over_opacity))
+ else:
+ painter.set_brush_scale(1.0)
+ painter.set_brush_opacity(_opacity)
+
+ #painter.paint_input(position)
+
+ if _shape_cycling_enabled:
+ _shape_index += 1
+ if _shape_index >= len(_shapes):
+ _shape_index = 0
+
+ return true
+
+
+# Call this when the user releases the pen or mouse button
+func on_paint_end():
+ _prev_position = Vector2(-999, -999)
+ _prev_time_ms = 0
+
+
diff --git a/game/addons/zylann.hterrain/tools/brush/brush_editor.gd b/game/addons/zylann.hterrain/tools/brush/brush_editor.gd
new file mode 100644
index 0000000..1e645a3
--- /dev/null
+++ b/game/addons/zylann.hterrain/tools/brush/brush_editor.gd
@@ -0,0 +1,234 @@
+@tool
+extends Control
+
+const HT_TerrainPainter = preload("./terrain_painter.gd")
+const HT_Brush = preload("./brush.gd")
+const HT_Errors = preload("../../util/errors.gd")
+#const NativeFactory = preload("../../native/factory.gd")
+const HT_Logger = preload("../../util/logger.gd")
+const HT_IntervalSlider = preload("../util/interval_slider.gd")
+
+const HT_BrushSettingsDialogScene = preload("./settings_dialog/brush_settings_dialog.tscn")
+const HT_BrushSettingsDialog = preload("./settings_dialog/brush_settings_dialog.gd")
+
+
+@onready var _size_slider : Slider = $GridContainer/BrushSizeControl/Slider
+@onready var _size_value_label : Label = $GridContainer/BrushSizeControl/Label
+#onready var _size_label = _params_container.get_node("BrushSizeLabel")
+
+@onready var _opacity_slider : Slider = $GridContainer/BrushOpacityControl/Slider
+@onready var _opacity_value_label : Label = $GridContainer/BrushOpacityControl/Label
+@onready var _opacity_control : Control = $GridContainer/BrushOpacityControl
+@onready var _opacity_label : Label = $GridContainer/BrushOpacityLabel
+
+@onready var _flatten_height_container : Control = $GridContainer/HB
+@onready var _flatten_height_box : SpinBox = $GridContainer/HB/FlattenHeightControl
+@onready var _flatten_height_label : Label = $GridContainer/FlattenHeightLabel
+@onready var _flatten_height_pick_button : Button = $GridContainer/HB/FlattenHeightPickButton
+
+@onready var _color_picker : ColorPickerButton = $GridContainer/ColorPickerButton
+@onready var _color_label : Label = $GridContainer/ColorLabel
+
+@onready var _density_slider : Slider = $GridContainer/DensitySlider
+@onready var _density_label : Label = $GridContainer/DensityLabel
+
+@onready var _holes_label : Label = $GridContainer/HoleLabel
+@onready var _holes_checkbox : CheckBox = $GridContainer/HoleCheckbox
+
+@onready var _slope_limit_label : Label = $GridContainer/SlopeLimitLabel
+@onready var _slope_limit_control : HT_IntervalSlider = $GridContainer/SlopeLimit
+
+@onready var _shape_texture_rect : TextureRect = get_node("BrushShapeButton/TextureRect")
+
+var _terrain_painter : HT_TerrainPainter
+var _brush_settings_dialog : HT_BrushSettingsDialog = null
+var _logger = HT_Logger.get_for(self)
+
+# TODO This is an ugly workaround for https://github.com/godotengine/godot/issues/19479
+@onready var _temp_node = get_node("Temp")
+@onready var _grid_container = get_node("GridContainer")
+func _set_visibility_of(node: Control, v: bool):
+ node.get_parent().remove_child(node)
+ if v:
+ _grid_container.add_child(node)
+ else:
+ _temp_node.add_child(node)
+ node.visible = v
+
+
+func _ready():
+ _size_slider.value_changed.connect(_on_size_slider_value_changed)
+ _opacity_slider.value_changed.connect(_on_opacity_slider_value_changed)
+ _flatten_height_box.value_changed.connect(_on_flatten_height_box_value_changed)
+ _color_picker.color_changed.connect(_on_color_picker_color_changed)
+ _density_slider.value_changed.connect(_on_density_slider_changed)
+ _holes_checkbox.toggled.connect(_on_holes_checkbox_toggled)
+ _slope_limit_control.changed.connect(_on_slope_limit_changed)
+
+ _size_slider.max_value = HT_Brush.MAX_SIZE_FOR_SLIDERS
+ #if NativeFactory.is_native_available():
+ # _size_slider.max_value = 200
+ #else:
+ # _size_slider.max_value = 50
+
+
+func setup_dialogs(base_control: Node):
+ assert(_brush_settings_dialog == null)
+ _brush_settings_dialog = HT_BrushSettingsDialogScene.instantiate()
+ base_control.add_child(_brush_settings_dialog)
+
+ # That dialog has sub-dialogs
+ _brush_settings_dialog.setup_dialogs(base_control)
+ _brush_settings_dialog.set_brush(_terrain_painter.get_brush())
+
+
+func _exit_tree():
+ if _brush_settings_dialog != null:
+ _brush_settings_dialog.queue_free()
+ _brush_settings_dialog = null
+
+# Testing display modes
+#var mode = 0
+#func _input(event):
+# if event is InputEventKey:
+# if event.pressed:
+# set_display_mode(mode)
+# mode += 1
+# if mode >= Brush.MODE_COUNT:
+# mode = 0
+
+func set_terrain_painter(terrain_painter: HT_TerrainPainter):
+ if _terrain_painter != null:
+ _terrain_painter.flatten_height_changed.disconnect(_on_flatten_height_changed)
+ _terrain_painter.get_brush().shapes_changed.disconnect(_on_brush_shapes_changed)
+ _terrain_painter.get_brush().shape_index_changed.disconnect(_on_brush_shape_index_changed)
+
+ _terrain_painter = terrain_painter
+
+ if _terrain_painter != null:
+ # TODO Had an issue in Godot 3.2.3 where mismatching type would silently cast to null...
+ # It happens if the argument went through a Variant (for example if call_deferred is used)
+ assert(_terrain_painter != null)
+
+ if _terrain_painter != null:
+ # Initial brush params
+ _size_slider.value = _terrain_painter.get_brush().get_size()
+ _opacity_slider.ratio = _terrain_painter.get_brush().get_opacity()
+ # Initial specific params
+ _flatten_height_box.value = _terrain_painter.get_flatten_height()
+ _color_picker.get_picker().color = _terrain_painter.get_color()
+ _density_slider.value = _terrain_painter.get_detail_density()
+ _holes_checkbox.button_pressed = not _terrain_painter.get_mask_flag()
+
+ var low := rad_to_deg(_terrain_painter.get_slope_limit_low_angle())
+ var high := rad_to_deg(_terrain_painter.get_slope_limit_high_angle())
+ _slope_limit_control.set_values(low, high)
+
+ set_display_mode(_terrain_painter.get_mode())
+
+ # Load default brush
+ var brush := _terrain_painter.get_brush()
+ var default_shape_fpath := HT_Brush.DEFAULT_BRUSH_TEXTURE_PATH
+ var default_shape := HT_Brush.load_shape_from_image_file(default_shape_fpath, _logger)
+ brush.set_shapes([default_shape])
+ _update_shape_preview()
+
+ _terrain_painter.flatten_height_changed.connect(_on_flatten_height_changed)
+ brush.shapes_changed.connect(_on_brush_shapes_changed)
+ brush.shape_index_changed.connect(_on_brush_shape_index_changed)
+
+
+func _on_flatten_height_changed():
+ _flatten_height_box.value = _terrain_painter.get_flatten_height()
+ _flatten_height_pick_button.button_pressed = false
+
+
+func _on_brush_shapes_changed():
+ _update_shape_preview()
+
+
+func _on_brush_shape_index_changed():
+ _update_shape_preview()
+
+
+func _update_shape_preview():
+ var brush := _terrain_painter.get_brush()
+ var i := brush.get_shape_index()
+ _shape_texture_rect.texture = brush.get_shape(i)
+
+
+func set_display_mode(mode: int):
+ var show_flatten := mode == HT_TerrainPainter.MODE_FLATTEN
+ var show_color := mode == HT_TerrainPainter.MODE_COLOR
+ var show_density := mode == HT_TerrainPainter.MODE_DETAIL
+ var show_opacity := mode != HT_TerrainPainter.MODE_MASK
+ var show_holes := mode == HT_TerrainPainter.MODE_MASK
+ var show_slope_limit := \
+ mode == HT_TerrainPainter.MODE_SPLAT or mode == HT_TerrainPainter.MODE_DETAIL
+
+ _set_visibility_of(_opacity_label, show_opacity)
+ _set_visibility_of(_opacity_control, show_opacity)
+
+ _set_visibility_of(_color_label, show_color)
+ _set_visibility_of(_color_picker, show_color)
+
+ _set_visibility_of(_flatten_height_label, show_flatten)
+ _set_visibility_of(_flatten_height_container, show_flatten)
+
+ _set_visibility_of(_density_label, show_density)
+ _set_visibility_of(_density_slider, show_density)
+
+ _set_visibility_of(_holes_label, show_holes)
+ _set_visibility_of(_holes_checkbox, show_holes)
+
+ _set_visibility_of(_slope_limit_label, show_slope_limit)
+ _set_visibility_of(_slope_limit_control, show_slope_limit)
+
+ _flatten_height_pick_button.button_pressed = false
+
+
+func _on_size_slider_value_changed(v: float):
+ if _terrain_painter != null:
+ _terrain_painter.set_brush_size(int(v))
+ _size_value_label.text = str(v)
+
+
+func _on_opacity_slider_value_changed(v: float):
+ if _terrain_painter != null:
+ _terrain_painter.set_opacity(_opacity_slider.ratio)
+ _opacity_value_label.text = str(v)
+
+
+func _on_flatten_height_box_value_changed(v: float):
+ if _terrain_painter != null:
+ _terrain_painter.set_flatten_height(v)
+
+
+func _on_color_picker_color_changed(v: Color):
+ if _terrain_painter != null:
+ _terrain_painter.set_color(v)
+
+
+func _on_density_slider_changed(v: float):
+ if _terrain_painter != null:
+ _terrain_painter.set_detail_density(v)
+
+
+func _on_holes_checkbox_toggled(v: bool):
+ if _terrain_painter != null:
+ # When checked, we draw holes. When unchecked, we clear holes
+ _terrain_painter.set_mask_flag(not v)
+
+
+func _on_BrushShapeButton_pressed():
+ _brush_settings_dialog.popup_centered()
+
+
+func _on_FlattenHeightPickButton_pressed():
+ _terrain_painter.set_meta("pick_height", true)
+
+
+func _on_slope_limit_changed():
+ var low = deg_to_rad(_slope_limit_control.get_low_value())
+ var high = deg_to_rad(_slope_limit_control.get_high_value())
+ _terrain_painter.set_slope_limit_angles(low, high)
diff --git a/game/addons/zylann.hterrain/tools/brush/brush_editor.tscn b/game/addons/zylann.hterrain/tools/brush/brush_editor.tscn
new file mode 100644
index 0000000..42ef980
--- /dev/null
+++ b/game/addons/zylann.hterrain/tools/brush/brush_editor.tscn
@@ -0,0 +1,130 @@
+[gd_scene load_steps=4 format=3 uid="uid://bd42ig216p216"]
+
+[ext_resource type="Script" path="res://addons/zylann.hterrain/tools/brush/brush_editor.gd" id="1"]
+[ext_resource type="Script" path="res://addons/zylann.hterrain/tools/util/interval_slider.gd" id="3"]
+
+[sub_resource type="CanvasItemMaterial" id="1"]
+blend_mode = 1
+
+[node name="BrushEditor" type="HBoxContainer"]
+custom_minimum_size = Vector2(200, 0)
+offset_right = 293.0
+offset_bottom = 211.0
+script = ExtResource("1")
+
+[node name="BrushShapeButton" type="Button" parent="."]
+custom_minimum_size = Vector2(50, 0)
+layout_mode = 2
+
+[node name="TextureRect" type="TextureRect" parent="BrushShapeButton"]
+material = SubResource("1")
+layout_mode = 0
+anchor_right = 1.0
+anchor_bottom = 1.0
+mouse_filter = 2
+expand_mode = 1
+stretch_mode = 5
+
+[node name="GridContainer" type="GridContainer" parent="."]
+layout_mode = 2
+size_flags_horizontal = 3
+columns = 2
+
+[node name="BrushSizeLabel" type="Label" parent="GridContainer"]
+layout_mode = 2
+text = "Brush size"
+
+[node name="BrushSizeControl" type="HBoxContainer" parent="GridContainer"]
+layout_mode = 2
+size_flags_horizontal = 3
+mouse_filter = 0
+
+[node name="Slider" type="HSlider" parent="GridContainer/BrushSizeControl"]
+custom_minimum_size = Vector2(60, 0)
+layout_mode = 2
+size_flags_horizontal = 3
+size_flags_vertical = 1
+min_value = 2.0
+max_value = 500.0
+value = 2.0
+exp_edit = true
+rounded = true
+
+[node name="Label" type="Label" parent="GridContainer/BrushSizeControl"]
+layout_mode = 2
+text = "999"
+
+[node name="BrushOpacityLabel" type="Label" parent="GridContainer"]
+layout_mode = 2
+text = "Brush opacity"
+
+[node name="BrushOpacityControl" type="HBoxContainer" parent="GridContainer"]
+layout_mode = 2
+size_flags_horizontal = 3
+
+[node name="Slider" type="HSlider" parent="GridContainer/BrushOpacityControl"]
+layout_mode = 2
+size_flags_horizontal = 3
+size_flags_vertical = 1
+
+[node name="Label" type="Label" parent="GridContainer/BrushOpacityControl"]
+layout_mode = 2
+text = "999"
+
+[node name="FlattenHeightLabel" type="Label" parent="GridContainer"]
+layout_mode = 2
+text = "Flatten height"
+
+[node name="HB" type="HBoxContainer" parent="GridContainer"]
+layout_mode = 2
+
+[node name="FlattenHeightControl" type="SpinBox" parent="GridContainer/HB"]
+layout_mode = 2
+size_flags_horizontal = 3
+min_value = -500.0
+max_value = 500.0
+step = 0.01
+
+[node name="FlattenHeightPickButton" type="Button" parent="GridContainer/HB"]
+layout_mode = 2
+toggle_mode = true
+text = "Pick"
+
+[node name="ColorLabel" type="Label" parent="GridContainer"]
+layout_mode = 2
+text = "Color"
+
+[node name="ColorPickerButton" type="ColorPickerButton" parent="GridContainer"]
+layout_mode = 2
+toggle_mode = false
+color = Color(1, 1, 1, 1)
+
+[node name="DensityLabel" type="Label" parent="GridContainer"]
+layout_mode = 2
+text = "Detail density"
+
+[node name="DensitySlider" type="HSlider" parent="GridContainer"]
+layout_mode = 2
+max_value = 1.0
+step = 0.1
+
+[node name="HoleLabel" type="Label" parent="GridContainer"]
+layout_mode = 2
+text = "Draw holes"
+
+[node name="HoleCheckbox" type="CheckBox" parent="GridContainer"]
+layout_mode = 2
+
+[node name="SlopeLimitLabel" type="Label" parent="GridContainer"]
+layout_mode = 2
+text = "Slope limit"
+
+[node name="SlopeLimit" type="Control" parent="GridContainer"]
+layout_mode = 2
+script = ExtResource("3")
+range = Vector2(0, 90)
+
+[node name="Temp" type="Node" parent="."]
+
+[connection signal="pressed" from="BrushShapeButton" to="." method="_on_BrushShapeButton_pressed"]
+[connection signal="pressed" from="GridContainer/HB/FlattenHeightPickButton" to="." method="_on_FlattenHeightPickButton_pressed"]
diff --git a/game/addons/zylann.hterrain/tools/brush/decal.gd b/game/addons/zylann.hterrain/tools/brush/decal.gd
new file mode 100644
index 0000000..13433ed
--- /dev/null
+++ b/game/addons/zylann.hterrain/tools/brush/decal.gd
@@ -0,0 +1,121 @@
+@tool
+# Shows a cursor on top of the terrain to preview where the brush will paint
+
+# TODO Use an actual decal node, it wasn't available in Godot 3
+
+const HT_DirectMeshInstance = preload("../../util/direct_mesh_instance.gd")
+const HTerrain = preload("../../hterrain.gd")
+const HTerrainData = preload("../../hterrain_data.gd")
+const HT_Util = preload("../../util/util.gd")
+
+var _mesh_instance : HT_DirectMeshInstance
+var _mesh : PlaneMesh
+var _material = ShaderMaterial.new()
+#var _debug_mesh = CubeMesh.new()
+#var _debug_mesh_instance = null
+
+var _terrain : HTerrain = null
+
+
+func _init():
+ _material.shader = load("res://addons/zylann.hterrain/tools/brush/decal.gdshader")
+ _mesh_instance = HT_DirectMeshInstance.new()
+ _mesh_instance.set_material(_material)
+
+ _mesh = PlaneMesh.new()
+ _mesh_instance.set_mesh(_mesh)
+
+ #_debug_mesh_instance = DirectMeshInstance.new()
+ #_debug_mesh_instance.set_mesh(_debug_mesh)
+
+
+func set_size(size: float):
+ _mesh.size = Vector2(size, size)
+ # Must line up to terrain vertex policy, so must apply an off-by-one.
+ # If I don't do that, the brush will appear to wobble above the ground
+ var ss := size - 1
+ # Don't subdivide too much
+ while ss > 50:
+ ss /= 2
+ _mesh.subdivide_width = ss
+ _mesh.subdivide_depth = ss
+
+
+#func set_shape(shape_image):
+# set_size(shape_image.get_width())
+
+
+func _on_terrain_transform_changed(terrain_global_trans: Transform3D):
+ var inv = terrain_global_trans.affine_inverse()
+ _material.set_shader_parameter("u_terrain_inverse_transform", inv)
+
+ var normal_basis = terrain_global_trans.basis.inverse().transposed()
+ _material.set_shader_parameter("u_terrain_normal_basis", normal_basis)
+
+
+func set_terrain(terrain: HTerrain):
+ if _terrain == terrain:
+ return
+
+ if _terrain != null:
+ _terrain.transform_changed.disconnect(_on_terrain_transform_changed)
+ _mesh_instance.exit_world()
+ #_debug_mesh_instance.exit_world()
+
+ _terrain = terrain
+
+ if _terrain != null:
+ _terrain.transform_changed.connect(_on_terrain_transform_changed)
+ _on_terrain_transform_changed(_terrain.get_internal_transform())
+ _mesh_instance.enter_world(terrain.get_world_3d())
+ #_debug_mesh_instance.enter_world(terrain.get_world())
+
+ update_visibility()
+
+
+func set_position(p_local_pos: Vector3):
+ assert(_terrain != null)
+ assert(typeof(p_local_pos) == TYPE_VECTOR3)
+
+ # Set custom AABB (in local cells) because the decal is displaced by shader
+ var data = _terrain.get_data()
+ if data != null:
+ var r = _mesh.size / 2
+ var aabb = data.get_region_aabb( \
+ int(p_local_pos.x - r.x), \
+ int(p_local_pos.z - r.y), \
+ int(2 * r.x), \
+ int(2 * r.y))
+ aabb.position = Vector3(-r.x, aabb.position.y, -r.y)
+ _mesh.custom_aabb = aabb
+ #_debug_mesh.size = aabb.size
+
+ var trans = Transform3D(Basis(), p_local_pos)
+ var terrain_gt = _terrain.get_internal_transform()
+ trans = terrain_gt * trans
+ _mesh_instance.set_transform(trans)
+ #_debug_mesh_instance.set_transform(trans)
+
+
+# This is called very often so it should be cheap
+func update_visibility():
+ var heightmap = _get_heightmap(_terrain)
+ if heightmap == null:
+ # I do this for refcounting because heightmaps are large resources
+ _material.set_shader_parameter("u_terrain_heightmap", null)
+ _mesh_instance.set_visible(false)
+ #_debug_mesh_instance.set_visible(false)
+ else:
+ _material.set_shader_parameter("u_terrain_heightmap", heightmap)
+ _mesh_instance.set_visible(true)
+ #_debug_mesh_instance.set_visible(true)
+
+
+func _get_heightmap(terrain):
+ if terrain == null:
+ return null
+ var data = terrain.get_data()
+ if data == null:
+ return null
+ return data.get_texture(HTerrainData.CHANNEL_HEIGHT)
+
diff --git a/game/addons/zylann.hterrain/tools/brush/decal.gdshader b/game/addons/zylann.hterrain/tools/brush/decal.gdshader
new file mode 100644
index 0000000..b56f43a
--- /dev/null
+++ b/game/addons/zylann.hterrain/tools/brush/decal.gdshader
@@ -0,0 +1,41 @@
+shader_type spatial;
+render_mode unshaded;//, depth_test_disable;
+
+#include "res://addons/zylann.hterrain/shaders/include/heightmap.gdshaderinc"
+
+uniform sampler2D u_terrain_heightmap;
+uniform mat4 u_terrain_inverse_transform;
+uniform mat3 u_terrain_normal_basis;
+
+float get_height(sampler2D heightmap, vec2 uv) {
+ return sample_heightmap(heightmap, uv);
+}
+
+void vertex() {
+ vec2 cell_coords = (u_terrain_inverse_transform * MODEL_MATRIX * vec4(VERTEX, 1)).xz;
+
+ vec2 ps = vec2(1.0) / vec2(textureSize(u_terrain_heightmap, 0));
+ vec2 uv = ps * cell_coords;
+
+ // Get terrain normal
+ float k = 1.0;
+ float left = get_height(u_terrain_heightmap, uv + vec2(-ps.x, 0)) * k;
+ float right = get_height(u_terrain_heightmap, uv + vec2(ps.x, 0)) * k;
+ float back = get_height(u_terrain_heightmap, uv + vec2(0, -ps.y)) * k;
+ float fore = get_height(u_terrain_heightmap, uv + vec2(0, ps.y)) * k;
+ vec3 n = normalize(vec3(left - right, 2.0, back - fore));
+
+ n = u_terrain_normal_basis * n;
+
+ float h = get_height(u_terrain_heightmap, uv);
+ VERTEX.y = h;
+ VERTEX += 1.0 * n;
+ NORMAL = n;//vec3(0.0, 1.0, 0.0);
+}
+
+void fragment() {
+ float len = length(2.0 * UV - 1.0);
+ float g = clamp(1.0 - 15.0 * abs(0.9 - len), 0.0, 1.0);
+ ALBEDO = vec3(1.0, 0.1, 0.1);
+ ALPHA = g;
+}
diff --git a/game/addons/zylann.hterrain/tools/brush/no_blend.gdshader b/game/addons/zylann.hterrain/tools/brush/no_blend.gdshader
new file mode 100644
index 0000000..8ae0f84
--- /dev/null
+++ b/game/addons/zylann.hterrain/tools/brush/no_blend.gdshader
@@ -0,0 +1,6 @@
+shader_type canvas_item;
+render_mode blend_disabled;
+
+void fragment() {
+ COLOR = texture(TEXTURE, UV);
+}
diff --git a/game/addons/zylann.hterrain/tools/brush/no_blend_rf.gdshader b/game/addons/zylann.hterrain/tools/brush/no_blend_rf.gdshader
new file mode 100644
index 0000000..d0460de
--- /dev/null
+++ b/game/addons/zylann.hterrain/tools/brush/no_blend_rf.gdshader
@@ -0,0 +1,9 @@
+shader_type canvas_item;
+render_mode blend_disabled;
+
+#include "res://addons/zylann.hterrain/shaders/include/heightmap.gdshaderinc"
+
+void fragment() {
+ float h = sample_heightmap(TEXTURE, UV);
+ COLOR = encode_height_to_viewport(h);
+}
diff --git a/game/addons/zylann.hterrain/tools/brush/painter.gd b/game/addons/zylann.hterrain/tools/brush/painter.gd
new file mode 100644
index 0000000..fa4be98
--- /dev/null
+++ b/game/addons/zylann.hterrain/tools/brush/painter.gd
@@ -0,0 +1,399 @@
+
+# Core logic to paint a texture using shaders, with undo/redo support.
+# Operations are delayed so results are only available the next frame.
+# This doesn't implement UI or brush behavior, only rendering logic.
+#
+# Note: due to the absence of channel separation function in Image,
+# you may need to use multiple painters at once if your application exploits multiple channels.
+# Example: when painting a heightmap, it would be doable to output height in R, normalmap in GB, and
+# then separate channels in two images at the end.
+
+@tool
+extends Node
+
+const HT_Logger = preload("../../util/logger.gd")
+const HT_Util = preload("../../util/util.gd")
+const HT_NoBlendShader = preload("./no_blend.gdshader")
+const HT_NoBlendRFShader = preload("./no_blend_rf.gdshader")
+
+const UNDO_CHUNK_SIZE = 64
+
+# All painting shaders can use these common parameters
+const SHADER_PARAM_SRC_TEXTURE = "u_src_texture"
+const SHADER_PARAM_SRC_RECT = "u_src_rect"
+const SHADER_PARAM_OPACITY = "u_opacity"
+
+const _API_SHADER_PARAMS = [
+ SHADER_PARAM_SRC_TEXTURE,
+ SHADER_PARAM_SRC_RECT,
+ SHADER_PARAM_OPACITY
+]
+
+# Emitted when a region of the painted texture actually changed.
+# Note 1: the image might not have changed yet at this point.
+# Note 2: the user could still be in the middle of dragging the brush.
+signal texture_region_changed(rect)
+
+# Godot doesn't support 32-bit float rendering, so painting is limited to 16-bit depth.
+# We should get this in Godot 4.0, either as Compute or renderer improvement
+const _hdr_formats = [
+ Image.FORMAT_RH,
+ Image.FORMAT_RGH,
+ Image.FORMAT_RGBH,
+ Image.FORMAT_RGBAH
+]
+
+const _supported_formats = [
+ Image.FORMAT_R8,
+ Image.FORMAT_RG8,
+ Image.FORMAT_RGB8,
+ Image.FORMAT_RGBA8
+ # No longer supported since Godot 4 removed support for it in 2D viewports...
+# Image.FORMAT_RH,
+# Image.FORMAT_RGH,
+# Image.FORMAT_RGBH,
+# Image.FORMAT_RGBAH
+]
+
+# - SubViewport (size of edited region + margin to allow quad rotation)
+# |- Background
+# | Fills pixels with unmodified source image.
+# |- Brush sprite
+# Size of actual brush, scaled/rotated, modifies source image.
+# Assigned texture is the brush texture, src image is a shader param
+
+var _viewport : SubViewport
+var _viewport_bg_sprite : Sprite2D
+var _viewport_brush_sprite : Sprite2D
+var _brush_size := 32
+var _brush_scale := 1.0
+var _brush_position := Vector2()
+var _brush_opacity := 1.0
+var _brush_texture : Texture
+var _last_brush_position := Vector2()
+var _brush_material := ShaderMaterial.new()
+var _no_blend_material : ShaderMaterial
+var _image : Image
+var _texture : ImageTexture
+var _cmd_paint := false
+var _pending_paint_render := false
+var _modified_chunks := {}
+var _modified_shader_params := {}
+
+var _debug_display : TextureRect
+var _logger = HT_Logger.get_for(self)
+
+
+func _init():
+ _viewport = SubViewport.new()
+ _viewport.size = Vector2(_brush_size, _brush_size)
+ _viewport.render_target_update_mode = SubViewport.UPDATE_ONCE
+ _viewport.render_target_clear_mode = SubViewport.CLEAR_MODE_ONCE
+ #_viewport.hdr = false
+ # Require 4 components (RGBA)
+ _viewport.transparent_bg = true
+ # Apparently HDR doesn't work if this is set to 2D... so let's waste a depth buffer :/
+ #_viewport.usage = Viewport.USAGE_2D
+ #_viewport.keep_3d_linear
+
+ # There is no "blend_disabled" option on standard CanvasItemMaterial...
+ _no_blend_material = ShaderMaterial.new()
+ _no_blend_material.shader = HT_NoBlendShader
+ _viewport_bg_sprite = Sprite2D.new()
+ _viewport_bg_sprite.centered = false
+ _viewport_bg_sprite.material = _no_blend_material
+ _viewport.add_child(_viewport_bg_sprite)
+
+ _viewport_brush_sprite = Sprite2D.new()
+ _viewport_brush_sprite.centered = true
+ _viewport_brush_sprite.material = _brush_material
+ _viewport_brush_sprite.position = _viewport.size / 2.0
+ _viewport.add_child(_viewport_brush_sprite)
+
+ add_child(_viewport)
+
+
+func set_debug_display(dd: TextureRect):
+ _debug_display = dd
+ _debug_display.texture = _viewport.get_texture()
+
+
+func set_image(image: Image, texture: ImageTexture):
+ assert((image == null and texture == null) or (image != null and texture != null))
+ _image = image
+ _texture = texture
+ _viewport_bg_sprite.texture = _texture
+ _brush_material.set_shader_parameter(SHADER_PARAM_SRC_TEXTURE, _texture)
+ if image != null:
+ if image.get_format() == Image.FORMAT_RF:
+ # In case of RF all shaders must encode their fragment outputs in RGBA8,
+ # including the unmodified background, as Godot 4.0 does not support RF viewports
+ _no_blend_material.shader = HT_NoBlendRFShader
+ else:
+ _no_blend_material.shader = HT_NoBlendShader
+ # TODO HDR is required in order to paint heightmaps.
+ # Seems Godot 4.0 does not support it, so we have to wait for Godot 4.1...
+ #_viewport.hdr = image.get_format() in _hdr_formats
+ if (image.get_format() in _hdr_formats) and image.get_format() != Image.FORMAT_RF:
+ push_error("Godot 4.0 does not support HDR viewports for GPU-editing heightmaps! " +
+ "Only RF is supported using a bit packing hack.")
+ #print("PAINTER VIEWPORT HDR: ", _viewport.hdr)
+
+
+# Sets the size of the brush in pixels.
+# This will cause the internal viewport to resize, which is expensive.
+# If you need to frequently change brush size during a paint stroke, prefer using scale instead.
+func set_brush_size(new_size: int):
+ _brush_size = new_size
+
+
+func get_brush_size() -> int:
+ return _brush_size
+
+
+func set_brush_rotation(rotation: float):
+ _viewport_brush_sprite.rotation = rotation
+
+
+func get_brush_rotation() -> float:
+ return _viewport_bg_sprite.rotation
+
+
+# The difference between size and scale, is that size is in pixels, while scale is a multiplier.
+# Scale is also a lot cheaper to change, so you may prefer changing it instead of size if that
+# happens often during a painting stroke.
+func set_brush_scale(s: float):
+ _brush_scale = clampf(s, 0.0, 1.0)
+ #_viewport_brush_sprite.scale = Vector2(s, s)
+
+
+func get_brush_scale() -> float:
+ return _viewport_bg_sprite.scale.x
+
+
+func set_brush_opacity(opacity: float):
+ _brush_opacity = clampf(opacity, 0.0, 1.0)
+
+
+func get_brush_opacity() -> float:
+ return _brush_opacity
+
+
+func set_brush_texture(texture: Texture):
+ _viewport_brush_sprite.texture = texture
+
+
+func set_brush_shader(shader: Shader):
+ if _brush_material.shader != shader:
+ _brush_material.shader = shader
+
+
+func set_brush_shader_param(p: String, v):
+ assert(not _API_SHADER_PARAMS.has(p))
+ _modified_shader_params[p] = true
+ _brush_material.set_shader_parameter(p, v)
+
+
+func clear_brush_shader_params():
+ for key in _modified_shader_params:
+ _brush_material.set_shader_parameter(key, null)
+ _modified_shader_params.clear()
+
+
+# If we want to be able to rotate the brush quad every frame,
+# we must prepare a bigger viewport otherwise the quad will not fit inside
+static func _get_size_fit_for_rotation(src_size: Vector2) -> Vector2i:
+ var d = int(ceilf(src_size.length()))
+ return Vector2i(d, d)
+
+
+# You must call this from an `_input` function or similar.
+func paint_input(center_pos: Vector2):
+ var vp_size := _get_size_fit_for_rotation(Vector2(_brush_size, _brush_size))
+ if _viewport.size != vp_size:
+ # Do this lazily so the brush slider won't lag while adjusting it
+ # TODO An "sliding_ended" handling might produce better user experience
+ _viewport.size = vp_size
+ _viewport_brush_sprite.position = _viewport.size / 2.0
+
+ # Need to floor the position in case the brush has an odd size
+ var brush_pos := (center_pos - _viewport.size * 0.5).round()
+ _viewport.render_target_update_mode = SubViewport.UPDATE_ONCE
+ _viewport.render_target_clear_mode = SubViewport.CLEAR_MODE_ONCE
+ _viewport_bg_sprite.position = -brush_pos
+ _brush_position = brush_pos
+ _cmd_paint = true
+
+ # We want this quad to have a specific size, regardless of the texture assigned to it
+ _viewport_brush_sprite.scale = \
+ _brush_scale * Vector2(_brush_size, _brush_size) \
+ / Vector2(_viewport_brush_sprite.texture.get_size())
+
+ # Using a Color because Godot doesn't understand vec4
+ var rect := Color()
+ rect.r = brush_pos.x / _texture.get_width()
+ rect.g = brush_pos.y / _texture.get_height()
+ rect.b = float(_viewport.size.x) / float(_texture.get_width())
+ rect.a = float(_viewport.size.y) / float(_texture.get_height())
+ # In order to make sure that u_brush_rect is never bigger than the brush:
+ # 1. we ceil() the result of lower-left corner
+ # 2. we floor() the result of upper-right corner
+ # and then rederive width and height from the result
+# var half_brush:Vector2 = Vector2(_brush_size, _brush_size) / 2
+# var brush_LL := (center_pos - half_brush).ceil()
+# var brush_UR := (center_pos + half_brush).floor()
+# rect.r = brush_LL.x / _texture.get_width()
+# rect.g = brush_LL.y / _texture.get_height()
+# rect.b = (brush_UR.x - brush_LL.x) / _texture.get_width()
+# rect.a = (brush_UR.y - brush_LL.y) / _texture.get_height()
+ _brush_material.set_shader_parameter(SHADER_PARAM_SRC_RECT, rect)
+ _brush_material.set_shader_parameter(SHADER_PARAM_OPACITY, _brush_opacity)
+
+
+# Don't commit until this is false
+func is_operation_pending() -> bool:
+ return _pending_paint_render or _cmd_paint
+
+
+# Applies changes to the Image, and returns modified chunks for UndoRedo.
+func commit() -> Dictionary:
+ if is_operation_pending():
+ _logger.error("Painter commit() was called while an operation is still pending")
+ return _commit_modified_chunks()
+
+
+func has_modified_chunks() -> bool:
+ return len(_modified_chunks) > 0
+
+
+func _process(delta: float):
+ if _pending_paint_render:
+ _pending_paint_render = false
+
+ #print("Paint result at frame ", Engine.get_frames_drawn())
+ var viewport_image := _viewport.get_texture().get_image()
+
+ if _image.get_format() == Image.FORMAT_RF:
+ # Reinterpret RGBA8 as RF. This assumes painting shaders encode the output properly.
+ assert(viewport_image.get_format() == Image.FORMAT_RGBA8)
+ viewport_image = Image.create_from_data(
+ viewport_image.get_width(), viewport_image.get_height(), false, Image.FORMAT_RF,
+ viewport_image.get_data())
+ else:
+ viewport_image.convert(_image.get_format())
+
+ var brush_pos := _last_brush_position
+
+ var dst_x : int = clamp(brush_pos.x, 0, _texture.get_width())
+ var dst_y : int = clamp(brush_pos.y, 0, _texture.get_height())
+
+ var src_x : int = maxf(-brush_pos.x, 0)
+ var src_y : int = maxf(-brush_pos.y, 0)
+ var src_w : int = minf(maxf(_viewport.size.x - src_x, 0), _texture.get_width() - dst_x)
+ var src_h : int = minf(maxf(_viewport.size.y - src_y, 0), _texture.get_height() - dst_y)
+
+ if src_w != 0 and src_h != 0:
+ _mark_modified_chunks(dst_x, dst_y, src_w, src_h)
+ HT_Util.update_texture_partial(_texture, viewport_image,
+ Rect2i(src_x, src_y, src_w, src_h), Vector2i(dst_x, dst_y))
+ texture_region_changed.emit(Rect2(dst_x, dst_y, src_w, src_h))
+
+ # Input is handled just before process, so we still have to wait till next frame
+ if _cmd_paint:
+ _pending_paint_render = true
+ _last_brush_position = _brush_position
+ # Consume input
+ _cmd_paint = false
+
+
+func _mark_modified_chunks(bx: int, by: int, bw: int, bh: int):
+ var cs := UNDO_CHUNK_SIZE
+
+ var cmin_x := bx / cs
+ var cmin_y := by / cs
+ var cmax_x := (bx + bw - 1) / cs + 1
+ var cmax_y := (by + bh - 1) / cs + 1
+
+ for cy in range(cmin_y, cmax_y):
+ for cx in range(cmin_x, cmax_x):
+ #print("Marking chunk ", Vector2(cx, cy))
+ _modified_chunks[Vector2(cx, cy)] = true
+
+
+func _commit_modified_chunks() -> Dictionary:
+ var time_before := Time.get_ticks_msec()
+
+ var cs := UNDO_CHUNK_SIZE
+ var chunks_positions := []
+ var chunks_initial_data := []
+ var chunks_final_data := []
+
+ #_logger.debug("About to commit ", len(_modified_chunks), " chunks")
+
+ # TODO get_data_partial() would be nice...
+ var final_image := _texture.get_image()
+ for cpos in _modified_chunks:
+ var cx : int = cpos.x
+ var cy : int = cpos.y
+
+ var x := cx * cs
+ var y := cy * cs
+ var w : int = mini(cs, _image.get_width() - x)
+ var h : int = mini(cs, _image.get_height() - y)
+
+ var rect := Rect2i(x, y, w, h)
+ var initial_data := _image.get_region(rect)
+ var final_data := final_image.get_region(rect)
+
+ chunks_positions.append(cpos)
+ chunks_initial_data.append(initial_data)
+ chunks_final_data.append(final_data)
+ #_image_equals(initial_data, final_data)
+
+ # TODO We could also just replace the image with `final_image`...
+ # TODO Use `final_data` instead?
+ _image.blit_rect(final_image, rect, rect.position)
+
+ _modified_chunks.clear()
+
+ var time_spent := Time.get_ticks_msec() - time_before
+ _logger.debug("Spent {0} ms to commit paint operation".format([time_spent]))
+
+ return {
+ "chunk_positions": chunks_positions,
+ "chunk_initial_datas": chunks_initial_data,
+ "chunk_final_datas": chunks_final_data
+ }
+
+
+# DEBUG
+#func _input(event):
+# if event is InputEventKey:
+# if event.pressed:
+# if event.control and event.scancode == KEY_SPACE:
+# print("Saving painter viewport ", name)
+# var im = _viewport.get_texture().get_data()
+# im.convert(Image.FORMAT_RGBA8)
+# im.save_png(str("test_painter_viewport_", name, ".png"))
+
+
+#static func _image_equals(im_a: Image, im_b: Image) -> bool:
+# if im_a.get_size() != im_b.get_size():
+# print("Diff size: ", im_a.get_size, ", ", im_b.get_size())
+# return false
+# if im_a.get_format() != im_b.get_format():
+# print("Diff format: ", im_a.get_format(), ", ", im_b.get_format())
+# return false
+# im_a.lock()
+# im_b.lock()
+# for y in im_a.get_height():
+# for x in im_a.get_width():
+# var ca = im_a.get_pixel(x, y)
+# var cb = im_b.get_pixel(x, y)
+# if ca != cb:
+# print("Diff pixel ", x, ", ", y)
+# return false
+# im_a.unlock()
+# im_b.unlock()
+# print("SAME")
+# return true
diff --git a/game/addons/zylann.hterrain/tools/brush/settings_dialog/brush_settings_dialog.gd b/game/addons/zylann.hterrain/tools/brush/settings_dialog/brush_settings_dialog.gd
new file mode 100644
index 0000000..71f81d1
--- /dev/null
+++ b/game/addons/zylann.hterrain/tools/brush/settings_dialog/brush_settings_dialog.gd
@@ -0,0 +1,280 @@
+@tool
+extends AcceptDialog
+
+const HT_Util = preload("../../../util/util.gd")
+const HT_Brush = preload("../brush.gd")
+const HT_Logger = preload("../../../util/logger.gd")
+const HT_EditorUtil = preload("../../util/editor_util.gd")
+const HT_SpinSlider = preload("../../util/spin_slider.gd")
+const HT_Scratchpad = preload("./preview_scratchpad.gd")
+
+@onready var _scratchpad : HT_Scratchpad = $VB/HB/VB3/PreviewScratchpad
+
+@onready var _shape_list : ItemList = $VB/HB/VB/ShapeList
+@onready var _remove_shape_button : Button = $VB/HB/VB/HBoxContainer/RemoveShape
+@onready var _change_shape_button : Button = $VB/HB/VB/ChangeShape
+
+@onready var _size_slider : HT_SpinSlider = $VB/HB/VB2/Settings/Size
+@onready var _opacity_slider : HT_SpinSlider = $VB/HB/VB2/Settings/Opacity
+@onready var _pressure_enabled_checkbox : CheckBox = $VB/HB/VB2/Settings/PressureEnabled
+@onready var _pressure_over_size_slider : HT_SpinSlider = $VB/HB/VB2/Settings/PressureOverSize
+@onready var _pressure_over_opacity_slider : HT_SpinSlider = $VB/HB/VB2/Settings/PressureOverOpacity
+@onready var _frequency_distance_slider : HT_SpinSlider = $VB/HB/VB2/Settings/FrequencyDistance
+@onready var _frequency_time_slider : HT_SpinSlider = $VB/HB/VB2/Settings/FrequencyTime
+@onready var _random_rotation_checkbox : CheckBox = $VB/HB/VB2/Settings/RandomRotation
+@onready var _shape_cycling_checkbox : CheckBox = $VB/HB/VB2/Settings/ShapeCycling
+
+var _brush : HT_Brush
+# This is a `EditorFileDialog`,
+# but cannot type it because I want to be able to test it by running the scene.
+# And when I run it, Godot does not allow to use `EditorFileDialog`.
+var _load_image_dialog
+# -1 means add, otherwise replace
+var _load_image_index := -1
+var _logger = HT_Logger.get_for(self)
+
+
+func _ready():
+ if HT_Util.is_in_edited_scene(self):
+ return
+
+ _size_slider.set_max_value(HT_Brush.MAX_SIZE_FOR_SLIDERS)
+ _size_slider.set_greater_max_value(HT_Brush.MAX_SIZE)
+
+ # TESTING
+ if not Engine.is_editor_hint():
+ setup_dialogs(self)
+ call_deferred("popup")
+
+
+func set_brush(brush : HT_Brush):
+ assert(brush != null)
+ _brush = brush
+ _update_controls_from_brush()
+
+
+# `base_control` can no longer be hinted as a `Control` because in Godot 4 it could be a
+# window or dialog, which are no longer controls...
+func setup_dialogs(base_control: Node):
+ assert(_load_image_dialog == null)
+ _load_image_dialog = HT_EditorUtil.create_open_file_dialog()
+ _load_image_dialog.file_mode = EditorFileDialog.FILE_MODE_OPEN_FILE
+ _load_image_dialog.add_filter("*.exr ; EXR files")
+ _load_image_dialog.unresizable = false
+ _load_image_dialog.access = EditorFileDialog.ACCESS_FILESYSTEM
+ _load_image_dialog.current_dir = HT_Brush.SHAPES_DIR
+ _load_image_dialog.file_selected.connect(_on_LoadImageDialog_file_selected)
+ _load_image_dialog.files_selected.connect(_on_LoadImageDialog_files_selected)
+ #base_control.add_child(_load_image_dialog)
+ # When a dialog opens another dialog, we get this error:
+ # "Transient parent has another exclusive child."
+ # Which is worked around by making the other dialog a child of the first one (I don't know why)
+ add_child(_load_image_dialog)
+
+
+func _exit_tree():
+ if _load_image_dialog != null:
+ _load_image_dialog.queue_free()
+ _load_image_dialog = null
+
+
+func _get_shapes_from_gui() -> Array[Texture2D]:
+ var shapes : Array[Texture2D] = []
+ for i in _shape_list.get_item_count():
+ var icon : Texture2D = _shape_list.get_item_icon(i)
+ assert(icon != null)
+ shapes.append(icon)
+ return shapes
+
+
+func _update_shapes_gui(shapes: Array[Texture2D]):
+ _shape_list.clear()
+ for shape in shapes:
+ assert(shape != null)
+ assert(shape is Texture2D)
+ _shape_list.add_icon_item(shape)
+ _update_shape_list_buttons()
+
+
+func _on_AddShape_pressed():
+ _load_image_index = -1
+ _load_image_dialog.file_mode = EditorFileDialog.FILE_MODE_OPEN_FILES
+ _load_image_dialog.popup_centered_ratio(0.7)
+
+
+func _on_RemoveShape_pressed():
+ var selected_indices := _shape_list.get_selected_items()
+ if len(selected_indices) == 0:
+ return
+
+ var index : int = selected_indices[0]
+ _shape_list.remove_item(index)
+
+ var shapes := _get_shapes_from_gui()
+ for brush in _get_brushes():
+ brush.set_shapes(shapes)
+
+ _update_shape_list_buttons()
+
+
+func _on_ShapeList_item_activated(index: int):
+ _request_modify_shape(index)
+
+
+func _on_ChangeShape_pressed():
+ var selected = _shape_list.get_selected_items()
+ if len(selected) == 0:
+ return
+ _request_modify_shape(selected[0])
+
+
+func _request_modify_shape(index: int):
+ _load_image_index = index
+ _load_image_dialog.file_mode = EditorFileDialog.FILE_MODE_OPEN_FILE
+ _load_image_dialog.popup_centered_ratio(0.7)
+
+
+func _on_LoadImageDialog_files_selected(fpaths: PackedStringArray):
+ var shapes := _get_shapes_from_gui()
+
+ for fpath in fpaths:
+ var tex := HT_Brush.load_shape_from_image_file(fpath, _logger)
+ if tex == null:
+ # Failed
+ continue
+ shapes.append(tex)
+
+ for brush in _get_brushes():
+ brush.set_shapes(shapes)
+
+ _update_shapes_gui(shapes)
+
+
+func _on_LoadImageDialog_file_selected(fpath: String):
+ var tex := HT_Brush.load_shape_from_image_file(fpath, _logger)
+ if tex == null:
+ # Failed
+ return
+
+ var shapes := _get_shapes_from_gui()
+ if _load_image_index == -1 or _load_image_index >= len(shapes):
+ # Add
+ shapes.append(tex)
+ else:
+ # Replace
+ assert(_load_image_index >= 0)
+ shapes[_load_image_index] = tex
+
+ for brush in _get_brushes():
+ brush.set_shapes(shapes)
+
+ _update_shapes_gui(shapes)
+
+
+func _notification(what: int):
+ if what == NOTIFICATION_VISIBILITY_CHANGED:
+ # Testing the scratchpad because visibility can not only change before entering the tree
+ # since Godot 4 port, it can also change between entering the tree and being _ready...
+ if visible and _scratchpad != null:
+ _update_controls_from_brush()
+
+
+func _update_controls_from_brush():
+ var brush := _brush
+
+ if brush == null:
+ # To allow testing
+ brush = _scratchpad.get_painter().get_brush()
+
+ _update_shapes_gui(brush.get_shapes())
+
+ _size_slider.set_value(brush.get_size(), false)
+ _opacity_slider.set_value(brush.get_opacity() * 100.0, false)
+ _pressure_enabled_checkbox.button_pressed = brush.is_pressure_enabled()
+ _pressure_over_size_slider.set_value(brush.get_pressure_over_scale() * 100.0, false)
+ _pressure_over_opacity_slider.set_value(brush.get_pressure_over_opacity() * 100.0, false)
+ _frequency_distance_slider.set_value(brush.get_frequency_distance(), false)
+ _frequency_time_slider.set_value(
+ 1000.0 / maxf(0.1, float(brush.get_frequency_time_ms())), false)
+ _random_rotation_checkbox.button_pressed = brush.is_random_rotation_enabled()
+ _shape_cycling_checkbox.button_pressed = brush.is_shape_cycling_enabled()
+
+
+func _on_ClearScratchpad_pressed():
+ _scratchpad.reset_image()
+
+
+func _on_Size_value_changed(value: float):
+ for brush in _get_brushes():
+ brush.set_size(value)
+
+
+func _on_Opacity_value_changed(value):
+ for brush in _get_brushes():
+ brush.set_opacity(value / 100.0)
+
+
+func _on_PressureEnabled_toggled(button_pressed):
+ for brush in _get_brushes():
+ brush.set_pressure_enabled(button_pressed)
+
+
+func _on_PressureOverSize_value_changed(value):
+ for brush in _get_brushes():
+ brush.set_pressure_over_scale(value / 100.0)
+
+
+func _on_PressureOverOpacity_value_changed(value):
+ for brush in _get_brushes():
+ brush.set_pressure_over_opacity(value / 100.0)
+
+
+func _on_FrequencyDistance_value_changed(value):
+ for brush in _get_brushes():
+ brush.set_frequency_distance(value)
+
+
+func _on_FrequencyTime_value_changed(fps):
+ fps = max(1.0, fps)
+ var ms = 1000.0 / fps
+ if is_equal_approx(fps, 60.0):
+ ms = 0
+ for brush in _get_brushes():
+ brush.set_frequency_time_ms(ms)
+
+
+func _on_RandomRotation_toggled(button_pressed: bool):
+ for brush in _get_brushes():
+ brush.set_random_rotation_enabled(button_pressed)
+
+
+func _on_shape_cycling_toggled(button_pressed: bool):
+ for brush in _get_brushes():
+ brush.set_shape_cycling_enabled(button_pressed)
+
+
+func _get_brushes() -> Array[HT_Brush]:
+ if _brush != null:
+ # We edit both the preview brush and the terrain brush
+ # TODO Could we simply share the brush?
+ return [_brush, _scratchpad.get_painter().get_brush()]
+ # When testing the dialog in isolation, the edited brush might be null
+ return [_scratchpad.get_painter().get_brush()]
+
+
+func _on_ShapeList_item_selected(index):
+ _update_shape_list_buttons()
+ for brush in _get_brushes():
+ brush.set_shape_index(index)
+
+
+func _update_shape_list_buttons():
+ var selected_count := len(_shape_list.get_selected_items())
+ # There must be at least one shape
+ _remove_shape_button.disabled = _shape_list.get_item_count() == 1 or selected_count == 0
+ _change_shape_button.disabled = selected_count == 0
+
+
+func _on_shape_list_empty_clicked(at_position, mouse_button_index):
+ _update_shape_list_buttons()
+
diff --git a/game/addons/zylann.hterrain/tools/brush/settings_dialog/brush_settings_dialog.tscn b/game/addons/zylann.hterrain/tools/brush/settings_dialog/brush_settings_dialog.tscn
new file mode 100644
index 0000000..7d7e07d
--- /dev/null
+++ b/game/addons/zylann.hterrain/tools/brush/settings_dialog/brush_settings_dialog.tscn
@@ -0,0 +1,211 @@
+[gd_scene load_steps=4 format=3 uid="uid://d2rt3wj8xkhp2"]
+
+[ext_resource type="PackedScene" path="res://addons/zylann.hterrain/tools/util/spin_slider.tscn" id="2"]
+[ext_resource type="Script" path="res://addons/zylann.hterrain/tools/brush/settings_dialog/brush_settings_dialog.gd" id="3"]
+[ext_resource type="PackedScene" uid="uid://ng00jipfeucy" path="res://addons/zylann.hterrain/tools/brush/settings_dialog/preview_scratchpad.tscn" id="4"]
+
+[node name="BrushSettingsDialog" type="AcceptDialog"]
+title = "Brush settings"
+size = Vector2i(700, 422)
+min_size = Vector2i(700, 400)
+script = ExtResource("3")
+
+[node name="VB" type="VBoxContainer" parent="."]
+anchors_preset = 15
+anchor_right = 1.0
+anchor_bottom = 1.0
+offset_left = 8.0
+offset_top = 8.0
+offset_right = -8.0
+offset_bottom = -49.0
+
+[node name="HB" type="HBoxContainer" parent="VB"]
+layout_mode = 2
+size_flags_vertical = 3
+
+[node name="VB" type="VBoxContainer" parent="VB/HB"]
+layout_mode = 2
+size_flags_vertical = 3
+
+[node name="Label" type="Label" parent="VB/HB/VB"]
+layout_mode = 2
+text = "Shapes"
+
+[node name="ShapeList" type="ItemList" parent="VB/HB/VB"]
+layout_mode = 2
+size_flags_vertical = 3
+fixed_icon_size = Vector2i(100, 100)
+
+[node name="ChangeShape" type="Button" parent="VB/HB/VB"]
+layout_mode = 2
+disabled = true
+text = "Change..."
+
+[node name="HBoxContainer" type="HBoxContainer" parent="VB/HB/VB"]
+layout_mode = 2
+
+[node name="AddShape" type="Button" parent="VB/HB/VB/HBoxContainer"]
+layout_mode = 2
+text = "Add..."
+
+[node name="RemoveShape" type="Button" parent="VB/HB/VB/HBoxContainer"]
+layout_mode = 2
+disabled = true
+text = "Remove"
+
+[node name="VB2" type="VBoxContainer" parent="VB/HB"]
+layout_mode = 2
+size_flags_horizontal = 3
+
+[node name="Label" type="Label" parent="VB/HB/VB2"]
+layout_mode = 2
+
+[node name="Settings" type="VBoxContainer" parent="VB/HB/VB2"]
+layout_mode = 2
+size_flags_horizontal = 3
+
+[node name="Size" parent="VB/HB/VB2/Settings" instance=ExtResource("2")]
+custom_minimum_size = Vector2(32, 28)
+layout_mode = 2
+size_flags_horizontal = 3
+value = 32.0
+min_value = 2.0
+max_value = 500.0
+prefix = "Size:"
+suffix = "px"
+rounded = true
+centered = true
+allow_greater = true
+greater_max_value = 4000.0
+
+[node name="Opacity" parent="VB/HB/VB2/Settings" instance=ExtResource("2")]
+custom_minimum_size = Vector2(32, 28)
+layout_mode = 2
+size_flags_horizontal = 3
+value = 100.0
+max_value = 100.0
+prefix = "Opacity"
+suffix = "%"
+rounded = true
+centered = true
+greater_max_value = 10000.0
+
+[node name="PressureEnabled" type="CheckBox" parent="VB/HB/VB2/Settings"]
+layout_mode = 2
+text = "Enable pressure (pen tablets)"
+
+[node name="PressureOverSize" parent="VB/HB/VB2/Settings" instance=ExtResource("2")]
+custom_minimum_size = Vector2(32, 28)
+layout_mode = 2
+value = 50.0
+max_value = 100.0
+prefix = "Pressure affects size:"
+suffix = "%"
+centered = true
+greater_max_value = 10000.0
+
+[node name="PressureOverOpacity" parent="VB/HB/VB2/Settings" instance=ExtResource("2")]
+custom_minimum_size = Vector2(32, 28)
+layout_mode = 2
+value = 50.0
+max_value = 100.0
+prefix = "Pressure affects opacity:"
+suffix = "%"
+centered = true
+greater_max_value = 10000.0
+
+[node name="FrequencyTime" parent="VB/HB/VB2/Settings" instance=ExtResource("2")]
+custom_minimum_size = Vector2(32, 28)
+layout_mode = 2
+value = 60.0
+min_value = 1.0
+max_value = 60.0
+prefix = "Frequency time:"
+suffix = "fps"
+centered = true
+greater_max_value = 10000.0
+
+[node name="FrequencyDistance" parent="VB/HB/VB2/Settings" instance=ExtResource("2")]
+custom_minimum_size = Vector2(32, 28)
+layout_mode = 2
+max_value = 100.0
+prefix = "Frequency distance:"
+suffix = "px"
+centered = true
+greater_max_value = 4000.0
+
+[node name="RandomRotation" type="CheckBox" parent="VB/HB/VB2/Settings"]
+layout_mode = 2
+text = "Random rotation"
+
+[node name="ShapeCycling" type="CheckBox" parent="VB/HB/VB2/Settings"]
+layout_mode = 2
+text = "Shape cycling"
+
+[node name="HSeparator" type="HSeparator" parent="VB/HB/VB2/Settings"]
+visible = false
+layout_mode = 2
+
+[node name="SizeLimitHB" type="HBoxContainer" parent="VB/HB/VB2/Settings"]
+visible = false
+layout_mode = 2
+
+[node name="Label" type="Label" parent="VB/HB/VB2/Settings/SizeLimitHB"]
+layout_mode = 2
+mouse_filter = 0
+text = "Size limit:"
+
+[node name="SizeLimit" type="SpinBox" parent="VB/HB/VB2/Settings/SizeLimitHB"]
+layout_mode = 2
+size_flags_horizontal = 3
+min_value = 1.0
+max_value = 1000.0
+value = 200.0
+
+[node name="HSeparator2" type="HSeparator" parent="VB/HB/VB2/Settings"]
+visible = false
+layout_mode = 2
+
+[node name="HB" type="HBoxContainer" parent="VB/HB/VB2/Settings"]
+visible = false
+layout_mode = 2
+
+[node name="Button" type="Button" parent="VB/HB/VB2/Settings/HB"]
+layout_mode = 2
+text = "Load preset..."
+
+[node name="Button2" type="Button" parent="VB/HB/VB2/Settings/HB"]
+layout_mode = 2
+text = "Save preset..."
+
+[node name="VB3" type="VBoxContainer" parent="VB/HB"]
+layout_mode = 2
+
+[node name="Label" type="Label" parent="VB/HB/VB3"]
+layout_mode = 2
+text = "Scratchpad"
+
+[node name="PreviewScratchpad" parent="VB/HB/VB3" instance=ExtResource("4")]
+custom_minimum_size = Vector2(200, 300)
+layout_mode = 2
+
+[node name="ClearScratchpad" type="Button" parent="VB/HB/VB3"]
+layout_mode = 2
+text = "Clear"
+
+[connection signal="empty_clicked" from="VB/HB/VB/ShapeList" to="." method="_on_shape_list_empty_clicked"]
+[connection signal="item_activated" from="VB/HB/VB/ShapeList" to="." method="_on_ShapeList_item_activated"]
+[connection signal="item_selected" from="VB/HB/VB/ShapeList" to="." method="_on_ShapeList_item_selected"]
+[connection signal="pressed" from="VB/HB/VB/ChangeShape" to="." method="_on_ChangeShape_pressed"]
+[connection signal="pressed" from="VB/HB/VB/HBoxContainer/AddShape" to="." method="_on_AddShape_pressed"]
+[connection signal="pressed" from="VB/HB/VB/HBoxContainer/RemoveShape" to="." method="_on_RemoveShape_pressed"]
+[connection signal="value_changed" from="VB/HB/VB2/Settings/Size" to="." method="_on_Size_value_changed"]
+[connection signal="value_changed" from="VB/HB/VB2/Settings/Opacity" to="." method="_on_Opacity_value_changed"]
+[connection signal="toggled" from="VB/HB/VB2/Settings/PressureEnabled" to="." method="_on_PressureEnabled_toggled"]
+[connection signal="value_changed" from="VB/HB/VB2/Settings/PressureOverSize" to="." method="_on_PressureOverSize_value_changed"]
+[connection signal="value_changed" from="VB/HB/VB2/Settings/PressureOverOpacity" to="." method="_on_PressureOverOpacity_value_changed"]
+[connection signal="value_changed" from="VB/HB/VB2/Settings/FrequencyTime" to="." method="_on_FrequencyTime_value_changed"]
+[connection signal="value_changed" from="VB/HB/VB2/Settings/FrequencyDistance" to="." method="_on_FrequencyDistance_value_changed"]
+[connection signal="toggled" from="VB/HB/VB2/Settings/RandomRotation" to="." method="_on_RandomRotation_toggled"]
+[connection signal="toggled" from="VB/HB/VB2/Settings/ShapeCycling" to="." method="_on_shape_cycling_toggled"]
+[connection signal="pressed" from="VB/HB/VB3/ClearScratchpad" to="." method="_on_ClearScratchpad_pressed"]
diff --git a/game/addons/zylann.hterrain/tools/brush/settings_dialog/preview_painter.gd b/game/addons/zylann.hterrain/tools/brush/settings_dialog/preview_painter.gd
new file mode 100644
index 0000000..be52072
--- /dev/null
+++ b/game/addons/zylann.hterrain/tools/brush/settings_dialog/preview_painter.gd
@@ -0,0 +1,41 @@
+@tool
+extends Node
+
+const HT_Painter = preload("./../painter.gd")
+const HT_Brush = preload("./../brush.gd")
+
+const HT_ColorShader = preload("../shaders/color.gdshader")
+
+var _painter : HT_Painter
+var _brush : HT_Brush
+
+
+func _init():
+ var p = HT_Painter.new()
+ # The name is just for debugging
+ p.set_name("Painter")
+ add_child(p)
+ _painter = p
+
+ _brush = HT_Brush.new()
+
+
+func set_image_texture(image: Image, texture: ImageTexture):
+ _painter.set_image(image, texture)
+
+
+func get_brush() -> HT_Brush:
+ return _brush
+
+
+# This may be called from an `_input` callback
+func paint_input(position: Vector2, pressure: float):
+ var p : HT_Painter = _painter
+
+ if not _brush.configure_paint_input([p], position, pressure):
+ return
+
+ p.set_brush_shader(HT_ColorShader)
+ p.set_brush_shader_param("u_color", Color(0,0,0,1))
+ #p.set_image(_image, _texture)
+ p.paint_input(position)
diff --git a/game/addons/zylann.hterrain/tools/brush/settings_dialog/preview_scratchpad.gd b/game/addons/zylann.hterrain/tools/brush/settings_dialog/preview_scratchpad.gd
new file mode 100644
index 0000000..cec0728
--- /dev/null
+++ b/game/addons/zylann.hterrain/tools/brush/settings_dialog/preview_scratchpad.gd
@@ -0,0 +1,70 @@
+@tool
+extends Control
+
+const HT_PreviewPainter = preload("./preview_painter.gd")
+# TODO Can't preload because it causes the plugin to fail loading if assets aren't imported
+#const HT_DefaultBrushTexture = preload("../shapes/round2.exr")
+const HT_Brush = preload("../brush.gd")
+const HT_Logger = preload("../../../util/logger.gd")
+const HT_EditorUtil = preload("../../util/editor_util.gd")
+const HT_Util = preload("../../../util/util.gd")
+
+@onready var _texture_rect : TextureRect = $TextureRect
+@onready var _painter : HT_PreviewPainter = $Painter
+
+var _logger := HT_Logger.get_for(self)
+
+
+func _ready():
+ if HT_Util.is_in_edited_scene(self):
+ # If it runs in the edited scene,
+ # saving the scene would also save the ImageTexture in it...
+ return
+ reset_image()
+ # Default so it doesn't crash when painting and can be tested
+ var default_brush_texture = \
+ HT_EditorUtil.load_texture(HT_Brush.DEFAULT_BRUSH_TEXTURE_PATH, _logger)
+ _painter.get_brush().set_shapes([default_brush_texture])
+
+
+func reset_image():
+ var image = Image.create(_texture_rect.size.x, _texture_rect.size.y, false, Image.FORMAT_RGB8)
+ image.fill(Color(1,1,1))
+
+ # TEST
+# var fnl = FastNoiseLite.new()
+# for y in image.get_height():
+# for x in image.get_width():
+# var g = 0.5 + 0.5 * fnl.get_noise_2d(x, y)
+# image.set_pixel(x, y, Color(g, g, g, 1.0))
+
+ var texture = ImageTexture.create_from_image(image)
+ _texture_rect.texture = texture
+ _painter.set_image_texture(image, texture)
+
+
+func get_painter() -> HT_PreviewPainter:
+ return _painter
+
+
+func _gui_input(event):
+ if event is InputEventMouseMotion:
+ if Input.is_mouse_button_pressed(MOUSE_BUTTON_LEFT):
+ _painter.paint_input(event.position, event.pressure)
+ queue_redraw()
+
+ elif event is InputEventMouseButton:
+ if event.button_index == MOUSE_BUTTON_LEFT:
+ if event.pressed:
+ # TODO `pressure` is not available on button events
+ # So I have to assume zero... which means clicks do not paint anything?
+ _painter.paint_input(event.position, 0.0)
+ else:
+ _painter.get_brush().on_paint_end()
+
+
+func _draw():
+ var mpos = get_local_mouse_position()
+ var brush = _painter.get_brush()
+ draw_arc(mpos, 0.5 * brush.get_size(), -PI, PI, 32, Color(1, 0.2, 0.2), 2.0, true)
+
diff --git a/game/addons/zylann.hterrain/tools/brush/settings_dialog/preview_scratchpad.tscn b/game/addons/zylann.hterrain/tools/brush/settings_dialog/preview_scratchpad.tscn
new file mode 100644
index 0000000..0b50c91
--- /dev/null
+++ b/game/addons/zylann.hterrain/tools/brush/settings_dialog/preview_scratchpad.tscn
@@ -0,0 +1,22 @@
+[gd_scene load_steps=3 format=3 uid="uid://ng00jipfeucy"]
+
+[ext_resource type="Script" path="res://addons/zylann.hterrain/tools/brush/settings_dialog/preview_scratchpad.gd" id="1"]
+[ext_resource type="Script" path="res://addons/zylann.hterrain/tools/brush/settings_dialog/preview_painter.gd" id="2"]
+
+[node name="PreviewScratchpad" type="Control"]
+clip_contents = true
+layout_mode = 3
+anchors_preset = 0
+offset_right = 200.0
+offset_bottom = 274.0
+script = ExtResource("1")
+
+[node name="Painter" type="Node" parent="."]
+script = ExtResource("2")
+
+[node name="TextureRect" type="TextureRect" parent="."]
+show_behind_parent = true
+layout_mode = 0
+anchor_right = 1.0
+anchor_bottom = 1.0
+stretch_mode = 5
diff --git a/game/addons/zylann.hterrain/tools/brush/shaders/alpha.gdshader b/game/addons/zylann.hterrain/tools/brush/shaders/alpha.gdshader
new file mode 100644
index 0000000..f8a05c5
--- /dev/null
+++ b/game/addons/zylann.hterrain/tools/brush/shaders/alpha.gdshader
@@ -0,0 +1,19 @@
+shader_type canvas_item;
+render_mode blend_disabled;
+
+uniform sampler2D u_src_texture;
+uniform vec4 u_src_rect;
+uniform float u_opacity = 1.0;
+uniform float u_value = 1.0;
+
+vec2 get_src_uv(vec2 screen_uv) {
+ vec2 uv = u_src_rect.xy + screen_uv * u_src_rect.zw;
+ return uv;
+}
+
+void fragment() {
+ float brush_value = u_opacity * texture(TEXTURE, UV).r;
+
+ vec4 src = texture(u_src_texture, get_src_uv(SCREEN_UV));
+ COLOR = vec4(src.rgb, mix(src.a, u_value, brush_value));
+}
diff --git a/game/addons/zylann.hterrain/tools/brush/shaders/color.gdshader b/game/addons/zylann.hterrain/tools/brush/shaders/color.gdshader
new file mode 100644
index 0000000..eec65f5
--- /dev/null
+++ b/game/addons/zylann.hterrain/tools/brush/shaders/color.gdshader
@@ -0,0 +1,68 @@
+shader_type canvas_item;
+render_mode blend_disabled;
+
+#include "res://addons/zylann.hterrain/shaders/include/heightmap.gdshaderinc"
+
+uniform sampler2D u_src_texture;
+uniform vec4 u_src_rect;
+uniform float u_opacity = 1.0;
+uniform vec4 u_color = vec4(1.0);
+uniform sampler2D u_heightmap;
+uniform float u_normal_min_y = 0.0;
+uniform float u_normal_max_y = 1.0;
+
+vec2 get_src_uv(vec2 screen_uv) {
+ vec2 uv = u_src_rect.xy + screen_uv * u_src_rect.zw;
+ return uv;
+}
+
+float get_height(sampler2D heightmap, vec2 uv) {
+ return sample_heightmap(heightmap, uv);
+}
+
+vec3 get_normal(sampler2D heightmap, vec2 pos) {
+ vec2 ps = vec2(1.0) / vec2(textureSize(heightmap, 0));
+ float hnx = get_height(heightmap, pos + vec2(-ps.x, 0.0));
+ float hpx = get_height(heightmap, pos + vec2(ps.x, 0.0));
+ float hny = get_height(heightmap, pos + vec2(0.0, -ps.y));
+ float hpy = get_height(heightmap, pos + vec2(0.0, ps.y));
+ return normalize(vec3(hnx - hpx, 2.0, hpy - hny));
+}
+
+// Limits painting based on the slope, with a bit of falloff
+float apply_slope_limit(float brush_value, vec3 normal, float normal_min_y, float normal_max_y) {
+ float normal_falloff = 0.2;
+
+ // If an edge is at min/max, make sure it won't be affected by falloff
+ normal_min_y = normal_min_y <= 0.0 ? -2.0 : normal_min_y;
+ normal_max_y = normal_max_y >= 1.0 ? 2.0 : normal_max_y;
+
+ brush_value *= 1.0 - smoothstep(
+ normal_max_y - normal_falloff,
+ normal_max_y + normal_falloff, normal.y);
+
+ brush_value *= smoothstep(
+ normal_min_y - normal_falloff,
+ normal_min_y + normal_falloff, normal.y);
+
+ return brush_value;
+}
+
+void fragment() {
+ float brush_value = u_opacity * texture(TEXTURE, UV).r;
+
+ vec2 src_uv = get_src_uv(SCREEN_UV);
+ vec3 normal = get_normal(u_heightmap, src_uv);
+ brush_value = apply_slope_limit(brush_value, normal, u_normal_min_y, u_normal_max_y);
+
+ vec4 src = texture(u_src_texture, src_uv);
+
+ // Despite hints, albedo textures render darker.
+ // Trying to undo sRGB does not work because of 8-bit precision loss
+ // that would occur either in texture, or on the source image.
+ // So it's not possible to use viewports to paint albedo...
+ //src.rgb = pow(src.rgb, vec3(0.4545));
+
+ vec4 col = vec4(mix(src.rgb, u_color.rgb, brush_value), src.a);
+ COLOR = col;
+}
diff --git a/game/addons/zylann.hterrain/tools/brush/shaders/erode.gdshader b/game/addons/zylann.hterrain/tools/brush/shaders/erode.gdshader
new file mode 100644
index 0000000..669f4d7
--- /dev/null
+++ b/game/addons/zylann.hterrain/tools/brush/shaders/erode.gdshader
@@ -0,0 +1,64 @@
+shader_type canvas_item;
+render_mode blend_disabled;
+
+#include "res://addons/zylann.hterrain/shaders/include/heightmap.gdshaderinc"
+
+uniform sampler2D u_src_texture;
+uniform vec4 u_src_rect;
+uniform float u_opacity = 1.0;
+uniform vec4 u_color = vec4(1.0);
+
+vec2 get_src_uv(vec2 screen_uv) {
+ vec2 uv = u_src_rect.xy + screen_uv * u_src_rect.zw;
+ return uv;
+}
+
+// float get_noise(vec2 pos) {
+// return fract(sin(dot(pos.xy ,vec2(12.9898,78.233))) * 43758.5453);
+// }
+
+float get_height(sampler2D heightmap, vec2 uv) {
+ return sample_heightmap(heightmap, uv);
+}
+
+float erode(sampler2D heightmap, vec2 uv, vec2 pixel_size, float weight) {
+ float r = 3.0;
+
+ // Divide so the shader stays neighbor dependent 1 pixel across.
+ // For this to work, filtering must be enabled.
+ vec2 eps = pixel_size / (0.99 * r);
+
+ float h = get_height(heightmap, uv);
+ float eh = h;
+ //float dh = h;
+
+ // Morphology with circular structuring element
+ for (float y = -r; y <= r; ++y) {
+ for (float x = -r; x <= r; ++x) {
+
+ vec2 p = vec2(x, y);
+ float nh = get_height(heightmap, uv + p * eps);
+
+ float s = max(length(p) - r, 0);
+ eh = min(eh, nh + s);
+
+ //s = min(r - length(p), 0);
+ //dh = max(dh, nh + s);
+ }
+ }
+
+ eh = mix(h, eh, weight);
+ //dh = mix(h, dh, u_weight);
+
+ float ph = eh;//mix(eh, dh, u_dilation);
+
+ return ph;
+}
+
+void fragment() {
+ float brush_value = u_opacity * texture(TEXTURE, UV).r;
+ vec2 src_pixel_size = 1.0 / vec2(textureSize(u_src_texture, 0));
+ float ph = erode(u_src_texture, get_src_uv(SCREEN_UV), src_pixel_size, brush_value);
+ //ph += brush_value * 0.35;
+ COLOR = encode_height_to_viewport(ph);
+}
diff --git a/game/addons/zylann.hterrain/tools/brush/shaders/flatten.gdshader b/game/addons/zylann.hterrain/tools/brush/shaders/flatten.gdshader
new file mode 100644
index 0000000..c51f03a
--- /dev/null
+++ b/game/addons/zylann.hterrain/tools/brush/shaders/flatten.gdshader
@@ -0,0 +1,22 @@
+shader_type canvas_item;
+render_mode blend_disabled;
+
+#include "res://addons/zylann.hterrain/shaders/include/heightmap.gdshaderinc"
+
+uniform sampler2D u_src_texture;
+uniform vec4 u_src_rect;
+uniform float u_opacity = 1.0;
+uniform float u_flatten_value;
+
+vec2 get_src_uv(vec2 screen_uv) {
+ vec2 uv = u_src_rect.xy + screen_uv * u_src_rect.zw;
+ return uv;
+}
+
+void fragment() {
+ float brush_value = u_opacity * texture(TEXTURE, UV).r;
+
+ float src_h = sample_heightmap(u_src_texture, get_src_uv(SCREEN_UV));
+ float h = mix(src_h, u_flatten_value, brush_value);
+ COLOR = encode_height_to_viewport(h);
+}
diff --git a/game/addons/zylann.hterrain/tools/brush/shaders/level.gdshader b/game/addons/zylann.hterrain/tools/brush/shaders/level.gdshader
new file mode 100644
index 0000000..4721b43
--- /dev/null
+++ b/game/addons/zylann.hterrain/tools/brush/shaders/level.gdshader
@@ -0,0 +1,45 @@
+shader_type canvas_item;
+render_mode blend_disabled;
+
+#include "res://addons/zylann.hterrain/shaders/include/heightmap.gdshaderinc"
+
+uniform sampler2D u_src_texture;
+uniform vec4 u_src_rect;
+uniform float u_opacity = 1.0;
+uniform float u_factor = 1.0;
+
+vec2 get_src_uv(vec2 screen_uv) {
+ vec2 uv = u_src_rect.xy + screen_uv * u_src_rect.zw;
+ return uv;
+}
+
+float get_height(sampler2D heightmap, vec2 uv) {
+ return sample_heightmap(heightmap, uv);
+}
+
+// TODO Could actually level to whatever height the brush was at the beginning of the stroke?
+
+void fragment() {
+ float brush_value = u_factor * u_opacity * texture(TEXTURE, UV).r;
+
+ // The heightmap does not have mipmaps,
+ // so we need to use an approximation of average.
+ // This is not a very good one though...
+ float dst_h = 0.0;
+ vec2 uv_min = vec2(u_src_rect.xy);
+ vec2 uv_max = vec2(u_src_rect.xy + u_src_rect.zw);
+ for (int i = 0; i < 5; ++i) {
+ for (int j = 0; j < 5; ++j) {
+ float x = mix(uv_min.x, uv_max.x, float(i) / 4.0);
+ float y = mix(uv_min.y, uv_max.y, float(j) / 4.0);
+ float h = get_height(u_src_texture, vec2(x, y));
+ dst_h += h;
+ }
+ }
+ dst_h /= (5.0 * 5.0);
+
+ // TODO I have no idea if this will check out
+ float src_h = get_height(u_src_texture, get_src_uv(SCREEN_UV));
+ float h = mix(src_h, dst_h, brush_value);
+ COLOR = encode_height_to_viewport(h);
+}
diff --git a/game/addons/zylann.hterrain/tools/brush/shaders/raise.gdshader b/game/addons/zylann.hterrain/tools/brush/shaders/raise.gdshader
new file mode 100644
index 0000000..10ee982
--- /dev/null
+++ b/game/addons/zylann.hterrain/tools/brush/shaders/raise.gdshader
@@ -0,0 +1,22 @@
+shader_type canvas_item;
+render_mode blend_disabled;
+
+#include "res://addons/zylann.hterrain/shaders/include/heightmap.gdshaderinc"
+
+uniform sampler2D u_src_texture;
+uniform vec4 u_src_rect;
+uniform float u_opacity = 1.0;
+uniform float u_factor = 1.0;
+
+vec2 get_src_uv(vec2 screen_uv) {
+ vec2 uv = u_src_rect.xy + screen_uv * u_src_rect.zw;
+ return uv;
+}
+
+void fragment() {
+ float brush_value = u_factor * u_opacity * texture(TEXTURE, UV).r;
+
+ float src_h = sample_heightmap(u_src_texture, get_src_uv(SCREEN_UV));
+ float h = src_h + brush_value;
+ COLOR = encode_height_to_viewport(h);
+}
diff --git a/game/addons/zylann.hterrain/tools/brush/shaders/smooth.gdshader b/game/addons/zylann.hterrain/tools/brush/shaders/smooth.gdshader
new file mode 100644
index 0000000..27123e4
--- /dev/null
+++ b/game/addons/zylann.hterrain/tools/brush/shaders/smooth.gdshader
@@ -0,0 +1,34 @@
+shader_type canvas_item;
+render_mode blend_disabled;
+
+#include "res://addons/zylann.hterrain/shaders/include/heightmap.gdshaderinc"
+
+uniform sampler2D u_src_texture;
+uniform vec4 u_src_rect;
+uniform float u_opacity = 1.0;
+uniform float u_factor = 1.0;
+
+vec2 get_src_uv(vec2 screen_uv) {
+ vec2 uv = u_src_rect.xy + screen_uv * u_src_rect.zw;
+ return uv;
+}
+
+float get_height(sampler2D heightmap, vec2 uv) {
+ return sample_heightmap(heightmap, uv);
+}
+
+void fragment() {
+ float brush_value = u_factor * u_opacity * texture(TEXTURE, UV).r;
+
+ vec2 src_pixel_size = 1.0 / vec2(textureSize(u_src_texture, 0));
+ vec2 src_uv = get_src_uv(SCREEN_UV);
+ vec2 offset = src_pixel_size;
+ float src_nx = get_height(u_src_texture, src_uv - vec2(offset.x, 0.0));
+ float src_px = get_height(u_src_texture, src_uv + vec2(offset.x, 0.0));
+ float src_ny = get_height(u_src_texture, src_uv - vec2(0.0, offset.y));
+ float src_py = get_height(u_src_texture, src_uv + vec2(0.0, offset.y));
+ float src_h = get_height(u_src_texture, src_uv);
+ float dst_h = (src_h + src_nx + src_px + src_ny + src_py) * 0.2;
+ float h = mix(src_h, dst_h, brush_value);
+ COLOR = encode_height_to_viewport(h);
+}
diff --git a/game/addons/zylann.hterrain/tools/brush/shaders/splat16.gdshader b/game/addons/zylann.hterrain/tools/brush/shaders/splat16.gdshader
new file mode 100644
index 0000000..68ebaa8
--- /dev/null
+++ b/game/addons/zylann.hterrain/tools/brush/shaders/splat16.gdshader
@@ -0,0 +1,81 @@
+shader_type canvas_item;
+render_mode blend_disabled;
+
+#include "res://addons/zylann.hterrain/shaders/include/heightmap.gdshaderinc"
+
+uniform sampler2D u_src_texture;
+uniform vec4 u_src_rect;
+uniform float u_opacity = 1.0;
+uniform vec4 u_splat = vec4(1.0, 0.0, 0.0, 0.0);
+uniform sampler2D u_other_splatmap_1;
+uniform sampler2D u_other_splatmap_2;
+uniform sampler2D u_other_splatmap_3;
+uniform sampler2D u_heightmap;
+uniform float u_normal_min_y = 0.0;
+uniform float u_normal_max_y = 1.0;
+
+vec2 get_src_uv(vec2 screen_uv) {
+ vec2 uv = u_src_rect.xy + screen_uv * u_src_rect.zw;
+ return uv;
+}
+
+float sum(vec4 v) {
+ return v.x + v.y + v.z + v.w;
+}
+
+float get_height(sampler2D heightmap, vec2 uv) {
+ return sample_heightmap(heightmap, uv);
+}
+
+vec3 get_normal(sampler2D heightmap, vec2 pos) {
+ vec2 ps = vec2(1.0) / vec2(textureSize(heightmap, 0));
+ float hnx = get_height(heightmap, pos + vec2(-ps.x, 0.0));
+ float hpx = get_height(heightmap, pos + vec2(ps.x, 0.0));
+ float hny = get_height(heightmap, pos + vec2(0.0, -ps.y));
+ float hpy = get_height(heightmap, pos + vec2(0.0, ps.y));
+ return normalize(vec3(hnx - hpx, 2.0, hpy - hny));
+}
+
+// Limits painting based on the slope, with a bit of falloff
+float apply_slope_limit(float brush_value, vec3 normal, float normal_min_y, float normal_max_y) {
+ float normal_falloff = 0.2;
+
+ // If an edge is at min/max, make sure it won't be affected by falloff
+ normal_min_y = normal_min_y <= 0.0 ? -2.0 : normal_min_y;
+ normal_max_y = normal_max_y >= 1.0 ? 2.0 : normal_max_y;
+
+ brush_value *= 1.0 - smoothstep(
+ normal_max_y - normal_falloff,
+ normal_max_y + normal_falloff, normal.y);
+
+ brush_value *= smoothstep(
+ normal_min_y - normal_falloff,
+ normal_min_y + normal_falloff, normal.y);
+
+ return brush_value;
+}
+
+void fragment() {
+ float brush_value = u_opacity * texture(TEXTURE, UV).r;
+
+ vec2 src_uv = get_src_uv(SCREEN_UV);
+ vec3 normal = get_normal(u_heightmap, src_uv);
+ brush_value = apply_slope_limit(brush_value, normal, u_normal_min_y, u_normal_max_y);
+
+ // It is assumed 3 other renders are done the same with the other 3
+ vec4 src0 = texture(u_src_texture, src_uv);
+ vec4 src1 = texture(u_other_splatmap_1, src_uv);
+ vec4 src2 = texture(u_other_splatmap_2, src_uv);
+ vec4 src3 = texture(u_other_splatmap_3, src_uv);
+ float t = brush_value;
+ vec4 s0 = mix(src0, u_splat, t);
+ vec4 s1 = mix(src1, vec4(0.0), t);
+ vec4 s2 = mix(src2, vec4(0.0), t);
+ vec4 s3 = mix(src3, vec4(0.0), t);
+ float sum = sum(s0) + sum(s1) + sum(s2) + sum(s3);
+ s0 /= sum;
+ s1 /= sum;
+ s2 /= sum;
+ s3 /= sum;
+ COLOR = s0;
+}
diff --git a/game/addons/zylann.hterrain/tools/brush/shaders/splat4.gdshader b/game/addons/zylann.hterrain/tools/brush/shaders/splat4.gdshader
new file mode 100644
index 0000000..1291dbd
--- /dev/null
+++ b/game/addons/zylann.hterrain/tools/brush/shaders/splat4.gdshader
@@ -0,0 +1,63 @@
+shader_type canvas_item;
+render_mode blend_disabled;
+
+#include "res://addons/zylann.hterrain/shaders/include/heightmap.gdshaderinc"
+
+uniform sampler2D u_src_texture;
+uniform vec4 u_src_rect;
+uniform float u_opacity = 1.0;
+uniform vec4 u_splat = vec4(1.0, 0.0, 0.0, 0.0);
+uniform sampler2D u_heightmap;
+uniform float u_normal_min_y = 0.0;
+uniform float u_normal_max_y = 1.0;
+//uniform float u_normal_falloff = 0.0;
+
+vec2 get_src_uv(vec2 screen_uv) {
+ vec2 uv = u_src_rect.xy + screen_uv * u_src_rect.zw;
+ return uv;
+}
+
+float get_height(sampler2D heightmap, vec2 uv) {
+ return sample_heightmap(heightmap, uv);
+}
+
+vec3 get_normal(sampler2D heightmap, vec2 pos) {
+ vec2 ps = vec2(1.0) / vec2(textureSize(heightmap, 0));
+ float hnx = get_height(heightmap, pos + vec2(-ps.x, 0.0));
+ float hpx = get_height(heightmap, pos + vec2(ps.x, 0.0));
+ float hny = get_height(heightmap, pos + vec2(0.0, -ps.y));
+ float hpy = get_height(heightmap, pos + vec2(0.0, ps.y));
+ return normalize(vec3(hnx - hpx, 2.0, hpy - hny));
+}
+
+// Limits painting based on the slope, with a bit of falloff
+float apply_slope_limit(float brush_value, vec3 normal, float normal_min_y, float normal_max_y) {
+ float normal_falloff = 0.2;
+
+ // If an edge is at min/max, make sure it won't be affected by falloff
+ normal_min_y = normal_min_y <= 0.0 ? -2.0 : normal_min_y;
+ normal_max_y = normal_max_y >= 1.0 ? 2.0 : normal_max_y;
+
+ brush_value *= 1.0 - smoothstep(
+ normal_max_y - normal_falloff,
+ normal_max_y + normal_falloff, normal.y);
+
+ brush_value *= smoothstep(
+ normal_min_y - normal_falloff,
+ normal_min_y + normal_falloff, normal.y);
+
+ return brush_value;
+}
+
+void fragment() {
+ float brush_value = u_opacity * texture(TEXTURE, UV).r;
+
+ vec2 src_uv = get_src_uv(SCREEN_UV);
+ vec3 normal = get_normal(u_heightmap, src_uv);
+ brush_value = apply_slope_limit(brush_value, normal, u_normal_min_y, u_normal_max_y);
+
+ vec4 src_splat = texture(u_src_texture, src_uv);
+ vec4 s = mix(src_splat, u_splat, brush_value);
+ s = s / (s.r + s.g + s.b + s.a);
+ COLOR = s;
+}
diff --git a/game/addons/zylann.hterrain/tools/brush/shaders/splat_indexed.gdshader b/game/addons/zylann.hterrain/tools/brush/shaders/splat_indexed.gdshader
new file mode 100644
index 0000000..9828068
--- /dev/null
+++ b/game/addons/zylann.hterrain/tools/brush/shaders/splat_indexed.gdshader
@@ -0,0 +1,89 @@
+shader_type canvas_item;
+render_mode blend_disabled;
+
+uniform sampler2D u_src_texture;
+uniform vec4 u_src_rect;
+uniform float u_opacity = 1.0;
+uniform int u_texture_index;
+uniform int u_mode; // 0: output index, 1: output weight
+uniform sampler2D u_index_map;
+uniform sampler2D u_weight_map;
+
+vec2 get_src_uv(vec2 screen_uv) {
+ vec2 uv = u_src_rect.xy + screen_uv * u_src_rect.zw;
+ return uv;
+}
+
+void fragment() {
+ float brush_value = u_opacity * texture(TEXTURE, UV).r;
+
+ vec2 src_uv = get_src_uv(SCREEN_UV);
+ vec4 iv = texture(u_index_map, src_uv);
+ vec4 wv = texture(u_weight_map, src_uv);
+
+ float i[3] = {iv.r, iv.g, iv.b};
+ float w[3] = {wv.r, wv.g, wv.b};
+
+ if (brush_value > 0.0) {
+ float texture_index_f = float(u_texture_index) / 255.0;
+ int ci = u_texture_index % 3;
+
+ float cm[3] = {-1.0, -1.0, -1.0};
+ cm[ci] = 1.0;
+
+ // Decompress third weight to make computations easier
+ w[2] = 1.0 - w[0] - w[1];
+
+ if (abs(i[ci] - texture_index_f) > 0.001) {
+ // Pixel does not have our texture index,
+ // transfer its weight to other components first
+ if (w[ci] > brush_value) {
+ w[0] -= cm[0] * brush_value;
+ w[1] -= cm[1] * brush_value;
+ w[2] -= cm[2] * brush_value;
+
+ } else if (w[ci] >= 0.f) {
+ w[ci] = 0.f;
+ i[ci] = texture_index_f;
+ }
+
+ } else {
+ // Pixel has our texture index, increase its weight
+ if (w[ci] + brush_value < 1.f) {
+ w[0] += cm[0] * brush_value;
+ w[1] += cm[1] * brush_value;
+ w[2] += cm[2] * brush_value;
+
+ } else {
+ // Pixel weight is full, we can set all components to the same index.
+ // Need to nullify other weights because they would otherwise never reach
+ // zero due to normalization
+ w[0] = 0.0;
+ w[1] = 0.0;
+ w[2] = 0.0;
+
+ w[ci] = 1.0;
+
+ i[0] = texture_index_f;
+ i[1] = texture_index_f;
+ i[2] = texture_index_f;
+ }
+ }
+
+ w[0] = clamp(w[0], 0.0, 1.0);
+ w[1] = clamp(w[1], 0.0, 1.0);
+ w[2] = clamp(w[2], 0.0, 1.0);
+
+ // Renormalize
+ float sum = w[0] + w[1] + w[2];
+ w[0] /= sum;
+ w[1] /= sum;
+ w[2] /= sum;
+ }
+
+ if (u_mode == 0) {
+ COLOR = vec4(i[0], i[1], i[2], 1.0);
+ } else {
+ COLOR = vec4(w[0], w[1], w[2], 1.0);
+ }
+}
diff --git a/game/addons/zylann.hterrain/tools/brush/shapes/acrylic1.exr b/game/addons/zylann.hterrain/tools/brush/shapes/acrylic1.exr
new file mode 100644
index 0000000..14b66d6
--- /dev/null
+++ b/game/addons/zylann.hterrain/tools/brush/shapes/acrylic1.exr
Binary files differ
diff --git a/game/addons/zylann.hterrain/tools/brush/shapes/acrylic1.exr.import b/game/addons/zylann.hterrain/tools/brush/shapes/acrylic1.exr.import
new file mode 100644
index 0000000..c547891
--- /dev/null
+++ b/game/addons/zylann.hterrain/tools/brush/shapes/acrylic1.exr.import
@@ -0,0 +1,34 @@
+[remap]
+
+importer="texture"
+type="CompressedTexture2D"
+uid="uid://cua2gxgj2pum5"
+path="res://.godot/imported/acrylic1.exr-8a4b622f104c607118d296791ee118f3.ctex"
+metadata={
+"vram_texture": false
+}
+
+[deps]
+
+source_file="res://addons/zylann.hterrain/tools/brush/shapes/acrylic1.exr"
+dest_files=["res://.godot/imported/acrylic1.exr-8a4b622f104c607118d296791ee118f3.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/game/addons/zylann.hterrain/tools/brush/shapes/round0.exr b/game/addons/zylann.hterrain/tools/brush/shapes/round0.exr
new file mode 100644
index 0000000..e91d97e
--- /dev/null
+++ b/game/addons/zylann.hterrain/tools/brush/shapes/round0.exr
Binary files differ
diff --git a/game/addons/zylann.hterrain/tools/brush/shapes/round0.exr.import b/game/addons/zylann.hterrain/tools/brush/shapes/round0.exr.import
new file mode 100644
index 0000000..6308ee6
--- /dev/null
+++ b/game/addons/zylann.hterrain/tools/brush/shapes/round0.exr.import
@@ -0,0 +1,34 @@
+[remap]
+
+importer="texture"
+type="CompressedTexture2D"
+uid="uid://dekau2j7fx14d"
+path="res://.godot/imported/round0.exr-fc6d691e8892911b1b4496769ee75dbb.ctex"
+metadata={
+"vram_texture": false
+}
+
+[deps]
+
+source_file="res://addons/zylann.hterrain/tools/brush/shapes/round0.exr"
+dest_files=["res://.godot/imported/round0.exr-fc6d691e8892911b1b4496769ee75dbb.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/game/addons/zylann.hterrain/tools/brush/shapes/round1.exr b/game/addons/zylann.hterrain/tools/brush/shapes/round1.exr
new file mode 100644
index 0000000..f6931ba
--- /dev/null
+++ b/game/addons/zylann.hterrain/tools/brush/shapes/round1.exr
Binary files differ
diff --git a/game/addons/zylann.hterrain/tools/brush/shapes/round1.exr.import b/game/addons/zylann.hterrain/tools/brush/shapes/round1.exr.import
new file mode 100644
index 0000000..e64db1a
--- /dev/null
+++ b/game/addons/zylann.hterrain/tools/brush/shapes/round1.exr.import
@@ -0,0 +1,34 @@
+[remap]
+
+importer="texture"
+type="CompressedTexture2D"
+uid="uid://bbo5hotdg6mg7"
+path="res://.godot/imported/round1.exr-8050cfbed31968e6ce8bd055fbaa6897.ctex"
+metadata={
+"vram_texture": false
+}
+
+[deps]
+
+source_file="res://addons/zylann.hterrain/tools/brush/shapes/round1.exr"
+dest_files=["res://.godot/imported/round1.exr-8050cfbed31968e6ce8bd055fbaa6897.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/game/addons/zylann.hterrain/tools/brush/shapes/round2.exr b/game/addons/zylann.hterrain/tools/brush/shapes/round2.exr
new file mode 100644
index 0000000..477ab7e
--- /dev/null
+++ b/game/addons/zylann.hterrain/tools/brush/shapes/round2.exr
Binary files differ
diff --git a/game/addons/zylann.hterrain/tools/brush/shapes/round2.exr.import b/game/addons/zylann.hterrain/tools/brush/shapes/round2.exr.import
new file mode 100644
index 0000000..6038a8e
--- /dev/null
+++ b/game/addons/zylann.hterrain/tools/brush/shapes/round2.exr.import
@@ -0,0 +1,34 @@
+[remap]
+
+importer="texture"
+type="CompressedTexture2D"
+uid="uid://tn1ww3c47pwy"
+path="res://.godot/imported/round2.exr-2a843db3bf131f2b2f5964ce65600f42.ctex"
+metadata={
+"vram_texture": false
+}
+
+[deps]
+
+source_file="res://addons/zylann.hterrain/tools/brush/shapes/round2.exr"
+dest_files=["res://.godot/imported/round2.exr-2a843db3bf131f2b2f5964ce65600f42.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/game/addons/zylann.hterrain/tools/brush/shapes/round3.exr b/game/addons/zylann.hterrain/tools/brush/shapes/round3.exr
new file mode 100644
index 0000000..b466f92
--- /dev/null
+++ b/game/addons/zylann.hterrain/tools/brush/shapes/round3.exr
Binary files differ
diff --git a/game/addons/zylann.hterrain/tools/brush/shapes/round3.exr.import b/game/addons/zylann.hterrain/tools/brush/shapes/round3.exr.import
new file mode 100644
index 0000000..e8f2107
--- /dev/null
+++ b/game/addons/zylann.hterrain/tools/brush/shapes/round3.exr.import
@@ -0,0 +1,34 @@
+[remap]
+
+importer="texture"
+type="CompressedTexture2D"
+uid="uid://baim7e27k13r4"
+path="res://.godot/imported/round3.exr-77a9cdd9a592eb6010dc1db702d42c3a.ctex"
+metadata={
+"vram_texture": false
+}
+
+[deps]
+
+source_file="res://addons/zylann.hterrain/tools/brush/shapes/round3.exr"
+dest_files=["res://.godot/imported/round3.exr-77a9cdd9a592eb6010dc1db702d42c3a.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/game/addons/zylann.hterrain/tools/brush/shapes/smoke.exr b/game/addons/zylann.hterrain/tools/brush/shapes/smoke.exr
new file mode 100644
index 0000000..021947b
--- /dev/null
+++ b/game/addons/zylann.hterrain/tools/brush/shapes/smoke.exr
Binary files differ
diff --git a/game/addons/zylann.hterrain/tools/brush/shapes/smoke.exr.import b/game/addons/zylann.hterrain/tools/brush/shapes/smoke.exr.import
new file mode 100644
index 0000000..85151ff
--- /dev/null
+++ b/game/addons/zylann.hterrain/tools/brush/shapes/smoke.exr.import
@@ -0,0 +1,34 @@
+[remap]
+
+importer="texture"
+type="CompressedTexture2D"
+uid="uid://cl6pxk6wr4hem"
+path="res://.godot/imported/smoke.exr-0061a0a2acdf1ca295ec547e4b8c920d.ctex"
+metadata={
+"vram_texture": false
+}
+
+[deps]
+
+source_file="res://addons/zylann.hterrain/tools/brush/shapes/smoke.exr"
+dest_files=["res://.godot/imported/smoke.exr-0061a0a2acdf1ca295ec547e4b8c920d.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/game/addons/zylann.hterrain/tools/brush/shapes/texture1.exr b/game/addons/zylann.hterrain/tools/brush/shapes/texture1.exr
new file mode 100644
index 0000000..f456b77
--- /dev/null
+++ b/game/addons/zylann.hterrain/tools/brush/shapes/texture1.exr
Binary files differ
diff --git a/game/addons/zylann.hterrain/tools/brush/shapes/texture1.exr.import b/game/addons/zylann.hterrain/tools/brush/shapes/texture1.exr.import
new file mode 100644
index 0000000..9226d03
--- /dev/null
+++ b/game/addons/zylann.hterrain/tools/brush/shapes/texture1.exr.import
@@ -0,0 +1,34 @@
+[remap]
+
+importer="texture"
+type="CompressedTexture2D"
+uid="uid://dqdkxiq52oo0j"
+path="res://.godot/imported/texture1.exr-0fac1840855f814972ea5666743101fc.ctex"
+metadata={
+"vram_texture": false
+}
+
+[deps]
+
+source_file="res://addons/zylann.hterrain/tools/brush/shapes/texture1.exr"
+dest_files=["res://.godot/imported/texture1.exr-0fac1840855f814972ea5666743101fc.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/game/addons/zylann.hterrain/tools/brush/shapes/thing.exr b/game/addons/zylann.hterrain/tools/brush/shapes/thing.exr
new file mode 100644
index 0000000..49d341e
--- /dev/null
+++ b/game/addons/zylann.hterrain/tools/brush/shapes/thing.exr
Binary files differ
diff --git a/game/addons/zylann.hterrain/tools/brush/shapes/thing.exr.import b/game/addons/zylann.hterrain/tools/brush/shapes/thing.exr.import
new file mode 100644
index 0000000..90ba164
--- /dev/null
+++ b/game/addons/zylann.hterrain/tools/brush/shapes/thing.exr.import
@@ -0,0 +1,34 @@
+[remap]
+
+importer="texture"
+type="CompressedTexture2D"
+uid="uid://blljfgdlwdae5"
+path="res://.godot/imported/thing.exr-8e88d861fe83e5e870fa01faee694c73.ctex"
+metadata={
+"vram_texture": false
+}
+
+[deps]
+
+source_file="res://addons/zylann.hterrain/tools/brush/shapes/thing.exr"
+dest_files=["res://.godot/imported/thing.exr-8e88d861fe83e5e870fa01faee694c73.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/game/addons/zylann.hterrain/tools/brush/shapes/vegetation1.exr b/game/addons/zylann.hterrain/tools/brush/shapes/vegetation1.exr
new file mode 100644
index 0000000..d65bc6e
--- /dev/null
+++ b/game/addons/zylann.hterrain/tools/brush/shapes/vegetation1.exr
Binary files differ
diff --git a/game/addons/zylann.hterrain/tools/brush/shapes/vegetation1.exr.import b/game/addons/zylann.hterrain/tools/brush/shapes/vegetation1.exr.import
new file mode 100644
index 0000000..2a1c46a
--- /dev/null
+++ b/game/addons/zylann.hterrain/tools/brush/shapes/vegetation1.exr.import
@@ -0,0 +1,34 @@
+[remap]
+
+importer="texture"
+type="CompressedTexture2D"
+uid="uid://ca5nk2h4ukm0g"
+path="res://.godot/imported/vegetation1.exr-0573f4c73944e2dd8f3202b8930ac625.ctex"
+metadata={
+"vram_texture": false
+}
+
+[deps]
+
+source_file="res://addons/zylann.hterrain/tools/brush/shapes/vegetation1.exr"
+dest_files=["res://.godot/imported/vegetation1.exr-0573f4c73944e2dd8f3202b8930ac625.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/game/addons/zylann.hterrain/tools/brush/terrain_painter.gd b/game/addons/zylann.hterrain/tools/brush/terrain_painter.gd
new file mode 100644
index 0000000..81891b6
--- /dev/null
+++ b/game/addons/zylann.hterrain/tools/brush/terrain_painter.gd
@@ -0,0 +1,573 @@
+@tool
+extends Node
+
+const HT_Painter = preload("./painter.gd")
+const HTerrain = preload("../../hterrain.gd")
+const HTerrainData = preload("../../hterrain_data.gd")
+const HT_Logger = preload("../../util/logger.gd")
+const HT_Brush = preload("./brush.gd")
+
+const HT_RaiseShader = preload("./shaders/raise.gdshader")
+const HT_SmoothShader = preload("./shaders/smooth.gdshader")
+const HT_LevelShader = preload("./shaders/level.gdshader")
+const HT_FlattenShader = preload("./shaders/flatten.gdshader")
+const HT_ErodeShader = preload("./shaders/erode.gdshader")
+const HT_Splat4Shader = preload("./shaders/splat4.gdshader")
+const HT_Splat16Shader = preload("./shaders/splat16.gdshader")
+const HT_SplatIndexedShader = preload("./shaders/splat_indexed.gdshader")
+const HT_ColorShader = preload("./shaders/color.gdshader")
+const HT_AlphaShader = preload("./shaders/alpha.gdshader")
+
+const MODE_RAISE = 0
+const MODE_LOWER = 1
+const MODE_SMOOTH = 2
+const MODE_FLATTEN = 3
+const MODE_SPLAT = 4
+const MODE_COLOR = 5
+const MODE_MASK = 6
+const MODE_DETAIL = 7
+const MODE_LEVEL = 8
+const MODE_ERODE = 9
+const MODE_COUNT = 10
+
+class HT_ModifiedMap:
+ var map_type := 0
+ var map_index := 0
+ var painter_index := 0
+
+signal flatten_height_changed
+
+var _painters : Array[HT_Painter] = []
+
+var _brush := HT_Brush.new()
+
+var _color := Color(1, 0, 0, 1)
+var _mask_flag := false
+var _mode := MODE_RAISE
+var _flatten_height := 0.0
+var _detail_index := 0
+var _detail_density := 1.0
+var _texture_index := 0
+var _slope_limit_low_angle := 0.0
+var _slope_limit_high_angle := PI / 2.0
+
+var _modified_maps := []
+var _terrain : HTerrain
+var _logger = HT_Logger.get_for(self)
+
+
+func _init():
+ for i in 4:
+ var p := HT_Painter.new()
+ # The name is just for debugging
+ p.set_name(str("Painter", i))
+ #p.set_brush_size(_brush_size)
+ p.texture_region_changed.connect(_on_painter_texture_region_changed.bind(i))
+ add_child(p)
+ _painters.append(p)
+
+
+func get_brush() -> HT_Brush:
+ return _brush
+
+
+func get_brush_size() -> int:
+ return _brush.get_size()
+
+
+func set_brush_size(s: int):
+ _brush.set_size(s)
+# for p in _painters:
+# p.set_brush_size(_brush_size)
+
+
+func set_brush_texture(texture: Texture2D):
+ _brush.set_shapes([texture])
+# for p in _painters:
+# p.set_brush_texture(texture)
+
+
+func get_opacity() -> float:
+ return _brush.get_opacity()
+
+
+func set_opacity(opacity: float):
+ _brush.set_opacity(opacity)
+
+
+func set_flatten_height(h: float):
+ if h == _flatten_height:
+ return
+ _flatten_height = h
+ flatten_height_changed.emit()
+
+
+func get_flatten_height() -> float:
+ return _flatten_height
+
+
+func set_color(c: Color):
+ _color = c
+
+
+func get_color() -> Color:
+ return _color
+
+
+func set_mask_flag(m: bool):
+ _mask_flag = m
+
+
+func get_mask_flag() -> bool:
+ return _mask_flag
+
+
+func set_detail_density(d: float):
+ _detail_density = clampf(d, 0.0, 1.0)
+
+
+func get_detail_density() -> float:
+ return _detail_density
+
+
+func set_detail_index(di: int):
+ _detail_index = di
+
+
+func set_texture_index(i: int):
+ _texture_index = i
+
+
+func get_texture_index() -> int:
+ return _texture_index
+
+
+func get_slope_limit_low_angle() -> float:
+ return _slope_limit_low_angle
+
+
+func get_slope_limit_high_angle() -> float:
+ return _slope_limit_high_angle
+
+
+func set_slope_limit_angles(low: float, high: float):
+ _slope_limit_low_angle = low
+ _slope_limit_high_angle = high
+
+
+func is_operation_pending() -> bool:
+ for p in _painters:
+ if p.is_operation_pending():
+ return true
+ return false
+
+
+func has_modified_chunks() -> bool:
+ for p in _painters:
+ if p.has_modified_chunks():
+ return true
+ return false
+
+
+func get_undo_chunk_size() -> int:
+ return HT_Painter.UNDO_CHUNK_SIZE
+
+
+func commit() -> Dictionary:
+ assert(_terrain.get_data() != null)
+ var terrain_data = _terrain.get_data()
+ assert(not terrain_data.is_locked())
+
+ var changes := []
+ var chunk_positions : Array
+
+ assert(len(_modified_maps) > 0)
+
+ for mm in _modified_maps:
+ #print("Flushing painter ", mm.painter_index)
+ var painter : HT_Painter = _painters[mm.painter_index]
+ var info := painter.commit()
+
+ # Note, positions are always the same for each map
+ chunk_positions = info.chunk_positions
+
+ changes.append({
+ "map_type": mm.map_type,
+ "map_index": mm.map_index,
+ "chunk_initial_datas": info.chunk_initial_datas,
+ "chunk_final_datas": info.chunk_final_datas
+ })
+
+ var cs := get_undo_chunk_size()
+ for pos in info.chunk_positions:
+ var rect = Rect2(pos * cs, Vector2(cs, cs))
+ # This will update vertical bounds and notify normal map baker,
+ # since the latter updates out of order for preview
+ terrain_data.notify_region_change(rect, mm.map_type, mm.map_index, false, true)
+
+# for i in len(_painters):
+# var p = _painters[i]
+# if p.has_modified_chunks():
+# print("Painter ", i, " has modified chunks")
+
+ # `commit()` is supposed to consume these chunks, there should be none left
+ assert(not has_modified_chunks())
+
+ return {
+ "chunk_positions": chunk_positions,
+ "maps": changes
+ }
+
+
+func set_mode(mode: int):
+ assert(mode >= 0 and mode < MODE_COUNT)
+ _mode = mode
+
+
+func get_mode() -> int:
+ return _mode
+
+
+func set_terrain(terrain: HTerrain):
+ if terrain == _terrain:
+ return
+ _terrain = terrain
+ # It's important to release resources here,
+ # otherwise Godot keeps modified terrain maps in memory and "reloads" them like that
+ # next time we reopen the scene, even if we didn't save it
+ for p in _painters:
+ p.set_image(null, null)
+ p.clear_brush_shader_params()
+
+
+# This may be called from an `_input` callback.
+# Returns `true` if any change was performed.
+func paint_input(position: Vector2, pressure: float) -> bool:
+ assert(_terrain.get_data() != null)
+ var data := _terrain.get_data()
+ assert(not data.is_locked())
+
+ if not _brush.configure_paint_input(_painters, position, pressure):
+ # Sometimes painting may not happen due to frequency options
+ return false
+
+ _modified_maps.clear()
+
+ match _mode:
+ MODE_RAISE:
+ _paint_height(data, position, 1.0)
+
+ MODE_LOWER:
+ _paint_height(data, position, -1.0)
+
+ MODE_SMOOTH:
+ _paint_smooth(data, position)
+
+ MODE_FLATTEN:
+ _paint_flatten(data, position)
+
+ MODE_LEVEL:
+ _paint_level(data, position)
+
+ MODE_ERODE:
+ _paint_erode(data, position)
+
+ MODE_SPLAT:
+ # TODO Properly support what happens when painting outside of supported index
+ # var supported_slots_count := terrain.get_cached_ground_texture_slot_count()
+ # if _texture_index >= supported_slots_count:
+ # _logger.debug("Painting out of range of supported texture slots: {0}/{1}" \
+ # .format([_texture_index, supported_slots_count]))
+ # return
+ if _terrain.is_using_indexed_splatmap():
+ _paint_splat_indexed(data, position)
+ else:
+ var splatmap_count := _terrain.get_used_splatmaps_count()
+ match splatmap_count:
+ 1:
+ _paint_splat4(data, position)
+ 4:
+ _paint_splat16(data, position)
+
+ MODE_COLOR:
+ _paint_color(data, position)
+
+ MODE_MASK:
+ _paint_mask(data, position)
+
+ MODE_DETAIL:
+ _paint_detail(data, position)
+
+ _:
+ _logger.error("Unknown mode {0}".format([_mode]))
+
+ assert(len(_modified_maps) > 0)
+ return true
+
+
+func _on_painter_texture_region_changed(rect: Rect2, painter_index: int):
+ var data := _terrain.get_data()
+ if data == null:
+ return
+ for mm in _modified_maps:
+ if mm.painter_index == painter_index:
+ # This will tell auto-baked maps to update (like normals).
+ data.notify_region_change(rect, mm.map_type, mm.map_index, false, false)
+ break
+
+
+func _paint_height(data: HTerrainData, position: Vector2, factor: float):
+ var image := data.get_image(HTerrainData.CHANNEL_HEIGHT)
+ var texture := data.get_texture(HTerrainData.CHANNEL_HEIGHT, 0, true)
+
+ var mm := HT_ModifiedMap.new()
+ mm.map_type = HTerrainData.CHANNEL_HEIGHT
+ mm.map_index = 0
+ mm.painter_index = 0
+ _modified_maps = [mm]
+
+ # When using sculpting tools, make it dependent on brush size
+ var raise_strength := 10.0 + float(_brush.get_size())
+ var delta := factor * (2.0 / 60.0) * raise_strength
+
+ var p : HT_Painter = _painters[0]
+
+ p.set_brush_shader(HT_RaiseShader)
+ p.set_brush_shader_param("u_factor", delta)
+ p.set_image(image, texture)
+ p.paint_input(position)
+
+
+func _paint_smooth(data: HTerrainData, position: Vector2):
+ var image := data.get_image(HTerrainData.CHANNEL_HEIGHT)
+ var texture := data.get_texture(HTerrainData.CHANNEL_HEIGHT, 0, true)
+
+ var mm := HT_ModifiedMap.new()
+ mm.map_type = HTerrainData.CHANNEL_HEIGHT
+ mm.map_index = 0
+ mm.painter_index = 0
+ _modified_maps = [mm]
+
+ var p : HT_Painter = _painters[0]
+
+ p.set_brush_shader(HT_SmoothShader)
+ p.set_brush_shader_param("u_factor", 1.0)
+ p.set_image(image, texture)
+ p.paint_input(position)
+
+
+func _paint_flatten(data: HTerrainData, position: Vector2):
+ var image := data.get_image(HTerrainData.CHANNEL_HEIGHT)
+ var texture := data.get_texture(HTerrainData.CHANNEL_HEIGHT, 0, true)
+
+ var mm := HT_ModifiedMap.new()
+ mm.map_type = HTerrainData.CHANNEL_HEIGHT
+ mm.map_index = 0
+ mm.painter_index = 0
+ _modified_maps = [mm]
+
+ var p : HT_Painter = _painters[0]
+
+ p.set_brush_shader(HT_FlattenShader)
+ p.set_brush_shader_param("u_flatten_value", _flatten_height)
+ p.set_image(image, texture)
+ p.paint_input(position)
+
+
+func _paint_level(data: HTerrainData, position: Vector2):
+ var image := data.get_image(HTerrainData.CHANNEL_HEIGHT)
+ var texture := data.get_texture(HTerrainData.CHANNEL_HEIGHT, 0, true)
+
+ var mm := HT_ModifiedMap.new()
+ mm.map_type = HTerrainData.CHANNEL_HEIGHT
+ mm.map_index = 0
+ mm.painter_index = 0
+ _modified_maps = [mm]
+
+ var p : HT_Painter = _painters[0]
+
+ p.set_brush_shader(HT_LevelShader)
+ p.set_brush_shader_param("u_factor", (10.0 / 60.0))
+ p.set_image(image, texture)
+ p.paint_input(position)
+
+
+func _paint_erode(data: HTerrainData, position: Vector2):
+ var image := data.get_image(HTerrainData.CHANNEL_HEIGHT)
+ var texture := data.get_texture(HTerrainData.CHANNEL_HEIGHT, 0, true)
+
+ var mm := HT_ModifiedMap.new()
+ mm.map_type = HTerrainData.CHANNEL_HEIGHT
+ mm.map_index = 0
+ mm.painter_index = 0
+ _modified_maps = [mm]
+
+ var p : HT_Painter = _painters[0]
+
+ p.set_brush_shader(HT_ErodeShader)
+ p.set_image(image, texture)
+ p.paint_input(position)
+
+
+func _paint_splat4(data: HTerrainData, position: Vector2):
+ var image := data.get_image(HTerrainData.CHANNEL_SPLAT)
+ var texture := data.get_texture(HTerrainData.CHANNEL_SPLAT, 0, true)
+ var heightmap_texture := data.get_texture(HTerrainData.CHANNEL_HEIGHT, 0)
+
+ var mm := HT_ModifiedMap.new()
+ mm.map_type = HTerrainData.CHANNEL_SPLAT
+ mm.map_index = 0
+ mm.painter_index = 0
+ _modified_maps = [mm]
+
+ var p : HT_Painter = _painters[0]
+ var splat := Color(0.0, 0.0, 0.0, 0.0)
+ splat[_texture_index] = 1.0;
+ p.set_brush_shader(HT_Splat4Shader)
+ p.set_brush_shader_param("u_splat", splat)
+ _set_slope_limit_shader_params(p, heightmap_texture)
+ p.set_image(image, texture)
+ p.paint_input(position)
+
+
+func _paint_splat_indexed(data: HTerrainData, position: Vector2):
+ var map_types := [
+ HTerrainData.CHANNEL_SPLAT_INDEX,
+ HTerrainData.CHANNEL_SPLAT_WEIGHT
+ ]
+ _modified_maps = []
+
+ var textures := []
+ for mode in 2:
+ textures.append(data.get_texture(map_types[mode], 0, true))
+
+ for mode in 2:
+ var image := data.get_image(map_types[mode])
+
+ var mm := HT_ModifiedMap.new()
+ mm.map_type = map_types[mode]
+ mm.map_index = 0
+ mm.painter_index = mode
+ _modified_maps.append(mm)
+
+ var p : HT_Painter = _painters[mode]
+
+ p.set_brush_shader(HT_SplatIndexedShader)
+ p.set_brush_shader_param("u_mode", mode)
+ p.set_brush_shader_param("u_index_map", textures[0])
+ p.set_brush_shader_param("u_weight_map", textures[1])
+ p.set_brush_shader_param("u_texture_index", _texture_index)
+ p.set_image(image, textures[mode])
+ p.paint_input(position)
+
+
+func _paint_splat16(data: HTerrainData, position: Vector2):
+ # Make sure required maps are present
+ while data.get_map_count(HTerrainData.CHANNEL_SPLAT) < 4:
+ data._edit_add_map(HTerrainData.CHANNEL_SPLAT)
+
+ var splats := []
+ for i in 4:
+ splats.append(Color(0.0, 0.0, 0.0, 0.0))
+ splats[_texture_index / 4][_texture_index % 4] = 1.0
+
+ var textures := []
+ for i in 4:
+ textures.append(data.get_texture(HTerrainData.CHANNEL_SPLAT, i, true))
+
+ var heightmap_texture := data.get_texture(HTerrainData.CHANNEL_HEIGHT, 0)
+
+ for i in 4:
+ var image : Image = data.get_image(HTerrainData.CHANNEL_SPLAT, i)
+ var texture : Texture = textures[i]
+
+ var mm := HT_ModifiedMap.new()
+ mm.map_type = HTerrainData.CHANNEL_SPLAT
+ mm.map_index = i
+ mm.painter_index = i
+ _modified_maps.append(mm)
+
+ var p : HT_Painter = _painters[i]
+
+ var other_splatmaps := []
+ for tex in textures:
+ if tex != texture:
+ other_splatmaps.append(tex)
+
+ p.set_brush_shader(HT_Splat16Shader)
+ p.set_brush_shader_param("u_splat", splats[i])
+ p.set_brush_shader_param("u_other_splatmap_1", other_splatmaps[0])
+ p.set_brush_shader_param("u_other_splatmap_2", other_splatmaps[1])
+ p.set_brush_shader_param("u_other_splatmap_3", other_splatmaps[2])
+ _set_slope_limit_shader_params(p, heightmap_texture)
+ p.set_image(image, texture)
+ p.paint_input(position)
+
+
+func _paint_color(data: HTerrainData, position: Vector2):
+ var image := data.get_image(HTerrainData.CHANNEL_COLOR)
+ var texture := data.get_texture(HTerrainData.CHANNEL_COLOR, 0, true)
+
+ var mm := HT_ModifiedMap.new()
+ mm.map_type = HTerrainData.CHANNEL_COLOR
+ mm.map_index = 0
+ mm.painter_index = 0
+ _modified_maps = [mm]
+
+ var p : HT_Painter = _painters[0]
+
+ # There was a problem with painting colors because of sRGB
+ # https://github.com/Zylann/godot_heightmap_plugin/issues/17#issuecomment-734001879
+
+ p.set_brush_shader(HT_ColorShader)
+ p.set_brush_shader_param("u_color", _color)
+ p.set_brush_shader_param("u_normal_min_y", 0.0)
+ p.set_brush_shader_param("u_normal_max_y", 1.0)
+ p.set_image(image, texture)
+ p.paint_input(position)
+
+
+func _paint_mask(data: HTerrainData, position: Vector2):
+ var image := data.get_image(HTerrainData.CHANNEL_COLOR)
+ var texture := data.get_texture(HTerrainData.CHANNEL_COLOR, 0, true)
+
+ var mm := HT_ModifiedMap.new()
+ mm.map_type = HTerrainData.CHANNEL_COLOR
+ mm.map_index = 0
+ mm.painter_index = 0
+ _modified_maps = [mm]
+
+ var p : HT_Painter = _painters[0]
+
+ p.set_brush_shader(HT_AlphaShader)
+ p.set_brush_shader_param("u_value", 1.0 if _mask_flag else 0.0)
+ p.set_image(image, texture)
+ p.paint_input(position)
+
+
+func _paint_detail(data: HTerrainData, position: Vector2):
+ var image := data.get_image(HTerrainData.CHANNEL_DETAIL, _detail_index)
+ var texture := data.get_texture(HTerrainData.CHANNEL_DETAIL, _detail_index, true)
+ var heightmap_texture = data.get_texture(HTerrainData.CHANNEL_HEIGHT, 0)
+
+ var mm := HT_ModifiedMap.new()
+ mm.map_type = HTerrainData.CHANNEL_DETAIL
+ mm.map_index = _detail_index
+ mm.painter_index = 0
+ _modified_maps = [mm]
+
+ var p : HT_Painter = _painters[0]
+ var c := Color(_detail_density, _detail_density, _detail_density, 1.0)
+
+ # TODO Don't use this shader (why?)
+ p.set_brush_shader(HT_ColorShader)
+ p.set_brush_shader_param("u_color", c)
+ _set_slope_limit_shader_params(p, heightmap_texture)
+ p.set_image(image, texture)
+ p.paint_input(position)
+
+
+func _set_slope_limit_shader_params(p: HT_Painter, heightmap_texture: Texture):
+ p.set_brush_shader_param("u_normal_min_y", cos(_slope_limit_high_angle))
+ p.set_brush_shader_param("u_normal_max_y", cos(_slope_limit_low_angle) + 0.001)
+ p.set_brush_shader_param("u_heightmap", heightmap_texture)
diff --git a/game/addons/zylann.hterrain/tools/bump2normal_tex.gdshader b/game/addons/zylann.hterrain/tools/bump2normal_tex.gdshader
new file mode 100644
index 0000000..b0c97da
--- /dev/null
+++ b/game/addons/zylann.hterrain/tools/bump2normal_tex.gdshader
@@ -0,0 +1,25 @@
+shader_type canvas_item;
+
+#include "res://addons/zylann.hterrain/shaders/include/heightmap.gdshaderinc"
+
+vec4 pack_normal(vec3 n) {
+ return vec4((0.5 * (n + 1.0)).xzy, 1.0);
+}
+
+float get_height(sampler2D tex, vec2 uv) {
+ return sample_heightmap(tex, uv);
+}
+
+void fragment() {
+ vec2 uv = UV;
+ vec2 ps = TEXTURE_PIXEL_SIZE;
+ float left = get_height(TEXTURE, uv + vec2(-ps.x, 0));
+ float right = get_height(TEXTURE, uv + vec2(ps.x, 0));
+ float back = get_height(TEXTURE, uv + vec2(0, -ps.y));
+ float fore = get_height(TEXTURE, uv + vec2(0, ps.y));
+ vec3 n = normalize(vec3(left - right, 2.0, fore - back));
+ COLOR = pack_normal(n);
+ // DEBUG
+ //COLOR.r = fract(TIME * 100.0);
+}
+
diff --git a/game/addons/zylann.hterrain/tools/detail_editor/detail_editor.gd b/game/addons/zylann.hterrain/tools/detail_editor/detail_editor.gd
new file mode 100644
index 0000000..cb3288e
--- /dev/null
+++ b/game/addons/zylann.hterrain/tools/detail_editor/detail_editor.gd
@@ -0,0 +1,200 @@
+@tool
+extends Control
+
+const HTerrain = preload("../../hterrain.gd")
+const HTerrainData = preload("../../hterrain_data.gd")
+const HTerrainDetailLayer = preload("../../hterrain_detail_layer.gd")
+const HT_ImageFileCache = preload("../../util/image_file_cache.gd")
+const HT_EditorUtil = preload("../util/editor_util.gd")
+const HT_Logger = preload("../../util/logger.gd")
+
+# TODO Can't preload because it causes the plugin to fail loading if assets aren't imported
+const PLACEHOLDER_ICON_TEXTURE = "res://addons/zylann.hterrain/tools/icons/icon_grass.svg"
+const DETAIL_LAYER_ICON_TEXTURE = \
+ "res://addons/zylann.hterrain/tools/icons/icon_detail_layer_node.svg"
+
+signal detail_selected(index)
+# Emitted when the tool added or removed a detail map
+signal detail_list_changed
+
+@onready var _item_list : ItemList = $ItemList
+@onready var _confirmation_dialog : ConfirmationDialog = $ConfirmationDialog
+
+var _terrain : HTerrain = null
+var _dialog_target := -1
+var _undo_redo_manager : EditorUndoRedoManager
+var _image_cache : HT_ImageFileCache
+var _logger = HT_Logger.get_for(self)
+
+
+func set_terrain(terrain):
+ if _terrain == terrain:
+ return
+ _terrain = terrain
+ _update_list()
+
+
+func set_undo_redo(ur: EditorUndoRedoManager):
+ assert(ur != null)
+ _undo_redo_manager = ur
+
+
+func set_image_cache(image_cache: HT_ImageFileCache):
+ _image_cache = image_cache
+
+
+func set_layer_index(i: int):
+ _item_list.select(i, true)
+
+
+func _update_list():
+ _item_list.clear()
+
+ if _terrain == null:
+ return
+
+ var layer_nodes = _terrain.get_detail_layers()
+ var layer_nodes_by_index := {}
+ for layer in layer_nodes:
+ if not layer_nodes_by_index.has(layer.layer_index):
+ layer_nodes_by_index[layer.layer_index] = []
+ layer_nodes_by_index[layer.layer_index].append(layer.name)
+
+ var data = _terrain.get_data()
+ if data != null:
+ # Display layers from what terrain data actually contains,
+ # because layer nodes are just what makes them rendered and aren't much restricted.
+ var layer_count = data.get_map_count(HTerrainData.CHANNEL_DETAIL)
+ var placeholder_icon = HT_EditorUtil.load_texture(PLACEHOLDER_ICON_TEXTURE, _logger)
+
+ for i in layer_count:
+ # TODO Show a preview icon
+ _item_list.add_item(str("Map ", i), placeholder_icon)
+
+ if layer_nodes_by_index.has(i):
+ # TODO How to keep names updated with node names?
+ var names := ", ".join(PackedStringArray(layer_nodes_by_index[i]))
+ if len(names) == 1:
+ _item_list.set_item_tooltip(i, "Used by " + names)
+ else:
+ _item_list.set_item_tooltip(i, "Used by " + names)
+ # Remove custom color
+ # TODO Use fg version when available in Godot 3.1, I want to only highlight text
+ _item_list.set_item_custom_bg_color(i, Color(0, 0, 0, 0))
+ else:
+ # TODO Use fg version when available in Godot 3.1, I want to only highlight text
+ _item_list.set_item_custom_bg_color(i, Color(1.0, 0.2, 0.2, 0.3))
+ _item_list.set_item_tooltip(i, "This map isn't used by any layer. " \
+ + "Add a HTerrainDetailLayer node as child of the terrain.")
+
+
+func _on_Add_pressed():
+ _add_layer()
+
+
+func _on_Remove_pressed():
+ var selected = _item_list.get_selected_items()
+ if len(selected) == 0:
+ return
+ _dialog_target = _item_list.get_selected_items()[0]
+ _confirmation_dialog.title = "Removing detail map {0}".format([_dialog_target])
+ _confirmation_dialog.popup_centered()
+
+
+func _on_ConfirmationDialog_confirmed():
+ _remove_layer(_dialog_target)
+
+
+func _add_layer():
+ assert(_terrain != null)
+ assert(_terrain.get_data() != null)
+ assert(_undo_redo_manager != null)
+ var terrain_data : HTerrainData = _terrain.get_data()
+
+ # First, create node and map image
+ var node := HTerrainDetailLayer.new()
+ # TODO Workarounds for https://github.com/godotengine/godot/issues/21410
+ var detail_layer_icon := HT_EditorUtil.load_texture(DETAIL_LAYER_ICON_TEXTURE, _logger)
+ node.set_meta("_editor_icon", detail_layer_icon)
+ node.name = "HTerrainDetailLayer"
+ var map_index := terrain_data._edit_add_map(HTerrainData.CHANNEL_DETAIL)
+ var map_image := terrain_data.get_image(HTerrainData.CHANNEL_DETAIL)
+ var map_image_cache_id := _image_cache.save_image(map_image)
+ node.layer_index = map_index
+
+ var undo_redo := _undo_redo_manager.get_history_undo_redo(
+ _undo_redo_manager.get_object_history_id(_terrain))
+
+ # Then, create an action
+ undo_redo.create_action("Add Detail Layer {0}".format([map_index]))
+
+ undo_redo.add_do_method(terrain_data._edit_insert_map_from_image_cache.bind(
+ HTerrainData.CHANNEL_DETAIL, map_index, _image_cache, map_image_cache_id))
+ undo_redo.add_do_method(_terrain.add_child.bind(node))
+ undo_redo.add_do_property(node, "owner", get_tree().edited_scene_root)
+ undo_redo.add_do_method(self._update_list)
+ undo_redo.add_do_reference(node)
+
+ undo_redo.add_undo_method(_terrain.remove_child.bind(node))
+ undo_redo.add_undo_method(
+ terrain_data._edit_remove_map.bind(HTerrainData.CHANNEL_DETAIL, map_index))
+ undo_redo.add_undo_method(self._update_list)
+
+ # Yet another instance of this hack, to prevent UndoRedo from running some of the functions,
+ # which we had to run already
+ terrain_data._edit_set_disable_apply_undo(true)
+ undo_redo.commit_action()
+ terrain_data._edit_set_disable_apply_undo(false)
+
+ #_update_list()
+ detail_list_changed.emit()
+
+ var index := node.layer_index
+ _item_list.select(index)
+ # select() doesn't trigger the signal
+ detail_selected.emit(index)
+
+
+func _remove_layer(map_index: int):
+ var terrain_data : HTerrainData = _terrain.get_data()
+
+ # First, cache image data
+ var image := terrain_data.get_image(HTerrainData.CHANNEL_DETAIL, map_index)
+ var image_id := _image_cache.save_image(image)
+ var nodes = _terrain.get_detail_layers()
+ var using_nodes := []
+ # Nodes using this map will be removed from the tree
+ for node in nodes:
+ if node.layer_index == map_index:
+ using_nodes.append(node)
+
+ var undo_redo := _undo_redo_manager.get_history_undo_redo(
+ _undo_redo_manager.get_object_history_id(_terrain))
+
+ undo_redo.create_action("Remove Detail Layer {0}".format([map_index]))
+
+ undo_redo.add_do_method(
+ terrain_data._edit_remove_map.bind(HTerrainData.CHANNEL_DETAIL, map_index))
+ for node in using_nodes:
+ undo_redo.add_do_method(_terrain.remove_child.bind(node))
+ undo_redo.add_do_method(self._update_list)
+
+ undo_redo.add_undo_method(terrain_data._edit_insert_map_from_image_cache.bind(
+ HTerrainData.CHANNEL_DETAIL, map_index, _image_cache, image_id))
+ for node in using_nodes:
+ undo_redo.add_undo_method(_terrain.add_child.bind(node))
+ undo_redo.add_undo_property(node, "owner", get_tree().edited_scene_root)
+ undo_redo.add_undo_reference(node)
+ undo_redo.add_undo_method(self._update_list)
+
+ undo_redo.commit_action()
+
+ #_update_list()
+ detail_list_changed.emit()
+
+
+func _on_ItemList_item_selected(index):
+ detail_selected.emit(index)
+
+
+
diff --git a/game/addons/zylann.hterrain/tools/detail_editor/detail_editor.tscn b/game/addons/zylann.hterrain/tools/detail_editor/detail_editor.tscn
new file mode 100644
index 0000000..cc5b995
--- /dev/null
+++ b/game/addons/zylann.hterrain/tools/detail_editor/detail_editor.tscn
@@ -0,0 +1,48 @@
+[gd_scene load_steps=2 format=3 uid="uid://do3c3jse5p7hx"]
+
+[ext_resource type="Script" path="res://addons/zylann.hterrain/tools/detail_editor/detail_editor.gd" id="1"]
+
+[node name="DetailEditor" type="Control"]
+custom_minimum_size = Vector2(200, 0)
+layout_mode = 3
+anchors_preset = 0
+offset_right = 189.0
+offset_bottom = 109.0
+script = ExtResource("1")
+
+[node name="ItemList" type="ItemList" parent="."]
+layout_mode = 0
+anchor_right = 1.0
+anchor_bottom = 1.0
+offset_bottom = -26.0
+max_columns = 0
+same_column_width = true
+icon_mode = 0
+fixed_icon_size = Vector2i(32, 32)
+
+[node name="HBoxContainer" type="HBoxContainer" parent="."]
+layout_mode = 0
+anchor_top = 1.0
+anchor_right = 1.0
+anchor_bottom = 1.0
+offset_top = -24.0
+
+[node name="Add" type="Button" parent="HBoxContainer"]
+layout_mode = 2
+text = "Add"
+
+[node name="Remove" type="Button" parent="HBoxContainer"]
+layout_mode = 2
+text = "Remove"
+
+[node name="Label" type="Label" parent="HBoxContainer"]
+layout_mode = 2
+text = "Details"
+
+[node name="ConfirmationDialog" type="ConfirmationDialog" parent="."]
+dialog_text = "Are you sure you want to remove this detail map?"
+
+[connection signal="item_selected" from="ItemList" to="." method="_on_ItemList_item_selected"]
+[connection signal="pressed" from="HBoxContainer/Add" to="." method="_on_Add_pressed"]
+[connection signal="pressed" from="HBoxContainer/Remove" to="." method="_on_Remove_pressed"]
+[connection signal="confirmed" from="ConfirmationDialog" to="." method="_on_ConfirmationDialog_confirmed"]
diff --git a/game/addons/zylann.hterrain/tools/exporter/export_image_dialog.gd b/game/addons/zylann.hterrain/tools/exporter/export_image_dialog.gd
new file mode 100644
index 0000000..b989099
--- /dev/null
+++ b/game/addons/zylann.hterrain/tools/exporter/export_image_dialog.gd
@@ -0,0 +1,221 @@
+@tool
+extends AcceptDialog
+
+const HTerrain = preload("../../hterrain.gd")
+const HTerrainData = preload("../../hterrain_data.gd")
+const HT_Errors = preload("../../util/errors.gd")
+const HT_Util = preload("../../util/util.gd")
+const HT_Logger = preload("../../util/logger.gd")
+
+const FORMAT_RH = 0
+const FORMAT_RF = 1
+const FORMAT_R16 = 2
+const FORMAT_PNG8 = 3
+const FORMAT_EXRH = 4
+const FORMAT_EXRF = 5
+const FORMAT_COUNT = 6
+
+@onready var _output_path_line_edit := $VB/Grid/OutputPath/HeightmapPathLineEdit as LineEdit
+@onready var _format_selector := $VB/Grid/FormatSelector as OptionButton
+@onready var _height_range_min_spinbox := $VB/Grid/HeightRange/HeightRangeMin as SpinBox
+@onready var _height_range_max_spinbox := $VB/Grid/HeightRange/HeightRangeMax as SpinBox
+@onready var _export_button := $VB/Buttons/ExportButton as Button
+@onready var _show_in_explorer_checkbox := $VB/ShowInExplorerCheckbox as CheckBox
+
+var _terrain : HTerrain = null
+var _file_dialog : EditorFileDialog = null
+var _format_names := []
+var _format_extensions := []
+var _logger = HT_Logger.get_for(self)
+
+
+func _init():
+ # Godot 4 decided to not have a plain WindowDialog class...
+ # there is Window but it's way too unfriendly...
+ get_ok_button().hide()
+
+
+func _ready():
+ _format_names.resize(FORMAT_COUNT)
+ _format_extensions.resize(FORMAT_COUNT)
+
+ _format_names[FORMAT_RH] = "16-bit RAW float"
+ _format_names[FORMAT_RF] = "32-bit RAW float"
+ _format_names[FORMAT_R16] = "16-bit RAW unsigned"
+ _format_names[FORMAT_PNG8] = "8-bit PNG greyscale"
+ _format_names[FORMAT_EXRH] = "16-bit float greyscale EXR"
+ _format_names[FORMAT_EXRF] = "32-bit float greyscale EXR"
+
+ _format_extensions[FORMAT_RH] = "raw"
+ _format_extensions[FORMAT_RF] = "raw"
+ _format_extensions[FORMAT_R16] = "raw"
+ _format_extensions[FORMAT_PNG8] = "png"
+ _format_extensions[FORMAT_EXRH] = "exr"
+ _format_extensions[FORMAT_EXRF] = "exr"
+
+ if not HT_Util.is_in_edited_scene(self):
+ for i in len(_format_names):
+ _format_selector.get_popup().add_item(_format_names[i], i)
+
+
+func setup_dialogs(base_control: Control):
+ assert(_file_dialog == null)
+ var fd := EditorFileDialog.new()
+ fd.file_mode = EditorFileDialog.FILE_MODE_SAVE_FILE
+ fd.unresizable = false
+ fd.access = EditorFileDialog.ACCESS_FILESYSTEM
+ fd.file_selected.connect(_on_FileDialog_file_selected)
+ add_child(fd)
+ _file_dialog = fd
+
+ _update_file_extension()
+
+
+func set_terrain(terrain: HTerrain):
+ _terrain = terrain
+
+
+func _exit_tree():
+ if _file_dialog != null:
+ _file_dialog.queue_free()
+ _file_dialog = null
+
+
+func _on_FileDialog_file_selected(fpath: String):
+ _output_path_line_edit.text = fpath
+
+
+func _auto_adjust_height_range():
+ assert(_terrain != null)
+ assert(_terrain.get_data() != null)
+ var aabb := _terrain.get_data().get_aabb()
+ _height_range_min_spinbox.value = aabb.position.y
+ _height_range_max_spinbox.value = aabb.position.y + aabb.size.y
+
+
+func _export() -> bool:
+ assert(_terrain != null)
+ assert(_terrain.get_data() != null)
+ var src_heightmap: Image = _terrain.get_data().get_image(HTerrainData.CHANNEL_HEIGHT)
+ var fpath := _output_path_line_edit.text.strip_edges()
+
+ # TODO Is `selected` an ID or an index? I need an ID, it works by chance for now.
+ var format := _format_selector.selected
+
+ var height_min := _height_range_min_spinbox.value
+ var height_max := _height_range_max_spinbox.value
+
+ if height_min == height_max:
+ _logger.error("Cannot export, height range is zero")
+ return false
+
+ if height_min > height_max:
+ _logger.error("Cannot export, height min is greater than max")
+ return false
+
+ var save_error := OK
+
+ var float_heightmap := HTerrainData.convert_heightmap_to_float(src_heightmap, _logger)
+
+ if format == FORMAT_PNG8:
+ var hscale := 1.0 / (height_max - height_min)
+ var im := Image.create(
+ src_heightmap.get_width(), src_heightmap.get_height(), false, Image.FORMAT_R8)
+
+ for y in src_heightmap.get_height():
+ for x in src_heightmap.get_width():
+ var h := clampf((float_heightmap.get_pixel(x, y).r - height_min) * hscale, 0.0, 1.0)
+ im.set_pixel(x, y, Color(h, h, h))
+
+ save_error = im.save_png(fpath)
+
+ elif format == FORMAT_EXRH:
+ float_heightmap.convert(Image.FORMAT_RH)
+ save_error = float_heightmap.save_exr(fpath, true)
+
+ elif format == FORMAT_EXRF:
+ save_error = float_heightmap.save_exr(fpath, true)
+
+ else: # RAW
+ var f := FileAccess.open(fpath, FileAccess.WRITE)
+ if f == null:
+ var err := FileAccess.get_open_error()
+ _print_file_error(fpath, err)
+ return false
+
+ if format == FORMAT_RH:
+ float_heightmap.convert(Image.FORMAT_RH)
+ f.store_buffer(float_heightmap.get_data())
+
+ elif format == FORMAT_RF:
+ f.store_buffer(float_heightmap.get_data())
+
+ elif format == FORMAT_R16:
+ var hscale := 65535.0 / (height_max - height_min)
+ for y in float_heightmap.get_height():
+ for x in float_heightmap.get_width():
+ var h := int((float_heightmap.get_pixel(x, y).r - height_min) * hscale)
+ if h < 0:
+ h = 0
+ elif h > 65535:
+ h = 65535
+ if x % 50 == 0:
+ _logger.debug(str(h))
+ f.store_16(h)
+
+ if save_error == OK:
+ _logger.debug("Exported heightmap as \"{0}\"".format([fpath]))
+ return true
+ else:
+ _print_file_error(fpath, save_error)
+ return false
+
+
+func _update_file_extension():
+ if _format_selector.selected == -1:
+ _format_selector.selected = 0
+ # This recursively calls the current function
+ return
+
+ # TODO Is `selected` an ID or an index? I need an ID, it works by chance for now.
+ var format = _format_selector.selected
+
+ var ext : String = _format_extensions[format]
+ _file_dialog.clear_filters()
+ _file_dialog.add_filter(str("*.", ext, " ; ", ext.to_upper(), " files"))
+
+ var fpath := _output_path_line_edit.text.strip_edges()
+ if fpath != "":
+ _output_path_line_edit.text = str(fpath.get_basename(), ".", ext)
+
+
+func _print_file_error(fpath: String, err: int):
+ _logger.error("Could not save path {0}, error: {1}" \
+ .format([fpath, HT_Errors.get_message(err)]))
+
+
+func _on_CancelButton_pressed():
+ hide()
+
+
+func _on_ExportButton_pressed():
+ if _export():
+ hide()
+ if _show_in_explorer_checkbox.button_pressed:
+ OS.shell_open(_output_path_line_edit.text.strip_edges().get_base_dir())
+
+
+func _on_HeightmapPathLineEdit_text_changed(new_text: String):
+ _export_button.disabled = (new_text.strip_edges() == "")
+
+
+func _on_HeightmapPathBrowseButton_pressed():
+ _file_dialog.popup_centered_ratio()
+
+
+func _on_FormatSelector_item_selected(id):
+ _update_file_extension()
+
+
+func _on_HeightRangeAutoButton_pressed():
+ _auto_adjust_height_range()
diff --git a/game/addons/zylann.hterrain/tools/exporter/export_image_dialog.tscn b/game/addons/zylann.hterrain/tools/exporter/export_image_dialog.tscn
new file mode 100644
index 0000000..a702181
--- /dev/null
+++ b/game/addons/zylann.hterrain/tools/exporter/export_image_dialog.tscn
@@ -0,0 +1,125 @@
+[gd_scene load_steps=3 format=3 uid="uid://bcocysgmum5ag"]
+
+[ext_resource type="Script" path="res://addons/zylann.hterrain/tools/exporter/export_image_dialog.gd" id="1"]
+[ext_resource type="PackedScene" path="res://addons/zylann.hterrain/tools/util/dialog_fitter.tscn" id="2"]
+
+[node name="ExportImageDialog" type="AcceptDialog"]
+size = Vector2i(500, 340)
+min_size = Vector2i(500, 250)
+script = ExtResource("1")
+
+[node name="VB" type="VBoxContainer" parent="."]
+anchors_preset = 15
+anchor_right = 1.0
+anchor_bottom = 1.0
+offset_left = 8.0
+offset_top = 8.0
+offset_right = -8.0
+offset_bottom = -18.0
+
+[node name="Grid" type="GridContainer" parent="VB"]
+layout_mode = 2
+columns = 2
+
+[node name="OutputPathLabel" type="Label" parent="VB/Grid"]
+layout_mode = 2
+text = "Output path:"
+
+[node name="OutputPath" type="HBoxContainer" parent="VB/Grid"]
+layout_mode = 2
+size_flags_horizontal = 3
+
+[node name="HeightmapPathLineEdit" type="LineEdit" parent="VB/Grid/OutputPath"]
+layout_mode = 2
+size_flags_horizontal = 3
+
+[node name="HeightmapPathBrowseButton" type="Button" parent="VB/Grid/OutputPath"]
+layout_mode = 2
+text = "..."
+
+[node name="FormatLabel" type="Label" parent="VB/Grid"]
+layout_mode = 2
+text = "Format:"
+
+[node name="FormatSelector" type="OptionButton" parent="VB/Grid"]
+layout_mode = 2
+
+[node name="HeightRangeLabel" type="Label" parent="VB/Grid"]
+layout_mode = 2
+text = "Height range:"
+
+[node name="HeightRange" type="HBoxContainer" parent="VB/Grid"]
+layout_mode = 2
+
+[node name="Label" type="Label" parent="VB/Grid/HeightRange"]
+layout_mode = 2
+text = "Min"
+
+[node name="HeightRangeMin" type="SpinBox" parent="VB/Grid/HeightRange"]
+custom_minimum_size = Vector2(100, 0)
+layout_mode = 2
+min_value = -10000.0
+max_value = 10000.0
+step = 0.0
+value = -2000.0
+
+[node name="Label2" type="Label" parent="VB/Grid/HeightRange"]
+layout_mode = 2
+text = "Max"
+
+[node name="HeightRangeMax" type="SpinBox" parent="VB/Grid/HeightRange"]
+custom_minimum_size = Vector2(100, 0)
+layout_mode = 2
+min_value = -10000.0
+max_value = 10000.0
+step = 0.0
+value = 2000.0
+
+[node name="HeightRangeAutoButton" type="Button" parent="VB/Grid/HeightRange"]
+layout_mode = 2
+size_flags_horizontal = 3
+text = "Auto"
+
+[node name="ShowInExplorerCheckbox" type="CheckBox" parent="VB"]
+layout_mode = 2
+text = "Show in explorer after export"
+
+[node name="Spacer" type="Control" parent="VB"]
+custom_minimum_size = Vector2(0, 16)
+layout_mode = 2
+
+[node name="Label" type="Label" parent="VB"]
+layout_mode = 2
+text = "Note: height range is needed for integer image formats, as they can't directly represent the real height. 8-bit formats may cause precision loss."
+autowrap_mode = 2
+
+[node name="Spacer2" type="Control" parent="VB"]
+custom_minimum_size = Vector2(0, 16)
+layout_mode = 2
+
+[node name="Buttons" type="HBoxContainer" parent="VB"]
+layout_mode = 2
+alignment = 1
+
+[node name="ExportButton" type="Button" parent="VB/Buttons"]
+layout_mode = 2
+text = "Export"
+
+[node name="CancelButton" type="Button" parent="VB/Buttons"]
+layout_mode = 2
+text = "Cancel"
+
+[node name="DialogFitter" parent="." instance=ExtResource("2")]
+layout_mode = 3
+anchors_preset = 0
+offset_left = 8.0
+offset_top = 8.0
+offset_right = 492.0
+offset_bottom = 322.0
+
+[connection signal="text_changed" from="VB/Grid/OutputPath/HeightmapPathLineEdit" to="." method="_on_HeightmapPathLineEdit_text_changed"]
+[connection signal="pressed" from="VB/Grid/OutputPath/HeightmapPathBrowseButton" to="." method="_on_HeightmapPathBrowseButton_pressed"]
+[connection signal="item_selected" from="VB/Grid/FormatSelector" to="." method="_on_FormatSelector_item_selected"]
+[connection signal="pressed" from="VB/Grid/HeightRange/HeightRangeAutoButton" to="." method="_on_HeightRangeAutoButton_pressed"]
+[connection signal="pressed" from="VB/Buttons/ExportButton" to="." method="_on_ExportButton_pressed"]
+[connection signal="pressed" from="VB/Buttons/CancelButton" to="." method="_on_CancelButton_pressed"]
diff --git a/game/addons/zylann.hterrain/tools/generate_mesh_dialog.gd b/game/addons/zylann.hterrain/tools/generate_mesh_dialog.gd
new file mode 100644
index 0000000..f567e02
--- /dev/null
+++ b/game/addons/zylann.hterrain/tools/generate_mesh_dialog.gd
@@ -0,0 +1,54 @@
+@tool
+extends AcceptDialog
+
+signal generate_selected(lod)
+
+const HTerrain = preload("../hterrain.gd")
+const HTerrainMesher = preload("../hterrain_mesher.gd")
+const HT_Util = preload("../util/util.gd")
+
+@onready var _preview_label : Label = $VBoxContainer/PreviewLabel
+@onready var _lod_spinbox : SpinBox = $VBoxContainer/HBoxContainer/LODSpinBox
+
+var _terrain : HTerrain = null
+
+
+func _init():
+ get_ok_button().hide()
+
+
+func set_terrain(terrain: HTerrain):
+ _terrain = terrain
+
+
+func _notification(what: int):
+ if what == NOTIFICATION_VISIBILITY_CHANGED:
+ if visible and _terrain != null:
+ _update_preview()
+
+
+func _on_LODSpinBox_value_changed(value):
+ _update_preview()
+
+
+func _update_preview():
+ assert(_terrain != null)
+ assert(_terrain.get_data() != null)
+ var resolution := _terrain.get_data().get_resolution()
+ var stride := int(_lod_spinbox.value)
+ resolution /= stride
+ var s := HTerrainMesher.get_mesh_size(resolution, resolution)
+ _preview_label.text = str( \
+ HT_Util.format_integer(s.vertices), " vertices, ", \
+ HT_Util.format_integer(s.triangles), " triangles")
+
+
+func _on_Generate_pressed():
+ var stride := int(_lod_spinbox.value)
+ generate_selected.emit(stride)
+ hide()
+
+
+func _on_Cancel_pressed():
+ hide()
+
diff --git a/game/addons/zylann.hterrain/tools/generate_mesh_dialog.tscn b/game/addons/zylann.hterrain/tools/generate_mesh_dialog.tscn
new file mode 100644
index 0000000..12f3d4a
--- /dev/null
+++ b/game/addons/zylann.hterrain/tools/generate_mesh_dialog.tscn
@@ -0,0 +1,69 @@
+[gd_scene load_steps=3 format=3 uid="uid://ci0da54goyo5o"]
+
+[ext_resource type="Script" path="res://addons/zylann.hterrain/tools/generate_mesh_dialog.gd" id="1"]
+[ext_resource type="PackedScene" path="res://addons/zylann.hterrain/tools/util/dialog_fitter.tscn" id="2"]
+
+[node name="GenerateMeshDialog" type="AcceptDialog"]
+title = "Generate full mesh"
+size = Vector2i(448, 234)
+min_size = Vector2i(448, 186)
+script = ExtResource("1")
+
+[node name="VBoxContainer" type="VBoxContainer" parent="."]
+anchors_preset = 15
+anchor_right = 1.0
+anchor_bottom = 1.0
+offset_left = 8.0
+offset_top = 8.0
+offset_right = -8.0
+offset_bottom = -18.0
+
+[node name="HBoxContainer" type="HBoxContainer" parent="VBoxContainer"]
+layout_mode = 2
+
+[node name="Label" type="Label" parent="VBoxContainer/HBoxContainer"]
+layout_mode = 2
+text = "LOD"
+
+[node name="LODSpinBox" type="SpinBox" parent="VBoxContainer/HBoxContainer"]
+layout_mode = 2
+size_flags_horizontal = 3
+min_value = 1.0
+max_value = 16.0
+value = 1.0
+
+[node name="PreviewLabel" type="Label" parent="VBoxContainer"]
+layout_mode = 2
+text = "9999 vertices, 9999 triangles"
+
+[node name="Spacer" type="Control" parent="VBoxContainer"]
+layout_mode = 2
+
+[node name="Label" type="Label" parent="VBoxContainer"]
+layout_mode = 2
+text = "Note: generating a full mesh from the terrain may result in a huge amount of vertices for a single object. It is preferred to do this for small terrains, or as a temporary workaround to generate a navmesh."
+autowrap_mode = 2
+
+[node name="Buttons" type="HBoxContainer" parent="VBoxContainer"]
+layout_mode = 2
+alignment = 1
+
+[node name="Generate" type="Button" parent="VBoxContainer/Buttons"]
+layout_mode = 2
+text = "Generate"
+
+[node name="Cancel" type="Button" parent="VBoxContainer/Buttons"]
+layout_mode = 2
+text = "Cancel"
+
+[node name="DialogFitter" parent="." instance=ExtResource("2")]
+layout_mode = 3
+anchors_preset = 0
+offset_left = 8.0
+offset_top = 8.0
+offset_right = 440.0
+offset_bottom = 216.0
+
+[connection signal="value_changed" from="VBoxContainer/HBoxContainer/LODSpinBox" to="." method="_on_LODSpinBox_value_changed"]
+[connection signal="pressed" from="VBoxContainer/Buttons/Generate" to="." method="_on_Generate_pressed"]
+[connection signal="pressed" from="VBoxContainer/Buttons/Cancel" to="." method="_on_Cancel_pressed"]
diff --git a/game/addons/zylann.hterrain/tools/generator/generator_dialog.gd b/game/addons/zylann.hterrain/tools/generator/generator_dialog.gd
new file mode 100644
index 0000000..bbb7069
--- /dev/null
+++ b/game/addons/zylann.hterrain/tools/generator/generator_dialog.gd
@@ -0,0 +1,562 @@
+@tool
+extends AcceptDialog
+
+const HTerrain = preload("../../hterrain.gd")
+const HTerrainData = preload("../../hterrain_data.gd")
+const HTerrainMesher = preload("../../hterrain_mesher.gd")
+const HT_Util = preload("../../util/util.gd")
+const HT_TextureGenerator = preload("./texture_generator.gd")
+const HT_TextureGeneratorPass = preload("./texture_generator_pass.gd")
+const HT_Logger = preload("../../util/logger.gd")
+const HT_ImageFileCache = preload("../../util/image_file_cache.gd")
+const HT_Inspector = preload("../inspector/inspector.gd")
+const HT_TerrainPreview = preload("../terrain_preview.gd")
+const HT_ProgressWindow = preload("../progress_window.gd")
+
+const HT_ProgressWindowScene = preload("../progress_window.tscn")
+
+# TODO Power of two is assumed here.
+# I wonder why it doesn't have the off by one terrain textures usually have
+const MAX_VIEWPORT_RESOLUTION = 512
+
+#signal progress_notified(info) # { "progress": real, "message": string, "finished": bool }
+
+@onready var _inspector_container : Control = $VBoxContainer/Editor/Settings
+@onready var _inspector : HT_Inspector = $VBoxContainer/Editor/Settings/Inspector
+@onready var _preview : HT_TerrainPreview = $VBoxContainer/Editor/Preview/TerrainPreview
+@onready var _progress_bar : ProgressBar = $VBoxContainer/Editor/Preview/ProgressBar
+
+var _dummy_texture = load("res://addons/zylann.hterrain/tools/icons/empty.png")
+var _terrain : HTerrain = null
+var _applying := false
+var _generator : HT_TextureGenerator
+var _generated_textures := [null, null]
+var _dialog_visible := false
+var _undo_map_ids := {}
+var _image_cache : HT_ImageFileCache = null
+var _undo_redo_manager : EditorUndoRedoManager
+var _logger := HT_Logger.get_for(self)
+var _viewport_resolution := MAX_VIEWPORT_RESOLUTION
+var _progress_window : HT_ProgressWindow
+
+
+static func get_shader(shader_name: String) -> Shader:
+ var path := "res://addons/zylann.hterrain/tools/generator/shaders"\
+ .path_join(str(shader_name, ".gdshader"))
+ return load(path) as Shader
+
+
+func _init():
+ # Godot 4 does not have a plain WindowDialog class... there is Window but it's too unfriendly...
+ get_ok_button().hide()
+
+ _progress_window = HT_ProgressWindowScene.instantiate()
+ add_child(_progress_window)
+
+
+func _ready():
+ _inspector.set_prototype({
+ "seed": {
+ "type": TYPE_INT,
+ "randomizable": true,
+ "range": { "min": -100, "max": 100 },
+ "slidable": false
+ },
+ "offset": {
+ "type": TYPE_VECTOR2
+ },
+ "base_height": {
+ "type": TYPE_FLOAT,
+ "range": {"min": -500.0, "max": 500.0, "step": 0.1 },
+ "default_value": -50.0
+ },
+ "height_range": {
+ "type": TYPE_FLOAT,
+ "range": {"min": 0.0, "max": 2000.0, "step": 0.1 },
+ "default_value": 150.0
+ },
+ "scale": {
+ "type": TYPE_FLOAT,
+ "range": {"min": 1.0, "max": 1000.0, "step": 1.0},
+ "default_value": 100.0
+ },
+ "roughness": {
+ "type": TYPE_FLOAT,
+ "range": {"min": 0.0, "max": 1.0, "step": 0.01},
+ "default_value": 0.4
+ },
+ "curve": {
+ "type": TYPE_FLOAT,
+ "range": {"min": 1.0, "max": 10.0, "step": 0.1},
+ "default_value": 1.0
+ },
+ "octaves": {
+ "type": TYPE_INT,
+ "range": {"min": 1, "max": 10, "step": 1},
+ "default_value": 6
+ },
+ "erosion_steps": {
+ "type": TYPE_INT,
+ "range": {"min": 0, "max": 100, "step": 1},
+ "default_value": 0
+ },
+ "erosion_weight": {
+ "type": TYPE_FLOAT,
+ "range": { "min": 0.0, "max": 1.0 },
+ "default_value": 0.5
+ },
+ "erosion_slope_factor": {
+ "type": TYPE_FLOAT,
+ "range": { "min": 0.0, "max": 1.0 },
+ "default_value": 0.0
+ },
+ "erosion_slope_direction": {
+ "type": TYPE_VECTOR2,
+ "default_value": Vector2(0, 0)
+ },
+ "erosion_slope_invert": {
+ "type": TYPE_BOOL,
+ "default_value": false
+ },
+ "dilation": {
+ "type": TYPE_FLOAT,
+ "range": { "min": 0.0, "max": 1.0 },
+ "default_value": 0.0
+ },
+ "island_weight": {
+ "type": TYPE_FLOAT,
+ "range": { "min": 0.0, "max": 1.0, "step": 0.01 },
+ "default_value": 0.0
+ },
+ "island_sharpness": {
+ "type": TYPE_FLOAT,
+ "range": { "min": 0.0, "max": 1.0, "step": 0.01 },
+ "default_value": 0.0
+ },
+ "island_height_ratio": {
+ "type": TYPE_FLOAT,
+ "range": { "min": -1.0, "max": 1.0, "step": 0.01 },
+ "default_value": -1.0
+ },
+ "island_shape": {
+ "type": TYPE_FLOAT,
+ "range": { "min": 0.0, "max": 1.0, "step": 0.01 },
+ "default_value": 0.0
+ },
+ "additive_heightmap": {
+ "type": TYPE_BOOL,
+ "default_value": false
+ },
+ "show_sea": {
+ "type": TYPE_BOOL,
+ "default_value": true
+ },
+ "shadows": {
+ "type": TYPE_BOOL,
+ "default_value": true
+ }
+ })
+
+ _generator = HT_TextureGenerator.new()
+ _generator.set_resolution(Vector2i(_viewport_resolution, _viewport_resolution))
+ # Setup the extra pixels we want on max edges for terrain
+ # TODO I wonder if it's not better to let the generator shaders work in pixels
+ # instead of NDC, rather than putting a padding system there
+ _generator.set_output_padding([0, 1, 0, 1])
+ _generator.output_generated.connect(_on_TextureGenerator_output_generated)
+ _generator.completed.connect(_on_TextureGenerator_completed)
+ _generator.progress_reported.connect(_on_TextureGenerator_progress_reported)
+ add_child(_generator)
+
+ # TEST
+ if not Engine.is_editor_hint():
+ call_deferred("popup_centered")
+
+
+func apply_dpi_scale(dpi_scale: float):
+ min_size *= dpi_scale
+ _inspector_container.custom_minimum_size *= dpi_scale
+
+
+func set_terrain(terrain: HTerrain):
+ _terrain = terrain
+ _adjust_viewport_resolution()
+
+
+func _adjust_viewport_resolution():
+ if _terrain == null:
+ return
+ var data = _terrain.get_data()
+ if data == null:
+ return
+ var terrain_resolution := data.get_resolution()
+
+ # By default we want to work with a large enough viewport to generate tiles,
+ # but we should pick a smaller size if the terrain is smaller than that...
+ var vp_res := MAX_VIEWPORT_RESOLUTION
+ while vp_res > terrain_resolution:
+ vp_res /= 2
+
+ _generator.set_resolution(Vector2(vp_res, vp_res))
+ _viewport_resolution = vp_res
+
+
+func set_image_cache(image_cache: HT_ImageFileCache):
+ _image_cache = image_cache
+
+
+func set_undo_redo(ur: EditorUndoRedoManager):
+ _undo_redo_manager = ur
+
+
+func _notification(what: int):
+ match what:
+ NOTIFICATION_VISIBILITY_CHANGED:
+ # We don't want any of this to run in an edited scene
+ if HT_Util.is_in_edited_scene(self):
+ return
+ # Since Godot 4 visibility can be changed even between _enter_tree and _ready
+ if _preview == null:
+ return
+
+ if visible:
+ # TODO https://github.com/godotengine/godot/issues/18160
+ if _dialog_visible:
+ return
+ _dialog_visible = true
+
+ _adjust_viewport_resolution()
+
+ _preview.set_sea_visible(_inspector.get_value("show_sea"))
+ _preview.set_shadows_enabled(_inspector.get_value("shadows"))
+
+ _update_generator(true)
+
+ else:
+# if not _applying:
+# _destroy_viewport()
+ _preview.cleanup()
+ for i in len(_generated_textures):
+ _generated_textures[i] = null
+ _dialog_visible = false
+
+
+func _update_generator(preview: bool):
+ var scale : float = _inspector.get_value("scale")
+ # Scale is inverted in the shader
+ if absf(scale) < 0.01:
+ scale = 0.0
+ else:
+ scale = 1.0 / scale
+ scale *= _viewport_resolution
+
+ var preview_scale := 4.0 # As if 2049x2049
+ var sectors := []
+ var terrain_size := 513
+
+ var additive_heightmap : Texture2D = null
+
+ # For testing
+ if not Engine.is_editor_hint() and _terrain == null:
+ sectors.append(Vector2(0, 0))
+
+ # Get preview scale and sectors to generate.
+ # Allowing null terrain to make it testable.
+ if _terrain != null and _terrain.get_data() != null:
+ var terrain_data := _terrain.get_data()
+ terrain_size = terrain_data.get_resolution()
+
+ if _inspector.get_value("additive_heightmap"):
+ additive_heightmap = terrain_data.get_texture(HTerrainData.CHANNEL_HEIGHT)
+
+ if preview:
+ # When previewing the resolution does not span the entire terrain,
+ # so we apply a scale to some of the passes to make it cover it all.
+ preview_scale = float(terrain_size) / float(_viewport_resolution)
+ sectors.append(Vector2(0, 0))
+
+ else:
+ if additive_heightmap != null:
+ # We have to duplicate the heightmap because we are going to write
+ # into it during the generation process.
+ # It would be fine when we don't read outside of a generated tile,
+ # but we actually do that for erosion: neighboring pixels are read
+ # again, and if they were modified by a previous tile it will
+ # disrupt generation, so we need to use a copy of the original.
+ additive_heightmap = additive_heightmap.duplicate()
+
+ # When we get to generate it fully, sectors are used,
+ # so the size or shape of the terrain doesn't matter
+ preview_scale = 1.0
+
+ var cw := terrain_size / _viewport_resolution
+ var ch := terrain_size / _viewport_resolution
+
+ for y in ch:
+ for x in cw:
+ sectors.append(Vector2(x, y))
+
+ var erosion_iterations := int(_inspector.get_value("erosion_steps"))
+ erosion_iterations /= int(preview_scale)
+
+ _generator.clear_passes()
+
+ # Terrain textures need to have an off-by-one on their max edge,
+ # which is shared with the other sectors.
+ var base_offset_ndc = _inspector.get_value("offset")
+ #var sector_size_offby1_ndc = float(VIEWPORT_RESOLUTION - 1) / padded_viewport_resolution
+
+ for i in len(sectors):
+ var sector = sectors[i]
+ #var offset = sector * sector_size_offby1_ndc - Vector2(pad_offset_ndc, pad_offset_ndc)
+
+# var offset_px = sector * (VIEWPORT_RESOLUTION - 1) - Vector2(pad_offset_px, pad_offset_px)
+# var offset_ndc = offset_px / padded_viewport_resolution
+ var progress := float(i) / len(sectors)
+ var p := HT_TextureGeneratorPass.new()
+ p.clear = true
+ p.shader = get_shader("perlin_noise")
+ # This pass generates the shapes of the terrain so will have to account for offset
+ p.tile_pos = sector
+ p.params = {
+ "u_octaves": _inspector.get_value("octaves"),
+ "u_seed": _inspector.get_value("seed"),
+ "u_scale": scale,
+ "u_offset": base_offset_ndc,
+ "u_base_height": _inspector.get_value("base_height") / preview_scale,
+ "u_height_range": _inspector.get_value("height_range") / preview_scale,
+ "u_roughness": _inspector.get_value("roughness"),
+ "u_curve": _inspector.get_value("curve"),
+ "u_island_weight": _inspector.get_value("island_weight"),
+ "u_island_sharpness": _inspector.get_value("island_sharpness"),
+ "u_island_height_ratio": _inspector.get_value("island_height_ratio"),
+ "u_island_shape": _inspector.get_value("island_shape"),
+ "u_additive_heightmap": additive_heightmap,
+ "u_additive_heightmap_factor": \
+ (1.0 if additive_heightmap != null else 0.0) / preview_scale,
+ "u_terrain_size": terrain_size / preview_scale,
+ "u_tile_size": _viewport_resolution
+ }
+ _generator.add_pass(p)
+
+ if erosion_iterations > 0:
+ p = HT_TextureGeneratorPass.new()
+ p.shader = get_shader("erode")
+ # TODO More erosion config
+ p.params = {
+ "u_slope_factor": _inspector.get_value("erosion_slope_factor"),
+ "u_slope_invert": _inspector.get_value("erosion_slope_invert"),
+ "u_slope_up": _inspector.get_value("erosion_slope_direction"),
+ "u_weight": _inspector.get_value("erosion_weight"),
+ "u_dilation": _inspector.get_value("dilation")
+ }
+ p.iterations = erosion_iterations
+ p.padding = p.iterations
+ _generator.add_pass(p)
+
+ _generator.add_output({
+ "maptype": HTerrainData.CHANNEL_HEIGHT,
+ "sector": sector,
+ "progress": progress
+ })
+
+ p = HT_TextureGeneratorPass.new()
+ p.shader = get_shader("bump2normal")
+ p.padding = 1
+ _generator.add_pass(p)
+
+ _generator.add_output({
+ "maptype": HTerrainData.CHANNEL_NORMAL,
+ "sector": sector,
+ "progress": progress
+ })
+
+ # TODO AO generation
+ # TODO Splat generation
+ _generator.run()
+
+
+func _on_CancelButton_pressed():
+ hide()
+
+
+func _on_ApplyButton_pressed():
+ # We used to hide the dialog when the Apply button is clicked, and then texture generation took
+ # place in an offscreen viewport in multiple tiled stages, with a progress window being shown.
+ # But in Godot 4, it turns out SubViewports never update if they are child of a hidden Window,
+ # even if they are set to UPDATE_ALWAYS...
+ #hide()
+
+ _apply()
+
+
+func _on_Inspector_property_changed(key, value):
+ match key:
+ "show_sea":
+ _preview.set_sea_visible(value)
+ "shadows":
+ _preview.set_shadows_enabled(value)
+ _:
+ _update_generator(true)
+
+
+func _on_TerrainPreview_dragged(relative: Vector2, button_mask: int):
+ if button_mask & MOUSE_BUTTON_MASK_LEFT:
+ var offset : Vector2 = _inspector.get_value("offset")
+ offset += relative
+ _inspector.set_value("offset", offset)
+
+
+func _apply():
+ if _terrain == null:
+ _logger.error("cannot apply, terrain is null")
+ return
+
+ var data := _terrain.get_data()
+ if data == null:
+ _logger.error("cannot apply, terrain data is null")
+ return
+
+ var dst_heights := data.get_image(HTerrainData.CHANNEL_HEIGHT)
+ if dst_heights == null:
+ _logger.error("terrain heightmap image isn't loaded")
+ return
+
+ var dst_normals := data.get_image(HTerrainData.CHANNEL_NORMAL)
+ if dst_normals == null:
+ _logger.error("terrain normal image isn't loaded")
+ return
+
+ _applying = true
+
+ _undo_map_ids[HTerrainData.CHANNEL_HEIGHT] = _image_cache.save_image(dst_heights)
+ _undo_map_ids[HTerrainData.CHANNEL_NORMAL] = _image_cache.save_image(dst_normals)
+
+ _update_generator(false)
+
+
+func _on_TextureGenerator_progress_reported(info: Dictionary):
+ if _applying:
+ return
+ var p := 0.0
+ if info.pass_index == 1:
+ p = float(info.iteration) / float(info.iteration_count)
+ _progress_bar.show()
+ _progress_bar.ratio = p
+
+
+func _on_TextureGenerator_output_generated(image: Image, info: Dictionary):
+ # TODO We should check the terrain's image format,
+ # but that would prevent from testing in isolation...
+ if info.maptype == HTerrainData.CHANNEL_HEIGHT:
+ # Hack to workaround Godot 4.0 not supporting RF viewports. Heights are packed as floats
+ # into RGBA8 components.
+ assert(image.get_format() == Image.FORMAT_RGBA8)
+ image = Image.create_from_data(image.get_width(), image.get_height(), false,
+ Image.FORMAT_RF, image.get_data())
+
+ if not _applying:
+ # Update preview
+ # TODO Improve TextureGenerator so we can get a ViewportTexture per output?
+ var tex = _generated_textures[info.maptype]
+ if tex == null:
+ tex = ImageTexture.create_from_image(image)
+ _generated_textures[info.maptype] = tex
+ else:
+ tex.update(image)
+
+ var num_set := 0
+ for v in _generated_textures:
+ if v != null:
+ num_set += 1
+ if num_set == len(_generated_textures):
+ _preview.setup( \
+ _generated_textures[HTerrainData.CHANNEL_HEIGHT],
+ _generated_textures[HTerrainData.CHANNEL_NORMAL])
+ else:
+ assert(_terrain != null)
+ var data := _terrain.get_data()
+ assert(data != null)
+ var dst := data.get_image(info.maptype)
+ assert(dst != null)
+# print("Tile ", info.sector)
+# image.save_png(str("debug_generator_tile_",
+# info.sector.x, "_", info.sector.y, "_map", info.maptype, ".png"))
+
+ # Converting in case Viewport texture isn't the format we expect for this map.
+ # Note, in Godot 4 it seems the chosen renderer also influences what you get.
+ # Forward+ non-transparent viewport gives RGB8, but Compatibility gives RGBA8.
+ # I don't know if it's expected or is a bug...
+ # Also, since RF heightmaps we use RGBA8 so we can pack floats in pixels, because
+ # Godot 4.0 does not support RF viewports. But that also means the same viewport may be
+ # re-used for other maps that don't need to be RGBA8.
+ if image.get_format() != dst.get_format():
+ image.convert(dst.get_format())
+
+ dst.blit_rect(image, \
+ Rect2i(0, 0, image.get_width(), image.get_height()), \
+ info.sector * _viewport_resolution)
+
+ _notify_progress({
+ "progress": info.progress,
+ "message": "Calculating sector ("
+ + str(info.sector.x) + ", " + str(info.sector.y) + ")"
+ })
+
+# if info.maptype == HTerrainData.CHANNEL_NORMAL:
+# image.save_png(str("normal_sector_", info.sector.x, "_", info.sector.y, ".png"))
+
+
+func _on_TextureGenerator_completed():
+ _progress_bar.hide()
+
+ if not _applying:
+ return
+ _applying = false
+
+ assert(_terrain != null)
+ var data : HTerrainData = _terrain.get_data()
+ var resolution := data.get_resolution()
+ data.notify_region_change(Rect2(0, 0, resolution, resolution), HTerrainData.CHANNEL_HEIGHT)
+
+ var redo_map_ids := {}
+ for map_type in _undo_map_ids:
+ redo_map_ids[map_type] = _image_cache.save_image(data.get_image(map_type))
+
+ var undo_redo := _undo_redo_manager.get_history_undo_redo(
+ _undo_redo_manager.get_object_history_id(data))
+
+ data._edit_set_disable_apply_undo(true)
+ undo_redo.create_action("Generate terrain")
+ undo_redo.add_do_method(data._edit_apply_maps_from_file_cache.bind(_image_cache, redo_map_ids))
+ undo_redo.add_undo_method(
+ data._edit_apply_maps_from_file_cache.bind(_image_cache, _undo_map_ids))
+ undo_redo.commit_action()
+ data._edit_set_disable_apply_undo(false)
+
+ _notify_progress({ "finished": true })
+ _logger.debug("Done")
+
+ hide()
+
+
+func _notify_progress(info: Dictionary):
+ _progress_window.handle_progress(info)
+
+
+func _process(delta):
+ if _applying:
+ # HACK to workaround a peculiar behavior of Viewports in Godot 4.
+ # Apparently Godot 4 will not update Viewports set to UPDATE_ALWAYS when the editor decides
+ # it doesn't need to redraw ("low processor mode", what makes the editor redraw only with
+ # changes). That wasn't the case in Godot 3, but I guess it is now.
+ # That means when we click Apply, the viewport will not update in particular when doing
+ # erosion passes, because the action of clicking Apply doesn't lead to as many redraws as
+ # changing preview parameters in the UI (those cause redraws for different reasons).
+ # So let's poke the renderer by redrawing something...
+ #
+ # This also piles on top of the workaround in which we keep the window visible when
+ # applying! So the window has one more reason to stay visible...
+ #
+ _preview.queue_redraw()
+
diff --git a/game/addons/zylann.hterrain/tools/generator/generator_dialog.tscn b/game/addons/zylann.hterrain/tools/generator/generator_dialog.tscn
new file mode 100644
index 0000000..5d911e3
--- /dev/null
+++ b/game/addons/zylann.hterrain/tools/generator/generator_dialog.tscn
@@ -0,0 +1,84 @@
+[gd_scene load_steps=5 format=3 uid="uid://cgfo1ocbdi1ug"]
+
+[ext_resource type="Script" path="res://addons/zylann.hterrain/tools/generator/generator_dialog.gd" id="1"]
+[ext_resource type="PackedScene" path="res://addons/zylann.hterrain/tools/inspector/inspector.tscn" id="2"]
+[ext_resource type="PackedScene" path="res://addons/zylann.hterrain/tools/terrain_preview.tscn" id="3"]
+[ext_resource type="PackedScene" path="res://addons/zylann.hterrain/tools/util/dialog_fitter.tscn" id="4"]
+
+[node name="GeneratorDialog" type="AcceptDialog"]
+title = "Generate terrain"
+size = Vector2i(1100, 780)
+min_size = Vector2i(1100, 620)
+script = ExtResource("1")
+
+[node name="VBoxContainer" type="VBoxContainer" parent="."]
+anchors_preset = 15
+anchor_right = 1.0
+anchor_bottom = 1.0
+offset_left = 8.0
+offset_top = 8.0
+offset_right = -8.0
+offset_bottom = -18.0
+
+[node name="Editor" type="HBoxContainer" parent="VBoxContainer"]
+layout_mode = 2
+size_flags_vertical = 3
+
+[node name="Settings" type="VBoxContainer" parent="VBoxContainer/Editor"]
+custom_minimum_size = Vector2(420, 0)
+layout_mode = 2
+
+[node name="Inspector" parent="VBoxContainer/Editor/Settings" instance=ExtResource("2")]
+layout_mode = 2
+
+[node name="Preview" type="Control" parent="VBoxContainer/Editor"]
+layout_mode = 2
+size_flags_horizontal = 3
+
+[node name="TerrainPreview" parent="VBoxContainer/Editor/Preview" instance=ExtResource("3")]
+layout_mode = 1
+anchors_preset = 15
+grow_horizontal = 2
+grow_vertical = 2
+
+[node name="Label" type="Label" parent="VBoxContainer/Editor/Preview"]
+layout_mode = 0
+offset_left = 5.0
+offset_top = 4.0
+offset_right = 207.0
+offset_bottom = 18.0
+text = "LMB: offset, MMB: rotate"
+
+[node name="ProgressBar" type="ProgressBar" parent="VBoxContainer/Editor/Preview"]
+layout_mode = 1
+anchors_preset = -1
+anchor_top = 1.0
+anchor_right = 1.0
+anchor_bottom = 1.0
+offset_top = -35.0
+step = 1.0
+
+[node name="Choices" type="HBoxContainer" parent="VBoxContainer"]
+layout_mode = 2
+alignment = 1
+
+[node name="ApplyButton" type="Button" parent="VBoxContainer/Choices"]
+layout_mode = 2
+text = "Apply"
+
+[node name="CancelButton" type="Button" parent="VBoxContainer/Choices"]
+layout_mode = 2
+text = "Cancel"
+
+[node name="DialogFitter" parent="." instance=ExtResource("4")]
+layout_mode = 3
+anchors_preset = 0
+offset_left = 8.0
+offset_top = 8.0
+offset_right = 1092.0
+offset_bottom = 762.0
+
+[connection signal="property_changed" from="VBoxContainer/Editor/Settings/Inspector" to="." method="_on_Inspector_property_changed"]
+[connection signal="dragged" from="VBoxContainer/Editor/Preview/TerrainPreview" to="." method="_on_TerrainPreview_dragged"]
+[connection signal="pressed" from="VBoxContainer/Choices/ApplyButton" to="." method="_on_ApplyButton_pressed"]
+[connection signal="pressed" from="VBoxContainer/Choices/CancelButton" to="." method="_on_CancelButton_pressed"]
diff --git a/game/addons/zylann.hterrain/tools/generator/shaders/bump2normal.gdshader b/game/addons/zylann.hterrain/tools/generator/shaders/bump2normal.gdshader
new file mode 100644
index 0000000..7dd9714
--- /dev/null
+++ b/game/addons/zylann.hterrain/tools/generator/shaders/bump2normal.gdshader
@@ -0,0 +1,27 @@
+shader_type canvas_item;
+render_mode blend_disabled;
+
+#include "res://addons/zylann.hterrain/shaders/include/heightmap.gdshaderinc"
+
+//uniform sampler2D u_screen_texture : hint_screen_texture;
+uniform sampler2D u_previous_pass;
+
+vec4 pack_normal(vec3 n) {
+ return vec4((0.5 * (n + 1.0)).xzy, 1.0);
+}
+
+float get_height(sampler2D tex, vec2 uv) {
+ return sample_height_from_viewport(tex, uv);
+}
+
+void fragment() {
+ vec2 uv = SCREEN_UV;
+ vec2 ps = SCREEN_PIXEL_SIZE;
+ float left = get_height(u_previous_pass, uv + vec2(-ps.x, 0));
+ float right = get_height(u_previous_pass, uv + vec2(ps.x, 0));
+ float back = get_height(u_previous_pass, uv + vec2(0, -ps.y));
+ float fore = get_height(u_previous_pass, uv + vec2(0, ps.y));
+ vec3 n = normalize(vec3(left - right, 2.0, fore - back));
+ COLOR = pack_normal(n);
+}
+
diff --git a/game/addons/zylann.hterrain/tools/generator/shaders/erode.gdshader b/game/addons/zylann.hterrain/tools/generator/shaders/erode.gdshader
new file mode 100644
index 0000000..6f45e1c
--- /dev/null
+++ b/game/addons/zylann.hterrain/tools/generator/shaders/erode.gdshader
@@ -0,0 +1,76 @@
+shader_type canvas_item;
+render_mode blend_disabled;
+
+#include "res://addons/zylann.hterrain/shaders/include/heightmap.gdshaderinc"
+
+uniform vec2 u_slope_up = vec2(0, 0);
+uniform float u_slope_factor = 1.0;
+uniform bool u_slope_invert = false;
+uniform float u_weight = 0.5;
+uniform float u_dilation = 0.0;
+//uniform sampler2D u_screen_texture : hint_screen_texture;
+uniform sampler2D u_previous_pass;
+
+float get_height(sampler2D tex, vec2 uv) {
+ return sample_height_from_viewport(tex, uv);
+}
+
+void fragment() {
+ float r = 3.0;
+
+ // Divide so the shader stays neighbor dependent 1 pixel across.
+ // For this to work, filtering must be enabled.
+ vec2 eps = SCREEN_PIXEL_SIZE / (0.99 * r);
+
+ vec2 uv = SCREEN_UV;
+ float h = get_height(u_previous_pass, uv);
+ float eh = h;
+ float dh = h;
+
+ // Morphology with circular structuring element
+ for (float y = -r; y <= r; ++y) {
+ for (float x = -r; x <= r; ++x) {
+
+ vec2 p = vec2(float(x), float(y));
+ float nh = get_height(u_previous_pass, uv + p * eps);
+
+ float s = max(length(p) - r, 0);
+ eh = min(eh, nh + s);
+
+ s = min(r - length(p), 0);
+ dh = max(dh, nh + s);
+ }
+ }
+
+ eh = mix(h, eh, u_weight);
+ dh = mix(h, dh, u_weight);
+
+ float ph = mix(eh, dh, u_dilation);
+
+ if (u_slope_factor > 0.0) {
+ vec2 ps = SCREEN_PIXEL_SIZE;
+
+ float left = get_height(u_previous_pass, uv + vec2(-ps.x, 0.0));
+ float right = get_height(u_previous_pass, uv + vec2(ps.x, 0.0));
+ float top = get_height(u_previous_pass, uv + vec2(0.0, ps.y));
+ float bottom = get_height(u_previous_pass, uv + vec2(0.0, -ps.y));
+
+ vec3 normal = normalize(vec3(left - right, ps.x + ps.y, bottom - top));
+ vec3 up = normalize(vec3(u_slope_up.x, 1.0, u_slope_up.y));
+
+ float f = max(dot(normal, up), 0);
+ if (u_slope_invert) {
+ f = 1.0 - f;
+ }
+
+ ph = mix(h, ph, mix(1.0, f, u_slope_factor));
+ //COLOR = vec4(f, f, f, 1.0);
+ }
+
+ //COLOR = vec4(0.5 * normal + 0.5, 1.0);
+
+ //eh = 0.5 * (eh + texture(SCREEN_TEXTURE, uv + mp * ps * k).r);
+ //eh = mix(h, eh, (1.0 - h) / r);
+
+ COLOR = encode_height_to_viewport(ph);
+}
diff --git a/game/addons/zylann.hterrain/tools/generator/shaders/perlin_noise.gdshader b/game/addons/zylann.hterrain/tools/generator/shaders/perlin_noise.gdshader
new file mode 100644
index 0000000..7609f7c
--- /dev/null
+++ b/game/addons/zylann.hterrain/tools/generator/shaders/perlin_noise.gdshader
@@ -0,0 +1,211 @@
+shader_type canvas_item;
+// Required only because we use all 4 channels to encode floats into RGBA8
+render_mode blend_disabled;
+
+#include "res://addons/zylann.hterrain/shaders/include/heightmap.gdshaderinc"
+
+uniform vec2 u_offset;
+uniform float u_scale = 0.02;
+uniform float u_base_height = 0.0;
+uniform float u_height_range = 100.0;
+uniform int u_seed;
+uniform int u_octaves = 5;
+uniform float u_roughness = 0.5;
+uniform float u_curve = 1.0;
+uniform float u_terrain_size = 513.0;
+uniform float u_tile_size = 513.0;
+uniform sampler2D u_additive_heightmap;
+uniform float u_additive_heightmap_factor = 0.0;
+uniform vec2 u_uv_offset;
+uniform vec2 u_uv_scale = vec2(1.0, 1.0);
+
+uniform float u_island_weight = 0.0;
+// 0: smooth transition, 1: sharp transition
+uniform float u_island_sharpness = 0.0;
+// 0: edge is min height (island), 1: edge is max height (canyon)
+uniform float u_island_height_ratio = 0.0;
+// 0: round, 1: square
+uniform float u_island_shape = 0.0;
+
+////////////////////////////////////////////////////////////////////////////////
+// Perlin noise source:
+// https://github.com/curly-brace/Godot-3.0-Noise-Shaders
+//
+// GLSL textureless classic 2D noise \"cnoise\",
+// with an RSL-style periodic variant \"pnoise\".
+// Author: Stefan Gustavson (stefan.gustavson@liu.se)
+// Version: 2011-08-22
+//
+// Many thanks to Ian McEwan of Ashima Arts for the
+// ideas for permutation and gradient selection.
+//
+// Copyright (c) 2011 Stefan Gustavson. All rights reserved.
+// Distributed under the MIT license. See LICENSE file.
+// https://github.com/stegu/webgl-noise
+//
+
+vec4 mod289(vec4 x) {
+ return x - floor(x * (1.0 / 289.0)) * 289.0;
+}
+
+vec4 permute(vec4 x) {
+ return mod289(((x * 34.0) + 1.0) * x);
+}
+
+vec4 taylorInvSqrt(vec4 r) {
+ return 1.79284291400159 - 0.85373472095314 * r;
+}
+
+vec2 fade(vec2 t) {
+ return t * t * t * (t * (t * 6.0 - 15.0) + 10.0);
+}
+
+// Classic Perlin noise
+float cnoise(vec2 P) {
+ vec4 Pi = floor(vec4(P, P)) + vec4(0.0, 0.0, 1.0, 1.0);
+ vec4 Pf = fract(vec4(P, P)) - vec4(0.0, 0.0, 1.0, 1.0);
+ Pi = mod289(Pi); // To avoid truncation effects in permutation
+ vec4 ix = Pi.xzxz;
+ vec4 iy = Pi.yyww;
+ vec4 fx = Pf.xzxz;
+ vec4 fy = Pf.yyww;
+
+ vec4 i = permute(permute(ix) + iy);
+
+ vec4 gx = fract(i * (1.0 / 41.0)) * 2.0 - 1.0 ;
+ vec4 gy = abs(gx) - 0.5 ;
+ vec4 tx = floor(gx + 0.5);
+ gx = gx - tx;
+
+ vec2 g00 = vec2(gx.x,gy.x);
+ vec2 g10 = vec2(gx.y,gy.y);
+ vec2 g01 = vec2(gx.z,gy.z);
+ vec2 g11 = vec2(gx.w,gy.w);
+
+ vec4 norm = taylorInvSqrt(vec4(dot(g00, g00), dot(g01, g01), dot(g10, g10), dot(g11, g11)));
+ g00 *= norm.x;
+ g01 *= norm.y;
+ g10 *= norm.z;
+ g11 *= norm.w;
+
+ float n00 = dot(g00, vec2(fx.x, fy.x));
+ float n10 = dot(g10, vec2(fx.y, fy.y));
+ float n01 = dot(g01, vec2(fx.z, fy.z));
+ float n11 = dot(g11, vec2(fx.w, fy.w));
+
+ vec2 fade_xy = fade(Pf.xy);
+ vec2 n_x = mix(vec2(n00, n01), vec2(n10, n11), fade_xy.x);
+ float n_xy = mix(n_x.x, n_x.y, fade_xy.y);
+ return 2.3 * n_xy;
+}
+////////////////////////////////////////////////////////////////////////////////
+
+float get_fractal_noise(vec2 uv) {
+ float scale = 1.0;
+ float sum = 0.0;
+ float amp = 0.0;
+ int octaves = u_octaves;
+ float p = 1.0;
+ uv.x += float(u_seed) * 61.0;
+
+ for (int i = 0; i < octaves; ++i) {
+ sum += p * cnoise(uv * scale);
+ amp += p;
+ scale *= 2.0;
+ p *= u_roughness;
+ }
+
+ float gs = sum / amp;
+ return gs;
+}
+
+// x is a ratio in 0..1
+float get_island_curve(float x) {
+ return smoothstep(min(0.999, u_island_sharpness), 1.0, x);
+// float exponent = 1.0 + 10.0 * u_island_sharpness;
+// return pow(abs(x), exponent);
+}
+
+float smooth_union(float a, float b, float k) {
+ float h = clamp(0.5 + 0.5 * (b - a) / k, 0.0, 1.0);
+ return mix(b, a, h) - k * h * (1.0 - h);
+}
+
+float squareish_distance(vec2 a, vec2 b, float r, float s) {
+ vec2 v = b - a;
+ // TODO This is brute force but this is the first attempt that gave me a "rounded square" distance,
+ // where the "roundings" remained constant over distance (not the case with standard box SDF)
+ float da = -smooth_union(v.x+s, v.y+s, r)+s;
+ float db = -smooth_union(s-v.x, s-v.y, r)+s;
+ float dc = -smooth_union(s-v.x, v.y+s, r)+s;
+ float dd = -smooth_union(v.x+s, s-v.y, r)+s;
+ return max(max(da, db), max(dc, dd));
+}
+
+// This is too sharp
+//float squareish_distance(vec2 a, vec2 b) {
+// vec2 v = b - a;
+// // Manhattan distance would produce a "diamond-shaped distance".
+// // This gives "square-shaped" distance.
+// return max(abs(v.x), abs(v.y));
+//}
+
+float get_island_distance(vec2 pos, vec2 center, float terrain_size) {
+ float rd = distance(pos, center);
+ float sd = squareish_distance(pos, center, terrain_size * 0.1, terrain_size);
+ return mix(rd, sd, u_island_shape);
+}
+
+// pos is in terrain space
+float get_height(vec2 pos) {
+ float h = 0.0;
+
+ {
+ // Noise (0..1)
+ // Offset and scale for the noise itself
+ vec2 uv_noise = (pos / u_terrain_size + u_offset) * u_scale;
+ h = 0.5 + 0.5 * get_fractal_noise(uv_noise);
+ }
+
+ // Curve
+ {
+ h = pow(h, u_curve);
+ }
+
+ // Island
+ {
+ float terrain_size = u_terrain_size;
+ vec2 island_center = vec2(0.5 * terrain_size);
+ float island_height_ratio = 0.5 + 0.5 * u_island_height_ratio;
+ float island_distance = get_island_distance(pos, island_center, terrain_size);
+ float distance_ratio = clamp(island_distance / (0.5 * terrain_size), 0.0, 1.0);
+ float island_ratio = u_island_weight * get_island_curve(distance_ratio);
+ h = mix(h, island_height_ratio, island_ratio);
+ }
+
+ // Height remapping
+ {
+ h = u_base_height + h * u_height_range;
+ }
+
+ // Additive heightmap
+ {
+ vec2 uv = pos / u_terrain_size;
+ float ah = sample_heightmap(u_additive_heightmap, uv);
+ h += u_additive_heightmap_factor * ah;
+ }
+
+ return h;
+}
+
+void fragment() {
+ // Handle screen padding: transform UV back into generation space.
+ // This is in tile space actually...? it spans 1 unit across the viewport,
+ // and starts from 0 when tile (0,0) is generated.
+ // Maybe we could change this into world units instead?
+ vec2 uv_tile = (SCREEN_UV + u_uv_offset) * u_uv_scale;
+
+ float h = get_height(uv_tile * u_tile_size);
+
+ COLOR = encode_height_to_viewport(h);
+}
diff --git a/game/addons/zylann.hterrain/tools/generator/texture_generator.gd b/game/addons/zylann.hterrain/tools/generator/texture_generator.gd
new file mode 100644
index 0000000..0caaf7c
--- /dev/null
+++ b/game/addons/zylann.hterrain/tools/generator/texture_generator.gd
@@ -0,0 +1,331 @@
+# Holds a viewport on which several passes may run to generate a final image.
+# Passes can have different shaders and re-use what was drawn by a previous pass.
+# TODO I'd like to make such a system working as a graph of passes for more possibilities.
+
+@tool
+extends Node
+
+const HT_Util = preload("res://addons/zylann.hterrain/util/util.gd")
+const HT_TextureGeneratorPass = preload("./texture_generator_pass.gd")
+const HT_Logger = preload("../../util/logger.gd")
+# TODO Can't preload because it causes the plugin to fail loading if assets aren't imported
+const DUMMY_TEXTURE_PATH = "res://addons/zylann.hterrain/tools/icons/empty.png"
+
+signal progress_reported(info)
+# Emitted when an output is generated.
+signal output_generated(image, metadata)
+# Emitted when all passes are complete
+signal completed
+
+class HT_TextureGeneratorViewport:
+ var viewport : SubViewport
+ var ci : TextureRect
+
+var _passes := []
+var _resolution := Vector2i(512, 512)
+var _output_padding := [0, 0, 0, 0]
+
+# Since Godot 4.0, we use ping-pong viewports because `hint_screen_texture` always returns `1.0`
+# for transparent pixels, which is wrong, but sadly appears to be intented...
+# https://github.com/godotengine/godot/issues/78207
+var _viewports : Array[HT_TextureGeneratorViewport] = [null, null]
+var _viewport_index := 0
+
+var _dummy_texture : Texture2D
+var _running := false
+var _rerun := false
+#var _tiles = PoolVector2Array([Vector2()])
+
+var _running_passes := []
+var _running_pass_index := 0
+var _running_iteration := 0
+var _shader_material : ShaderMaterial = null
+#var _uv_offset = 0 # Offset de to padding
+
+var _logger = HT_Logger.get_for(self)
+
+
+func _ready():
+ _dummy_texture = load(DUMMY_TEXTURE_PATH)
+ if _dummy_texture == null:
+ _logger.error(str("Failed to load dummy texture ", DUMMY_TEXTURE_PATH))
+
+ for viewport_index in len(_viewports):
+ var viewport = SubViewport.new()
+ # We render with 2D shaders, but we don't want the parent world to interfere
+ viewport.own_world_3d = true
+ viewport.world_3d = World3D.new()
+ viewport.render_target_update_mode = SubViewport.UPDATE_DISABLED
+ # Require RGBA8 so we can pack heightmap floats into pixels
+ viewport.transparent_bg = true
+ add_child(viewport)
+
+ var ci := TextureRect.new()
+ ci.stretch_mode = TextureRect.STRETCH_SCALE
+ ci.texture = _dummy_texture
+ viewport.add_child(ci)
+
+ var vp := HT_TextureGeneratorViewport.new()
+ vp.viewport = viewport
+ vp.ci = ci
+ _viewports[viewport_index] = vp
+
+ _shader_material = ShaderMaterial.new()
+
+ set_process(false)
+
+
+func is_running() -> bool:
+ return _running
+
+
+func clear_passes():
+ _passes.clear()
+
+
+func add_pass(p: HT_TextureGeneratorPass):
+ assert(_passes.find(p) == -1)
+ assert(p.iterations > 0)
+ _passes.append(p)
+
+
+func add_output(meta):
+ assert(len(_passes) > 0)
+ var p = _passes[-1]
+ p.output = true
+ p.metadata = meta
+
+
+# Sets at which base resolution the generator will work on.
+# In tiled rendering, this is the resolution of one tile.
+# The internal viewport may be larger if some passes need more room,
+# and the resulting images might include some of these pixels if output padding is used.
+func set_resolution(res: Vector2i):
+ assert(not _running)
+ _resolution = res
+
+
+# Tell image outputs to include extra pixels on the edges.
+# This extends the resolution of images compared to the base resolution.
+# The initial use case for this is to generate terrain tiles where edge pixels are
+# shared with the neighor tiles.
+func set_output_padding(p: Array):
+ assert(typeof(p) == TYPE_ARRAY)
+ assert(len(p) == 4)
+ for v in p:
+ assert(typeof(v) == TYPE_INT)
+ _output_padding = p
+
+
+func run():
+ assert(len(_passes) > 0)
+
+ if _running:
+ _rerun = true
+ return
+
+ for vp in _viewports:
+ assert(vp.viewport != null)
+ assert(vp.ci != null)
+
+ # Copy passes
+ var passes := []
+ passes.resize(len(_passes))
+ for i in len(_passes):
+ passes[i] = _passes[i].duplicate()
+ _running_passes = passes
+
+ # Pad pixels according to largest padding
+ var largest_padding := 0
+ for p in passes:
+ if p.padding > largest_padding:
+ largest_padding = p.padding
+ for v in _output_padding:
+ if v > largest_padding:
+ largest_padding = v
+ var padded_size := _resolution + 2 * Vector2i(largest_padding, largest_padding)
+
+# _uv_offset = Vector2( \
+# float(largest_padding) / padded_size.x,
+# float(largest_padding) / padded_size.y)
+
+ for vp in _viewports:
+ vp.ci.size = padded_size
+ vp.viewport.size = padded_size
+
+ # First viewport index doesn't matter.
+ # Maybe one issue of resetting it to zero would be that the previous run
+ # could have ended with the same viewport that will be sent as a texture as
+ # one of the uniforms of the shader material, which causes an error in the
+ # renderer because it's not allowed to use a viewport texture while
+ # rendering on the same viewport
+# _viewport_index = 0
+
+ var first_vp := _viewports[_viewport_index]
+ first_vp.viewport.render_target_clear_mode = SubViewport.CLEAR_MODE_ONCE
+ # I don't trust `UPDATE_ONCE`, it also doesn't reset so we never know if it actually works...
+ # https://github.com/godotengine/godot/issues/33351
+ first_vp.viewport.render_target_update_mode = SubViewport.UPDATE_ALWAYS
+
+ for vp in _viewports:
+ if vp != first_vp:
+ vp.viewport.render_target_update_mode = SubViewport.UPDATE_DISABLED
+
+ _running_pass_index = 0
+ _running_iteration = 0
+ _running = true
+ set_process(true)
+
+
+func _process(delta: float):
+ # TODO because of https://github.com/godotengine/godot/issues/7894
+ if not is_processing():
+ return
+
+ var prev_vpi := 0 if _viewport_index == 1 else 1
+ var prev_vp := _viewports[prev_vpi]
+
+ if _running_pass_index > 0:
+ var prev_pass : HT_TextureGeneratorPass = _running_passes[_running_pass_index - 1]
+ if prev_pass.output:
+ _create_output_image(prev_pass.metadata, prev_vp)
+
+ if _running_pass_index >= len(_running_passes):
+ _running = false
+
+ completed.emit()
+
+ if _rerun:
+ # run() was requested again before we complete...
+ # this will happen very frequently because we are forced to wait multiple frames
+ # before getting a result
+ _rerun = false
+ run()
+ else:
+ # Done
+ for vp in _viewports:
+ vp.viewport.render_target_update_mode = SubViewport.UPDATE_DISABLED
+ set_process(false)
+ return
+
+ var p : HT_TextureGeneratorPass = _running_passes[_running_pass_index]
+
+ var vp := _viewports[_viewport_index]
+ vp.viewport.render_target_update_mode = SubViewport.UPDATE_ALWAYS
+ prev_vp.viewport.render_target_update_mode = SubViewport.UPDATE_DISABLED
+
+ if _running_iteration == 0:
+ _setup_pass(p, vp)
+
+ _setup_iteration(vp, prev_vp)
+
+ _report_progress(_running_passes, _running_pass_index, _running_iteration)
+ # Wait one frame for render, and this for EVERY iteration and every pass,
+ # because Godot doesn't provide any way to run multiple feedback render passes in one go.
+ _running_iteration += 1
+
+ if _running_iteration == p.iterations:
+ _running_iteration = 0
+ _running_pass_index += 1
+
+ # Swap viewport for next pass
+ _viewport_index = (_viewport_index + 1) % 2
+
+ # The viewport should render after the tree was processed
+
+
+# Called at the beginning of each pass
+func _setup_pass(p: HT_TextureGeneratorPass, vp: HT_TextureGeneratorViewport):
+ if p.texture != null:
+ vp.ci.texture = p.texture
+ else:
+ vp.ci.texture = _dummy_texture
+
+ if p.shader != null:
+ if _shader_material == null:
+ _shader_material = ShaderMaterial.new()
+ _shader_material.shader = p.shader
+
+ vp.ci.material = _shader_material
+
+ if p.params != null:
+ for param_name in p.params:
+ _shader_material.set_shader_parameter(param_name, p.params[param_name])
+
+ var vp_size_f := Vector2(vp.viewport.size)
+ var res_f := Vector2(_resolution)
+ var scale_ndc := vp_size_f / res_f
+ var pad_offset_ndc := ((vp_size_f - res_f) / 2.0) / vp_size_f
+ var offset_ndc := -pad_offset_ndc + p.tile_pos / scale_ndc
+
+ # Because padding may be used around the generated area,
+ # the shader can use these predefined parameters,
+ # and apply the following to SCREEN_UV to adjust its calculations:
+ # vec2 uv = (SCREEN_UV + u_uv_offset) * u_uv_scale;
+
+ if p.params == null or not p.params.has("u_uv_scale"):
+ _shader_material.set_shader_parameter("u_uv_scale", scale_ndc)
+
+ if p.params == null or not p.params.has("u_uv_offset"):
+ _shader_material.set_shader_parameter("u_uv_offset", offset_ndc)
+
+ else:
+ vp.ci.material = null
+
+ if p.clear:
+ vp.viewport.render_target_clear_mode = SubViewport.CLEAR_MODE_ONCE
+
+
+# Called for every iteration of every pass
+func _setup_iteration(vp: HT_TextureGeneratorViewport, prev_vp: HT_TextureGeneratorViewport):
+ assert(vp != prev_vp)
+ if _shader_material != null:
+ _shader_material.set_shader_parameter("u_previous_pass", prev_vp.viewport.get_texture())
+
+
+func _create_output_image(metadata, vp: HT_TextureGeneratorViewport):
+ var tex := vp.viewport.get_texture()
+ var src := tex.get_image()
+# src.save_png(str("ddd_tgen_output", metadata.maptype, ".png"))
+
+ # Pick the center of the image
+ var subrect := Rect2i( \
+ (src.get_width() - _resolution.x) / 2, \
+ (src.get_height() - _resolution.y) / 2, \
+ _resolution.x, _resolution.y)
+
+ # Make sure we are pixel-perfect. If not, padding is odd
+# assert(int(subrect.position.x) == subrect.position.x)
+# assert(int(subrect.position.y) == subrect.position.y)
+
+ subrect.position.x -= _output_padding[0]
+ subrect.position.y -= _output_padding[2]
+ subrect.size.x += _output_padding[0] + _output_padding[1]
+ subrect.size.y += _output_padding[2] + _output_padding[3]
+
+ var dst : Image
+ if subrect == Rect2i(0, 0, src.get_width(), src.get_height()):
+ dst = src
+ else:
+ # Note: size MUST match at this point.
+ # If it doesn't, the viewport has not been configured properly,
+ # or padding has been modified while the generator was running
+ dst = Image.create( \
+ _resolution.x + _output_padding[0] + _output_padding[1], \
+ _resolution.y + _output_padding[2] + _output_padding[3], \
+ false, src.get_format())
+ dst.blit_rect(src, subrect, Vector2i())
+
+ output_generated.emit(dst, metadata)
+
+
+func _report_progress(passes: Array, pass_index: int, iteration: int):
+ var p = passes[pass_index]
+ progress_reported.emit({
+ "name": p.debug_name,
+ "pass_index": pass_index,
+ "pass_count": len(passes),
+ "iteration": iteration,
+ "iteration_count": p.iterations
+ })
+
diff --git a/game/addons/zylann.hterrain/tools/generator/texture_generator_pass.gd b/game/addons/zylann.hterrain/tools/generator/texture_generator_pass.gd
new file mode 100644
index 0000000..76745f1
--- /dev/null
+++ b/game/addons/zylann.hterrain/tools/generator/texture_generator_pass.gd
@@ -0,0 +1,43 @@
+
+# Name of the pass, for debug purposes
+var debug_name := ""
+# The viewport will be cleared at this pass
+var clear := false
+# Which main texture should be drawn.
+# If not set, a default texture will be drawn.
+# Note that it won't matter if the shader disregards it,
+# and will only serve to provide UVs, due to https://github.com/godotengine/godot/issues/7298.
+var texture : Texture = null
+# Which shader to use
+var shader : Shader = null
+# Parameters for the shader
+# TODO Use explicit Dictionary, dont allow null
+var params = null
+# How many pixels to pad the viewport on all edges, in case neighboring matters.
+# Outputs won't have that padding, but can pick part of it in case output padding is used.
+var padding := 0
+# How many times this pass must be run
+var iterations := 1
+# If not empty, the viewport will be downloaded as an image before the next pass
+var output := false
+# Sent along the output
+var metadata = null
+# Used for tiled rendering, where each tile has the base resolution,
+# in case the viewport cannot be made big enough to cover the final image,
+# of if you are generating a pseudo-infinite terrain.
+# TODO Have an API for this?
+var tile_pos := Vector2()
+
+func duplicate():
+ var p = get_script().new()
+ p.debug_name = debug_name
+ p.clear = clear
+ p.texture = texture
+ p.shader = shader
+ p.params = params
+ p.padding = padding
+ p.iterations = iterations
+ p.output = output
+ p.metadata = metadata
+ p.tile_pos = tile_pos
+ return p
diff --git a/game/addons/zylann.hterrain/tools/globalmap_baker.gd b/game/addons/zylann.hterrain/tools/globalmap_baker.gd
new file mode 100644
index 0000000..6faa0eb
--- /dev/null
+++ b/game/addons/zylann.hterrain/tools/globalmap_baker.gd
@@ -0,0 +1,168 @@
+
+# Bakes a global albedo map using the same shader the terrain uses,
+# but renders top-down in orthographic mode.
+
+@tool
+extends Node
+
+const HTerrain = preload("../hterrain.gd")
+const HTerrainData = preload("../hterrain_data.gd")
+const HTerrainMesher = preload("../hterrain_mesher.gd")
+
+# Must be power of two
+const DEFAULT_VIEWPORT_SIZE = 512
+
+signal progress_notified(info)
+signal permanent_change_performed(message)
+
+var _terrain : HTerrain = null
+var _viewport : SubViewport = null
+var _viewport_size := DEFAULT_VIEWPORT_SIZE
+var _plane : MeshInstance3D = null
+var _camera : Camera3D = null
+var _sectors := []
+var _sector_index := 0
+
+
+func _ready():
+ set_process(false)
+
+
+func bake(terrain: HTerrain):
+ assert(terrain != null)
+ var data := terrain.get_data()
+ assert(data != null)
+ _terrain = terrain
+
+ var splatmap := data.get_texture(HTerrainData.CHANNEL_SPLAT)
+ var colormap := data.get_texture(HTerrainData.CHANNEL_COLOR)
+
+ var terrain_size := data.get_resolution()
+
+ if _viewport == null:
+ _setup_scene(terrain_size)
+
+ var cw := terrain_size / _viewport_size
+ var ch := terrain_size / _viewport_size
+ for y in ch:
+ for x in cw:
+ _sectors.append(Vector2(x, y))
+
+ var mat := _plane.material_override
+ _terrain.setup_globalmap_material(mat)
+
+ _sector_index = 0
+ set_process(true)
+
+
+func _setup_scene(terrain_size: int):
+ assert(_viewport == null)
+
+ _viewport_size = DEFAULT_VIEWPORT_SIZE
+ while _viewport_size > terrain_size:
+ _viewport_size /= 2
+
+ _viewport = SubViewport.new()
+ _viewport.size = Vector2(_viewport_size + 1, _viewport_size + 1)
+ _viewport.render_target_update_mode = SubViewport.UPDATE_ALWAYS
+ _viewport.render_target_clear_mode = SubViewport.CLEAR_MODE_ALWAYS
+ # _viewport.render_target_v_flip = true
+ _viewport.world_3d = World3D.new()
+ _viewport.own_world_3d = true
+ _viewport.debug_draw = Viewport.DEBUG_DRAW_UNSHADED
+
+ var mat := ShaderMaterial.new()
+
+ _plane = MeshInstance3D.new()
+ # Make a very small mesh, vertex precision isn't required
+ var plane_res := 4
+ _plane.mesh = \
+ HTerrainMesher.make_flat_chunk(plane_res, plane_res, _viewport_size / plane_res, 0)
+ _plane.material_override = mat
+ _viewport.add_child(_plane)
+
+ _camera = Camera3D.new()
+ _camera.projection = Camera3D.PROJECTION_ORTHOGONAL
+ _camera.size = _viewport.size.x
+ _camera.near = 0.1
+ _camera.far = 10.0
+ _camera.current = true
+ _camera.rotation = Vector3(deg_to_rad(-90), 0, 0)
+ _viewport.add_child(_camera)
+
+ add_child(_viewport)
+
+
+func _cleanup_scene():
+ _viewport.queue_free()
+ _viewport = null
+ _plane = null
+ _camera = null
+
+
+func _process(delta):
+ if not is_processing():
+ return
+
+ if _sector_index > 0:
+ _grab_image(_sectors[_sector_index - 1])
+
+ if _sector_index >= len(_sectors):
+ set_process(false)
+ _finish()
+ progress_notified.emit({ "finished": true })
+ else:
+ _setup_pass(_sectors[_sector_index])
+ _report_progress()
+ _sector_index += 1
+
+
+func _report_progress():
+ var sector = _sectors[_sector_index]
+ progress_notified.emit({
+ "progress": float(_sector_index) / len(_sectors),
+ "message": "Calculating sector (" + str(sector.x) + ", " + str(sector.y) + ")"
+ })
+
+
+func _setup_pass(sector: Vector2):
+ # Note: we implicitely take off-by-one pixels into account
+ var origin := sector * _viewport_size
+ var center := origin + 0.5 * Vector2(_viewport.size)
+
+ # The heightmap is left empty, so will default to white, which is a height of 1.
+ # The camera must be placed above the terrain to see it.
+ _camera.position = Vector3(center.x, 2.0, center.y)
+ _plane.position = Vector3(origin.x, 0.0, origin.y)
+
+
+func _grab_image(sector: Vector2):
+ var tex := _viewport.get_texture()
+ var src := tex.get_image()
+
+ assert(_terrain != null)
+ var data := _terrain.get_data()
+ assert(data != null)
+
+ if data.get_map_count(HTerrainData.CHANNEL_GLOBAL_ALBEDO) == 0:
+ data._edit_add_map(HTerrainData.CHANNEL_GLOBAL_ALBEDO)
+
+ var dst := data.get_image(HTerrainData.CHANNEL_GLOBAL_ALBEDO)
+
+ src.convert(dst.get_format())
+ var origin = sector * _viewport_size
+ dst.blit_rect(src, Rect2i(0, 0, src.get_width(), src.get_height()), origin)
+
+
+func _finish():
+ assert(_terrain != null)
+ var data := _terrain.get_data() as HTerrainData
+ assert(data != null)
+ var dst := data.get_image(HTerrainData.CHANNEL_GLOBAL_ALBEDO)
+
+ data.notify_region_change(Rect2(0, 0, dst.get_width(), dst.get_height()),
+ HTerrainData.CHANNEL_GLOBAL_ALBEDO)
+ permanent_change_performed.emit("Bake globalmap")
+
+ _cleanup_scene()
+ _terrain = null
diff --git a/game/addons/zylann.hterrain/tools/icons/empty.png b/game/addons/zylann.hterrain/tools/icons/empty.png
new file mode 100644
index 0000000..0a59813
--- /dev/null
+++ b/game/addons/zylann.hterrain/tools/icons/empty.png
Binary files differ
diff --git a/game/addons/zylann.hterrain/tools/icons/empty.png.import b/game/addons/zylann.hterrain/tools/icons/empty.png.import
new file mode 100644
index 0000000..267b65d
--- /dev/null
+++ b/game/addons/zylann.hterrain/tools/icons/empty.png.import
@@ -0,0 +1,34 @@
+[remap]
+
+importer="texture"
+type="CompressedTexture2D"
+uid="uid://vo2brkj3udel"
+path="res://.godot/imported/empty.png-31363f083c9c4e2e8e54cf64f3716737.ctex"
+metadata={
+"vram_texture": false
+}
+
+[deps]
+
+source_file="res://addons/zylann.hterrain/tools/icons/empty.png"
+dest_files=["res://.godot/imported/empty.png-31363f083c9c4e2e8e54cf64f3716737.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/game/addons/zylann.hterrain/tools/icons/icon_anchor_bottom.svg b/game/addons/zylann.hterrain/tools/icons/icon_anchor_bottom.svg
new file mode 100644
index 0000000..f490172
--- /dev/null
+++ b/game/addons/zylann.hterrain/tools/icons/icon_anchor_bottom.svg
@@ -0,0 +1,66 @@
+<?xml version="1.0" encoding="UTF-8" standalone="no"?>
+<svg
+ xmlns:dc="http://purl.org/dc/elements/1.1/"
+ xmlns:cc="http://creativecommons.org/ns#"
+ xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#"
+ xmlns:svg="http://www.w3.org/2000/svg"
+ xmlns="http://www.w3.org/2000/svg"
+ xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
+ xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
+ width="16"
+ height="16"
+ version="1.1"
+ viewBox="0 0 16 16"
+ id="svg2"
+ inkscape:version="0.92.3 (2405546, 2018-03-11)"
+ sodipodi:docname="icon_anchor_bottom.svg">
+ <metadata
+ id="metadata12">
+ <rdf:RDF>
+ <cc:Work
+ rdf:about="">
+ <dc:format>image/svg+xml</dc:format>
+ <dc:type
+ rdf:resource="http://purl.org/dc/dcmitype/StillImage" />
+ <dc:title></dc:title>
+ </cc:Work>
+ </rdf:RDF>
+ </metadata>
+ <defs
+ id="defs10" />
+ <sodipodi:namedview
+ pagecolor="#ffffff"
+ bordercolor="#666666"
+ borderopacity="1"
+ objecttolerance="10"
+ gridtolerance="10"
+ guidetolerance="10"
+ inkscape:pageopacity="0"
+ inkscape:pageshadow="2"
+ inkscape:window-width="1461"
+ inkscape:window-height="826"
+ id="namedview8"
+ showgrid="true"
+ inkscape:snap-grids="true"
+ inkscape:snap-global="true"
+ inkscape:snap-bbox="false"
+ inkscape:snap-page="true"
+ inkscape:object-nodes="true"
+ inkscape:zoom="32"
+ inkscape:cx="7.7923218"
+ inkscape:cy="7.6837119"
+ inkscape:window-x="156"
+ inkscape:window-y="100"
+ inkscape:window-maximized="0"
+ inkscape:current-layer="svg2">
+ <inkscape:grid
+ type="xygrid"
+ id="grid4142" />
+ </sodipodi:namedview>
+ <path
+ style="fill:#e0e0e0;fill-opacity:1;stroke:#000000;stroke-width:1px;stroke-linecap:butt;stroke-linejoin:miter;stroke-opacity:1"
+ d="M 8,14 4,8 H 6 V 2 h 4 v 6 h 2 z"
+ id="path816"
+ inkscape:connector-curvature="0"
+ sodipodi:nodetypes="cccccccc" />
+</svg>
diff --git a/game/addons/zylann.hterrain/tools/icons/icon_anchor_bottom.svg.import b/game/addons/zylann.hterrain/tools/icons/icon_anchor_bottom.svg.import
new file mode 100644
index 0000000..a5e8479
--- /dev/null
+++ b/game/addons/zylann.hterrain/tools/icons/icon_anchor_bottom.svg.import
@@ -0,0 +1,37 @@
+[remap]
+
+importer="texture"
+type="CompressedTexture2D"
+uid="uid://l16qrsohg7jj"
+path="res://.godot/imported/icon_anchor_bottom.svg-963f115d31a41c38349ab03453cf2ef5.ctex"
+metadata={
+"vram_texture": false
+}
+
+[deps]
+
+source_file="res://addons/zylann.hterrain/tools/icons/icon_anchor_bottom.svg"
+dest_files=["res://.godot/imported/icon_anchor_bottom.svg-963f115d31a41c38349ab03453cf2ef5.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
+svg/scale=1.0
+editor/scale_with_editor_scale=false
+editor/convert_colors_with_editor_theme=false
diff --git a/game/addons/zylann.hterrain/tools/icons/icon_anchor_bottom_left.svg b/game/addons/zylann.hterrain/tools/icons/icon_anchor_bottom_left.svg
new file mode 100644
index 0000000..05f255a
--- /dev/null
+++ b/game/addons/zylann.hterrain/tools/icons/icon_anchor_bottom_left.svg
@@ -0,0 +1,66 @@
+<?xml version="1.0" encoding="UTF-8" standalone="no"?>
+<svg
+ xmlns:dc="http://purl.org/dc/elements/1.1/"
+ xmlns:cc="http://creativecommons.org/ns#"
+ xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#"
+ xmlns:svg="http://www.w3.org/2000/svg"
+ xmlns="http://www.w3.org/2000/svg"
+ xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
+ xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
+ width="16"
+ height="16"
+ version="1.1"
+ viewBox="0 0 16 16"
+ id="svg2"
+ inkscape:version="0.92.3 (2405546, 2018-03-11)"
+ sodipodi:docname="icon_anchor_bottom_left.svg">
+ <metadata
+ id="metadata12">
+ <rdf:RDF>
+ <cc:Work
+ rdf:about="">
+ <dc:format>image/svg+xml</dc:format>
+ <dc:type
+ rdf:resource="http://purl.org/dc/dcmitype/StillImage" />
+ <dc:title></dc:title>
+ </cc:Work>
+ </rdf:RDF>
+ </metadata>
+ <defs
+ id="defs10" />
+ <sodipodi:namedview
+ pagecolor="#ffffff"
+ bordercolor="#666666"
+ borderopacity="1"
+ objecttolerance="10"
+ gridtolerance="10"
+ guidetolerance="10"
+ inkscape:pageopacity="0"
+ inkscape:pageshadow="2"
+ inkscape:window-width="1461"
+ inkscape:window-height="826"
+ id="namedview8"
+ showgrid="true"
+ inkscape:snap-grids="true"
+ inkscape:snap-global="true"
+ inkscape:snap-bbox="false"
+ inkscape:snap-page="true"
+ inkscape:object-nodes="true"
+ inkscape:zoom="32"
+ inkscape:cx="7.7923218"
+ inkscape:cy="7.6837119"
+ inkscape:window-x="156"
+ inkscape:window-y="100"
+ inkscape:window-maximized="0"
+ inkscape:current-layer="svg2">
+ <inkscape:grid
+ type="xygrid"
+ id="grid4142" />
+ </sodipodi:namedview>
+ <path
+ style="fill:#e0e0e0;fill-opacity:1;stroke:#000000;stroke-width:1px;stroke-linecap:butt;stroke-linejoin:miter;stroke-opacity:1"
+ d="m 3.0307757,12.969224 1.414214,-7.071068 1.414213,1.4142142 4.2426413,-4.2426412 2.828427,2.828427 -4.2426407,4.242641 1.4142137,1.414214 z"
+ id="path816"
+ inkscape:connector-curvature="0"
+ sodipodi:nodetypes="cccccccc" />
+</svg>
diff --git a/game/addons/zylann.hterrain/tools/icons/icon_anchor_bottom_left.svg.import b/game/addons/zylann.hterrain/tools/icons/icon_anchor_bottom_left.svg.import
new file mode 100644
index 0000000..861cc99
--- /dev/null
+++ b/game/addons/zylann.hterrain/tools/icons/icon_anchor_bottom_left.svg.import
@@ -0,0 +1,37 @@
+[remap]
+
+importer="texture"
+type="CompressedTexture2D"
+uid="uid://30wsjmeemngg"
+path="res://.godot/imported/icon_anchor_bottom_left.svg-c59f20ff71f725e47b5fc556b5ef93c4.ctex"
+metadata={
+"vram_texture": false
+}
+
+[deps]
+
+source_file="res://addons/zylann.hterrain/tools/icons/icon_anchor_bottom_left.svg"
+dest_files=["res://.godot/imported/icon_anchor_bottom_left.svg-c59f20ff71f725e47b5fc556b5ef93c4.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
+svg/scale=1.0
+editor/scale_with_editor_scale=false
+editor/convert_colors_with_editor_theme=false
diff --git a/game/addons/zylann.hterrain/tools/icons/icon_anchor_bottom_right.svg b/game/addons/zylann.hterrain/tools/icons/icon_anchor_bottom_right.svg
new file mode 100644
index 0000000..33e97ef
--- /dev/null
+++ b/game/addons/zylann.hterrain/tools/icons/icon_anchor_bottom_right.svg
@@ -0,0 +1,66 @@
+<?xml version="1.0" encoding="UTF-8" standalone="no"?>
+<svg
+ xmlns:dc="http://purl.org/dc/elements/1.1/"
+ xmlns:cc="http://creativecommons.org/ns#"
+ xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#"
+ xmlns:svg="http://www.w3.org/2000/svg"
+ xmlns="http://www.w3.org/2000/svg"
+ xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
+ xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
+ width="16"
+ height="16"
+ version="1.1"
+ viewBox="0 0 16 16"
+ id="svg2"
+ inkscape:version="0.92.3 (2405546, 2018-03-11)"
+ sodipodi:docname="icon_anchor_bottom_right.svg">
+ <metadata
+ id="metadata12">
+ <rdf:RDF>
+ <cc:Work
+ rdf:about="">
+ <dc:format>image/svg+xml</dc:format>
+ <dc:type
+ rdf:resource="http://purl.org/dc/dcmitype/StillImage" />
+ <dc:title></dc:title>
+ </cc:Work>
+ </rdf:RDF>
+ </metadata>
+ <defs
+ id="defs10" />
+ <sodipodi:namedview
+ pagecolor="#ffffff"
+ bordercolor="#666666"
+ borderopacity="1"
+ objecttolerance="10"
+ gridtolerance="10"
+ guidetolerance="10"
+ inkscape:pageopacity="0"
+ inkscape:pageshadow="2"
+ inkscape:window-width="1461"
+ inkscape:window-height="826"
+ id="namedview8"
+ showgrid="true"
+ inkscape:snap-grids="true"
+ inkscape:snap-global="true"
+ inkscape:snap-bbox="false"
+ inkscape:snap-page="true"
+ inkscape:object-nodes="true"
+ inkscape:zoom="32"
+ inkscape:cx="7.7923218"
+ inkscape:cy="7.6837119"
+ inkscape:window-x="156"
+ inkscape:window-y="100"
+ inkscape:window-maximized="0"
+ inkscape:current-layer="svg2">
+ <inkscape:grid
+ type="xygrid"
+ id="grid4142" />
+ </sodipodi:namedview>
+ <path
+ style="fill:#e0e0e0;fill-opacity:1;stroke:#000000;stroke-width:1px;stroke-linecap:butt;stroke-linejoin:miter;stroke-opacity:1"
+ d="M 13,12.969224 5.9289322,11.55501 7.3431464,10.140797 3.1005052,5.8981558 5.9289322,3.0697288 10.171573,7.3123695 11.585787,5.8981558 Z"
+ id="path816"
+ inkscape:connector-curvature="0"
+ sodipodi:nodetypes="cccccccc" />
+</svg>
diff --git a/game/addons/zylann.hterrain/tools/icons/icon_anchor_bottom_right.svg.import b/game/addons/zylann.hterrain/tools/icons/icon_anchor_bottom_right.svg.import
new file mode 100644
index 0000000..b24ab20
--- /dev/null
+++ b/game/addons/zylann.hterrain/tools/icons/icon_anchor_bottom_right.svg.import
@@ -0,0 +1,37 @@
+[remap]
+
+importer="texture"
+type="CompressedTexture2D"
+uid="uid://cwb02kd3gr5v1"
+path="res://.godot/imported/icon_anchor_bottom_right.svg-23dd5f1d1c7021fe105f8bde603dcc4d.ctex"
+metadata={
+"vram_texture": false
+}
+
+[deps]
+
+source_file="res://addons/zylann.hterrain/tools/icons/icon_anchor_bottom_right.svg"
+dest_files=["res://.godot/imported/icon_anchor_bottom_right.svg-23dd5f1d1c7021fe105f8bde603dcc4d.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
+svg/scale=1.0
+editor/scale_with_editor_scale=false
+editor/convert_colors_with_editor_theme=false
diff --git a/game/addons/zylann.hterrain/tools/icons/icon_anchor_center.svg b/game/addons/zylann.hterrain/tools/icons/icon_anchor_center.svg
new file mode 100644
index 0000000..8ab613a
--- /dev/null
+++ b/game/addons/zylann.hterrain/tools/icons/icon_anchor_center.svg
@@ -0,0 +1,65 @@
+<?xml version="1.0" encoding="UTF-8" standalone="no"?>
+<svg
+ xmlns:dc="http://purl.org/dc/elements/1.1/"
+ xmlns:cc="http://creativecommons.org/ns#"
+ xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#"
+ xmlns:svg="http://www.w3.org/2000/svg"
+ xmlns="http://www.w3.org/2000/svg"
+ xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
+ xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
+ width="16"
+ height="16"
+ version="1.1"
+ viewBox="0 0 16 16"
+ id="svg2"
+ inkscape:version="0.92.3 (2405546, 2018-03-11)"
+ sodipodi:docname="icon_anchor_center.svg">
+ <metadata
+ id="metadata12">
+ <rdf:RDF>
+ <cc:Work
+ rdf:about="">
+ <dc:format>image/svg+xml</dc:format>
+ <dc:type
+ rdf:resource="http://purl.org/dc/dcmitype/StillImage" />
+ <dc:title></dc:title>
+ </cc:Work>
+ </rdf:RDF>
+ </metadata>
+ <defs
+ id="defs10" />
+ <sodipodi:namedview
+ pagecolor="#ffffff"
+ bordercolor="#666666"
+ borderopacity="1"
+ objecttolerance="10"
+ gridtolerance="10"
+ guidetolerance="10"
+ inkscape:pageopacity="0"
+ inkscape:pageshadow="2"
+ inkscape:window-width="1461"
+ inkscape:window-height="826"
+ id="namedview8"
+ showgrid="true"
+ inkscape:snap-grids="true"
+ inkscape:snap-global="true"
+ inkscape:snap-bbox="false"
+ inkscape:snap-page="true"
+ inkscape:object-nodes="true"
+ inkscape:zoom="32"
+ inkscape:cx="7.7923218"
+ inkscape:cy="7.6837119"
+ inkscape:window-x="156"
+ inkscape:window-y="100"
+ inkscape:window-maximized="0"
+ inkscape:current-layer="svg2">
+ <inkscape:grid
+ type="xygrid"
+ id="grid4142" />
+ </sodipodi:namedview>
+ <path
+ style="fill:#e0e0e0;stroke:#000000;stroke-width:1px;stroke-linecap:butt;stroke-linejoin:miter;stroke-opacity:1;fill-opacity:1"
+ d="M 2,2 H 14 V 14 H 2 Z"
+ id="path830"
+ inkscape:connector-curvature="0" />
+</svg>
diff --git a/game/addons/zylann.hterrain/tools/icons/icon_anchor_center.svg.import b/game/addons/zylann.hterrain/tools/icons/icon_anchor_center.svg.import
new file mode 100644
index 0000000..a0d694c
--- /dev/null
+++ b/game/addons/zylann.hterrain/tools/icons/icon_anchor_center.svg.import
@@ -0,0 +1,37 @@
+[remap]
+
+importer="texture"
+type="CompressedTexture2D"
+uid="uid://bkphpwgf3vlqy"
+path="res://.godot/imported/icon_anchor_center.svg-d48605c4035ec4a02ae8159aea6db85f.ctex"
+metadata={
+"vram_texture": false
+}
+
+[deps]
+
+source_file="res://addons/zylann.hterrain/tools/icons/icon_anchor_center.svg"
+dest_files=["res://.godot/imported/icon_anchor_center.svg-d48605c4035ec4a02ae8159aea6db85f.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
+svg/scale=1.0
+editor/scale_with_editor_scale=false
+editor/convert_colors_with_editor_theme=false
diff --git a/game/addons/zylann.hterrain/tools/icons/icon_anchor_left.svg b/game/addons/zylann.hterrain/tools/icons/icon_anchor_left.svg
new file mode 100644
index 0000000..7fd1a68
--- /dev/null
+++ b/game/addons/zylann.hterrain/tools/icons/icon_anchor_left.svg
@@ -0,0 +1,66 @@
+<?xml version="1.0" encoding="UTF-8" standalone="no"?>
+<svg
+ xmlns:dc="http://purl.org/dc/elements/1.1/"
+ xmlns:cc="http://creativecommons.org/ns#"
+ xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#"
+ xmlns:svg="http://www.w3.org/2000/svg"
+ xmlns="http://www.w3.org/2000/svg"
+ xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
+ xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
+ width="16"
+ height="16"
+ version="1.1"
+ viewBox="0 0 16 16"
+ id="svg2"
+ inkscape:version="0.92.3 (2405546, 2018-03-11)"
+ sodipodi:docname="icon_anchor_left.svg">
+ <metadata
+ id="metadata12">
+ <rdf:RDF>
+ <cc:Work
+ rdf:about="">
+ <dc:format>image/svg+xml</dc:format>
+ <dc:type
+ rdf:resource="http://purl.org/dc/dcmitype/StillImage" />
+ <dc:title></dc:title>
+ </cc:Work>
+ </rdf:RDF>
+ </metadata>
+ <defs
+ id="defs10" />
+ <sodipodi:namedview
+ pagecolor="#ffffff"
+ bordercolor="#666666"
+ borderopacity="1"
+ objecttolerance="10"
+ gridtolerance="10"
+ guidetolerance="10"
+ inkscape:pageopacity="0"
+ inkscape:pageshadow="2"
+ inkscape:window-width="1461"
+ inkscape:window-height="826"
+ id="namedview8"
+ showgrid="true"
+ inkscape:snap-grids="true"
+ inkscape:snap-global="true"
+ inkscape:snap-bbox="false"
+ inkscape:snap-page="true"
+ inkscape:object-nodes="true"
+ inkscape:zoom="32"
+ inkscape:cx="7.7923218"
+ inkscape:cy="7.6837119"
+ inkscape:window-x="156"
+ inkscape:window-y="100"
+ inkscape:window-maximized="0"
+ inkscape:current-layer="svg2">
+ <inkscape:grid
+ type="xygrid"
+ id="grid4142" />
+ </sodipodi:namedview>
+ <path
+ style="fill:#e0e0e0;fill-opacity:1;stroke:#000000;stroke-width:1px;stroke-linecap:butt;stroke-linejoin:miter;stroke-opacity:1"
+ d="M 2,8 8,4 v 2 h 6 v 4 H 8 v 2 z"
+ id="path816"
+ inkscape:connector-curvature="0"
+ sodipodi:nodetypes="cccccccc" />
+</svg>
diff --git a/game/addons/zylann.hterrain/tools/icons/icon_anchor_left.svg.import b/game/addons/zylann.hterrain/tools/icons/icon_anchor_left.svg.import
new file mode 100644
index 0000000..9d5b149
--- /dev/null
+++ b/game/addons/zylann.hterrain/tools/icons/icon_anchor_left.svg.import
@@ -0,0 +1,37 @@
+[remap]
+
+importer="texture"
+type="CompressedTexture2D"
+uid="uid://cmydngdawudhq"
+path="res://.godot/imported/icon_anchor_left.svg-77f3e03e6fbadfd7e4dc1ab3661e6e7c.ctex"
+metadata={
+"vram_texture": false
+}
+
+[deps]
+
+source_file="res://addons/zylann.hterrain/tools/icons/icon_anchor_left.svg"
+dest_files=["res://.godot/imported/icon_anchor_left.svg-77f3e03e6fbadfd7e4dc1ab3661e6e7c.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
+svg/scale=1.0
+editor/scale_with_editor_scale=false
+editor/convert_colors_with_editor_theme=false
diff --git a/game/addons/zylann.hterrain/tools/icons/icon_anchor_right.svg b/game/addons/zylann.hterrain/tools/icons/icon_anchor_right.svg
new file mode 100644
index 0000000..8ad32f1
--- /dev/null
+++ b/game/addons/zylann.hterrain/tools/icons/icon_anchor_right.svg
@@ -0,0 +1,66 @@
+<?xml version="1.0" encoding="UTF-8" standalone="no"?>
+<svg
+ xmlns:dc="http://purl.org/dc/elements/1.1/"
+ xmlns:cc="http://creativecommons.org/ns#"
+ xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#"
+ xmlns:svg="http://www.w3.org/2000/svg"
+ xmlns="http://www.w3.org/2000/svg"
+ xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
+ xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
+ width="16"
+ height="16"
+ version="1.1"
+ viewBox="0 0 16 16"
+ id="svg2"
+ inkscape:version="0.92.3 (2405546, 2018-03-11)"
+ sodipodi:docname="icon_anchor_right.svg">
+ <metadata
+ id="metadata12">
+ <rdf:RDF>
+ <cc:Work
+ rdf:about="">
+ <dc:format>image/svg+xml</dc:format>
+ <dc:type
+ rdf:resource="http://purl.org/dc/dcmitype/StillImage" />
+ <dc:title></dc:title>
+ </cc:Work>
+ </rdf:RDF>
+ </metadata>
+ <defs
+ id="defs10" />
+ <sodipodi:namedview
+ pagecolor="#ffffff"
+ bordercolor="#666666"
+ borderopacity="1"
+ objecttolerance="10"
+ gridtolerance="10"
+ guidetolerance="10"
+ inkscape:pageopacity="0"
+ inkscape:pageshadow="2"
+ inkscape:window-width="1461"
+ inkscape:window-height="826"
+ id="namedview8"
+ showgrid="true"
+ inkscape:snap-grids="true"
+ inkscape:snap-global="true"
+ inkscape:snap-bbox="false"
+ inkscape:snap-page="true"
+ inkscape:object-nodes="true"
+ inkscape:zoom="32"
+ inkscape:cx="7.7923218"
+ inkscape:cy="7.6837119"
+ inkscape:window-x="156"
+ inkscape:window-y="100"
+ inkscape:window-maximized="0"
+ inkscape:current-layer="svg2">
+ <inkscape:grid
+ type="xygrid"
+ id="grid4142" />
+ </sodipodi:namedview>
+ <path
+ style="fill:#e0e0e0;fill-opacity:1;stroke:#000000;stroke-width:1px;stroke-linecap:butt;stroke-linejoin:miter;stroke-opacity:1"
+ d="M 14,8 8,12 V 10 H 2 V 6 H 8 V 4 Z"
+ id="path816"
+ inkscape:connector-curvature="0"
+ sodipodi:nodetypes="cccccccc" />
+</svg>
diff --git a/game/addons/zylann.hterrain/tools/icons/icon_anchor_right.svg.import b/game/addons/zylann.hterrain/tools/icons/icon_anchor_right.svg.import
new file mode 100644
index 0000000..42b5b1c
--- /dev/null
+++ b/game/addons/zylann.hterrain/tools/icons/icon_anchor_right.svg.import
@@ -0,0 +1,37 @@
+[remap]
+
+importer="texture"
+type="CompressedTexture2D"
+uid="uid://cut3xadorcxqt"
+path="res://.godot/imported/icon_anchor_right.svg-90e3a37e8d38587bac01703849f8b9f7.ctex"
+metadata={
+"vram_texture": false
+}
+
+[deps]
+
+source_file="res://addons/zylann.hterrain/tools/icons/icon_anchor_right.svg"
+dest_files=["res://.godot/imported/icon_anchor_right.svg-90e3a37e8d38587bac01703849f8b9f7.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
+svg/scale=1.0
+editor/scale_with_editor_scale=false
+editor/convert_colors_with_editor_theme=false
diff --git a/game/addons/zylann.hterrain/tools/icons/icon_anchor_top.svg b/game/addons/zylann.hterrain/tools/icons/icon_anchor_top.svg
new file mode 100644
index 0000000..e15ed1a
--- /dev/null
+++ b/game/addons/zylann.hterrain/tools/icons/icon_anchor_top.svg
@@ -0,0 +1,66 @@
+<?xml version="1.0" encoding="UTF-8" standalone="no"?>
+<svg
+ xmlns:dc="http://purl.org/dc/elements/1.1/"
+ xmlns:cc="http://creativecommons.org/ns#"
+ xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#"
+ xmlns:svg="http://www.w3.org/2000/svg"
+ xmlns="http://www.w3.org/2000/svg"
+ xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
+ xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
+ width="16"
+ height="16"
+ version="1.1"
+ viewBox="0 0 16 16"
+ id="svg2"
+ inkscape:version="0.92.3 (2405546, 2018-03-11)"
+ sodipodi:docname="icon_anchor_top.svg">
+ <metadata
+ id="metadata12">
+ <rdf:RDF>
+ <cc:Work
+ rdf:about="">
+ <dc:format>image/svg+xml</dc:format>
+ <dc:type
+ rdf:resource="http://purl.org/dc/dcmitype/StillImage" />
+ <dc:title></dc:title>
+ </cc:Work>
+ </rdf:RDF>
+ </metadata>
+ <defs
+ id="defs10" />
+ <sodipodi:namedview
+ pagecolor="#ffffff"
+ bordercolor="#666666"
+ borderopacity="1"
+ objecttolerance="10"
+ gridtolerance="10"
+ guidetolerance="10"
+ inkscape:pageopacity="0"
+ inkscape:pageshadow="2"
+ inkscape:window-width="1461"
+ inkscape:window-height="826"
+ id="namedview8"
+ showgrid="true"
+ inkscape:snap-grids="true"
+ inkscape:snap-global="true"
+ inkscape:snap-bbox="false"
+ inkscape:snap-page="true"
+ inkscape:object-nodes="true"
+ inkscape:zoom="32"
+ inkscape:cx="7.7923218"
+ inkscape:cy="7.6837119"
+ inkscape:window-x="156"
+ inkscape:window-y="100"
+ inkscape:window-maximized="0"
+ inkscape:current-layer="svg2">
+ <inkscape:grid
+ type="xygrid"
+ id="grid4142" />
+ </sodipodi:namedview>
+ <path
+ style="fill:#e0e0e0;fill-opacity:1;stroke:#000000;stroke-width:1px;stroke-linecap:butt;stroke-linejoin:miter;stroke-opacity:1"
+ d="m 8,2 4,6 h -2 v 6 H 6 V 8 H 4 Z"
+ id="path816"
+ inkscape:connector-curvature="0"
+ sodipodi:nodetypes="cccccccc" />
+</svg>
diff --git a/game/addons/zylann.hterrain/tools/icons/icon_anchor_top.svg.import b/game/addons/zylann.hterrain/tools/icons/icon_anchor_top.svg.import
new file mode 100644
index 0000000..50e96dc
--- /dev/null
+++ b/game/addons/zylann.hterrain/tools/icons/icon_anchor_top.svg.import
@@ -0,0 +1,37 @@
+[remap]
+
+importer="texture"
+type="CompressedTexture2D"
+uid="uid://cprd188dc3fc7"
+path="res://.godot/imported/icon_anchor_top.svg-f1dcf93e569fe43b280b5dc072ee78e5.ctex"
+metadata={
+"vram_texture": false
+}
+
+[deps]
+
+source_file="res://addons/zylann.hterrain/tools/icons/icon_anchor_top.svg"
+dest_files=["res://.godot/imported/icon_anchor_top.svg-f1dcf93e569fe43b280b5dc072ee78e5.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
+svg/scale=1.0
+editor/scale_with_editor_scale=false
+editor/convert_colors_with_editor_theme=false
diff --git a/game/addons/zylann.hterrain/tools/icons/icon_anchor_top_left.svg b/game/addons/zylann.hterrain/tools/icons/icon_anchor_top_left.svg
new file mode 100644
index 0000000..cd82a6f
--- /dev/null
+++ b/game/addons/zylann.hterrain/tools/icons/icon_anchor_top_left.svg
@@ -0,0 +1,66 @@
+<?xml version="1.0" encoding="UTF-8" standalone="no"?>
+<svg
+ xmlns:dc="http://purl.org/dc/elements/1.1/"
+ xmlns:cc="http://creativecommons.org/ns#"
+ xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#"
+ xmlns:svg="http://www.w3.org/2000/svg"
+ xmlns="http://www.w3.org/2000/svg"
+ xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
+ xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
+ width="16"
+ height="16"
+ version="1.1"
+ viewBox="0 0 16 16"
+ id="svg2"
+ inkscape:version="0.92.3 (2405546, 2018-03-11)"
+ sodipodi:docname="icon_anchor_top_left.svg">
+ <metadata
+ id="metadata12">
+ <rdf:RDF>
+ <cc:Work
+ rdf:about="">
+ <dc:format>image/svg+xml</dc:format>
+ <dc:type
+ rdf:resource="http://purl.org/dc/dcmitype/StillImage" />
+ <dc:title></dc:title>
+ </cc:Work>
+ </rdf:RDF>
+ </metadata>
+ <defs
+ id="defs10" />
+ <sodipodi:namedview
+ pagecolor="#ffffff"
+ bordercolor="#666666"
+ borderopacity="1"
+ objecttolerance="10"
+ gridtolerance="10"
+ guidetolerance="10"
+ inkscape:pageopacity="0"
+ inkscape:pageshadow="2"
+ inkscape:window-width="1461"
+ inkscape:window-height="826"
+ id="namedview8"
+ showgrid="true"
+ inkscape:snap-grids="true"
+ inkscape:snap-global="true"
+ inkscape:snap-bbox="false"
+ inkscape:snap-page="true"
+ inkscape:object-nodes="true"
+ inkscape:zoom="32"
+ inkscape:cx="7.7923218"
+ inkscape:cy="7.6837119"
+ inkscape:window-x="156"
+ inkscape:window-y="100"
+ inkscape:window-maximized="0"
+ inkscape:current-layer="svg2">
+ <inkscape:grid
+ type="xygrid"
+ id="grid4142" />
+ </sodipodi:namedview>
+ <path
+ style="fill:#e0e0e0;fill-opacity:1;stroke:#000000;stroke-width:1px;stroke-linecap:butt;stroke-linejoin:miter;stroke-opacity:1"
+ d="M 3.0307755,2.9999998 10.101844,4.4142138 8.6876298,5.8284268 12.930271,10.071068 10.101844,12.899495 5.8592027,8.6568544 4.4449891,10.071068 Z"
+ id="path816"
+ inkscape:connector-curvature="0"
+ sodipodi:nodetypes="cccccccc" />
+</svg>
diff --git a/game/addons/zylann.hterrain/tools/icons/icon_anchor_top_left.svg.import b/game/addons/zylann.hterrain/tools/icons/icon_anchor_top_left.svg.import
new file mode 100644
index 0000000..832b11e
--- /dev/null
+++ b/game/addons/zylann.hterrain/tools/icons/icon_anchor_top_left.svg.import
@@ -0,0 +1,37 @@
+[remap]
+
+importer="texture"
+type="CompressedTexture2D"
+uid="uid://bugm51ryp0urq"
+path="res://.godot/imported/icon_anchor_top_left.svg-aea4438056394f9967bf74b13799fedc.ctex"
+metadata={
+"vram_texture": false
+}
+
+[deps]
+
+source_file="res://addons/zylann.hterrain/tools/icons/icon_anchor_top_left.svg"
+dest_files=["res://.godot/imported/icon_anchor_top_left.svg-aea4438056394f9967bf74b13799fedc.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
+svg/scale=1.0
+editor/scale_with_editor_scale=false
+editor/convert_colors_with_editor_theme=false
diff --git a/game/addons/zylann.hterrain/tools/icons/icon_anchor_top_right.svg b/game/addons/zylann.hterrain/tools/icons/icon_anchor_top_right.svg
new file mode 100644
index 0000000..4619493
--- /dev/null
+++ b/game/addons/zylann.hterrain/tools/icons/icon_anchor_top_right.svg
@@ -0,0 +1,66 @@
+<?xml version="1.0" encoding="UTF-8" standalone="no"?>
+<svg
+ xmlns:dc="http://purl.org/dc/elements/1.1/"
+ xmlns:cc="http://creativecommons.org/ns#"
+ xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#"
+ xmlns:svg="http://www.w3.org/2000/svg"
+ xmlns="http://www.w3.org/2000/svg"
+ xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
+ xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
+ width="16"
+ height="16"
+ version="1.1"
+ viewBox="0 0 16 16"
+ id="svg2"
+ inkscape:version="0.92.3 (2405546, 2018-03-11)"
+ sodipodi:docname="icon_anchor_top_right.svg">
+ <metadata
+ id="metadata12">
+ <rdf:RDF>
+ <cc:Work
+ rdf:about="">
+ <dc:format>image/svg+xml</dc:format>
+ <dc:type
+ rdf:resource="http://purl.org/dc/dcmitype/StillImage" />
+ <dc:title></dc:title>
+ </cc:Work>
+ </rdf:RDF>
+ </metadata>
+ <defs
+ id="defs10" />
+ <sodipodi:namedview
+ pagecolor="#ffffff"
+ bordercolor="#666666"
+ borderopacity="1"
+ objecttolerance="10"
+ gridtolerance="10"
+ guidetolerance="10"
+ inkscape:pageopacity="0"
+ inkscape:pageshadow="2"
+ inkscape:window-width="1461"
+ inkscape:window-height="826"
+ id="namedview8"
+ showgrid="true"
+ inkscape:snap-grids="true"
+ inkscape:snap-global="true"
+ inkscape:snap-bbox="false"
+ inkscape:snap-page="true"
+ inkscape:object-nodes="true"
+ inkscape:zoom="32"
+ inkscape:cx="7.7923218"
+ inkscape:cy="7.6837119"
+ inkscape:window-x="156"
+ inkscape:window-y="100"
+ inkscape:window-maximized="0"
+ inkscape:current-layer="svg2">
+ <inkscape:grid
+ type="xygrid"
+ id="grid4142" />
+ </sodipodi:namedview>
+ <path
+ style="fill:#e0e0e0;fill-opacity:1;stroke:#000000;stroke-width:1px;stroke-linecap:butt;stroke-linejoin:miter;stroke-opacity:1"
+ d="M 13,3 11.585786,10.071068 10.171573,8.6568543 5.9289319,12.899495 3.1005047,10.071068 7.3431454,5.8284272 5.9289319,4.4142136 Z"
+ id="path816"
+ inkscape:connector-curvature="0"
+ sodipodi:nodetypes="cccccccc" />
+</svg>
diff --git a/game/addons/zylann.hterrain/tools/icons/icon_anchor_top_right.svg.import b/game/addons/zylann.hterrain/tools/icons/icon_anchor_top_right.svg.import
new file mode 100644
index 0000000..146b057
--- /dev/null
+++ b/game/addons/zylann.hterrain/tools/icons/icon_anchor_top_right.svg.import
@@ -0,0 +1,37 @@
+[remap]
+
+importer="texture"
+type="CompressedTexture2D"
+uid="uid://bebc1xsqguspq"
+path="res://.godot/imported/icon_anchor_top_right.svg-e9f520f41c9c20cc5e64aca56427ca01.ctex"
+metadata={
+"vram_texture": false
+}
+
+[deps]
+
+source_file="res://addons/zylann.hterrain/tools/icons/icon_anchor_top_right.svg"
+dest_files=["res://.godot/imported/icon_anchor_top_right.svg-e9f520f41c9c20cc5e64aca56427ca01.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
+svg/scale=1.0
+editor/scale_with_editor_scale=false
+editor/convert_colors_with_editor_theme=false
diff --git a/game/addons/zylann.hterrain/tools/icons/icon_detail_layer_node.svg b/game/addons/zylann.hterrain/tools/icons/icon_detail_layer_node.svg
new file mode 100644
index 0000000..f5d8156
--- /dev/null
+++ b/game/addons/zylann.hterrain/tools/icons/icon_detail_layer_node.svg
@@ -0,0 +1,90 @@
+<?xml version="1.0" encoding="UTF-8" standalone="no"?>
+<svg
+ xmlns:dc="http://purl.org/dc/elements/1.1/"
+ xmlns:cc="http://creativecommons.org/ns#"
+ xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#"
+ xmlns:svg="http://www.w3.org/2000/svg"
+ xmlns="http://www.w3.org/2000/svg"
+ xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
+ xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
+ width="16"
+ height="16"
+ version="1.1"
+ viewBox="0 0 16 16"
+ id="svg2"
+ inkscape:version="0.92.3 (2405546, 2018-03-11)"
+ sodipodi:docname="icon_detail_layer_node.svg">
+ <metadata
+ id="metadata12">
+ <rdf:RDF>
+ <cc:Work
+ rdf:about="">
+ <dc:format>image/svg+xml</dc:format>
+ <dc:type
+ rdf:resource="http://purl.org/dc/dcmitype/StillImage" />
+ <dc:title></dc:title>
+ </cc:Work>
+ </rdf:RDF>
+ </metadata>
+ <defs
+ id="defs10" />
+ <sodipodi:namedview
+ pagecolor="#ffffff"
+ bordercolor="#666666"
+ borderopacity="1"
+ objecttolerance="10"
+ gridtolerance="10"
+ guidetolerance="10"
+ inkscape:pageopacity="0"
+ inkscape:pageshadow="2"
+ inkscape:window-width="1920"
+ inkscape:window-height="1017"
+ id="namedview8"
+ showgrid="true"
+ inkscape:zoom="41.7193"
+ inkscape:cx="4.6131224"
+ inkscape:cy="7.1663773"
+ inkscape:window-x="-8"
+ inkscape:window-y="32"
+ inkscape:window-maximized="1"
+ inkscape:current-layer="svg2"
+ inkscape:object-paths="false"
+ inkscape:snap-smooth-nodes="false"
+ inkscape:object-nodes="true"
+ inkscape:snap-grids="false"
+ inkscape:snap-to-guides="true">
+ <inkscape:grid
+ type="xygrid"
+ id="grid4140" />
+ </sodipodi:namedview>
+ <path
+ style="fill:#fc9c9c;fill-opacity:0.99607843;fill-rule:evenodd;stroke:none;stroke-width:1px;stroke-linecap:butt;stroke-linejoin:miter;stroke-opacity:1"
+ d="M 3,15 C 3,15 3.1390243,13.835072 2.827418,12.420858 2.5397814,11.150462 1,9 1,9 1,9 2.9698195,10.552501 3.5930323,11.679078 4.5757909,13.021382 5,15 5,15 Z"
+ id="path4142"
+ inkscape:connector-curvature="0"
+ sodipodi:nodetypes="cccccc" />
+ <path
+ style="fill:#fc9c9c;fill-opacity:0.99607843;fill-rule:evenodd;stroke:none;stroke-width:1px;stroke-linecap:butt;stroke-linejoin:miter;stroke-opacity:1"
+ d="M 5,15 C 5,15 5.2636669,12.287637 5,10.513877 4.8082422,8.8359968 3.4793944,5 3.4793944,5 c 0,0 1.8082423,2.5214194 2.5206056,5 1,3.235912 1,5 1,5 z"
+ id="path4144"
+ inkscape:connector-curvature="0"
+ sodipodi:nodetypes="cccccc" />
+ <path
+ style="fill:#fc9c9c;fill-opacity:0.99607843;fill-rule:evenodd;stroke:none;stroke-width:1px;stroke-linecap:butt;stroke-linejoin:miter;stroke-opacity:1"
+ d="M 7,15 C 7,15 7.5561735,6.4731267 7.5731227,5.3205843 7.6578684,4.2019402 8,1.1355932 8,1.1355932 c 0,0 0.3992971,3.1361329 0.4501446,4.2886753 C 8.5857378,7.0852854 9,15 9,15 Z"
+ id="path4146"
+ inkscape:connector-curvature="0"
+ sodipodi:nodetypes="cccccc" />
+ <path
+ style="fill:#fc9c9c;fill-opacity:0.99607843;fill-rule:evenodd;stroke:none;stroke-width:1px;stroke-linecap:butt;stroke-linejoin:miter;stroke-opacity:1"
+ d="M 9,15 C 9,15 9.5761564,11.857317 10.007611,10.562952 10.747482,8.4130973 12,6 12,6 12,6 11.115351,9.5993511 10.923593,11.181353 10.659926,13.003052 11,15 11,15 Z"
+ id="path4148"
+ inkscape:connector-curvature="0"
+ sodipodi:nodetypes="cccccc" />
+ <path
+ style="fill:#fc9c9c;fill-opacity:0.99607843;fill-rule:evenodd;stroke:none;stroke-width:1px;stroke-linecap:butt;stroke-linejoin:miter;stroke-opacity:1"
+ d="m 11,15 c 0,0 -0.08106,-0.693375 0.733907,-2.395225 C 12.596817,10.735137 15,9 15,9 15,9 13.425134,10.982994 12.925634,12.583274 12.434254,14.309094 13,15 13,15 Z"
+ id="path4148-1"
+ inkscape:connector-curvature="0"
+ sodipodi:nodetypes="cccccc" />
+</svg>
diff --git a/game/addons/zylann.hterrain/tools/icons/icon_detail_layer_node.svg.import b/game/addons/zylann.hterrain/tools/icons/icon_detail_layer_node.svg.import
new file mode 100644
index 0000000..941c747
--- /dev/null
+++ b/game/addons/zylann.hterrain/tools/icons/icon_detail_layer_node.svg.import
@@ -0,0 +1,37 @@
+[remap]
+
+importer="texture"
+type="CompressedTexture2D"
+uid="uid://6jmdwj3vn6m0"
+path="res://.godot/imported/icon_detail_layer_node.svg-70daba484432569847b1d2fe22768af3.ctex"
+metadata={
+"vram_texture": false
+}
+
+[deps]
+
+source_file="res://addons/zylann.hterrain/tools/icons/icon_detail_layer_node.svg"
+dest_files=["res://.godot/imported/icon_detail_layer_node.svg-70daba484432569847b1d2fe22768af3.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
+svg/scale=1.0
+editor/scale_with_editor_scale=false
+editor/convert_colors_with_editor_theme=false
diff --git a/game/addons/zylann.hterrain/tools/icons/icon_grass.svg b/game/addons/zylann.hterrain/tools/icons/icon_grass.svg
new file mode 100644
index 0000000..7866628
--- /dev/null
+++ b/game/addons/zylann.hterrain/tools/icons/icon_grass.svg
@@ -0,0 +1,90 @@
+<?xml version="1.0" encoding="UTF-8" standalone="no"?>
+<svg
+ xmlns:dc="http://purl.org/dc/elements/1.1/"
+ xmlns:cc="http://creativecommons.org/ns#"
+ xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#"
+ xmlns:svg="http://www.w3.org/2000/svg"
+ xmlns="http://www.w3.org/2000/svg"
+ xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
+ xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
+ width="16"
+ height="16"
+ version="1.1"
+ viewBox="0 0 16 16"
+ id="svg2"
+ inkscape:version="0.91 r13725"
+ sodipodi:docname="icon_grass.svg">
+ <metadata
+ id="metadata12">
+ <rdf:RDF>
+ <cc:Work
+ rdf:about="">
+ <dc:format>image/svg+xml</dc:format>
+ <dc:type
+ rdf:resource="http://purl.org/dc/dcmitype/StillImage" />
+ <dc:title></dc:title>
+ </cc:Work>
+ </rdf:RDF>
+ </metadata>
+ <defs
+ id="defs10" />
+ <sodipodi:namedview
+ pagecolor="#ffffff"
+ bordercolor="#666666"
+ borderopacity="1"
+ objecttolerance="10"
+ gridtolerance="10"
+ guidetolerance="10"
+ inkscape:pageopacity="0"
+ inkscape:pageshadow="2"
+ inkscape:window-width="1920"
+ inkscape:window-height="1017"
+ id="namedview8"
+ showgrid="true"
+ inkscape:zoom="41.7193"
+ inkscape:cx="8.5082021"
+ inkscape:cy="7.1663773"
+ inkscape:window-x="-8"
+ inkscape:window-y="32"
+ inkscape:window-maximized="1"
+ inkscape:current-layer="svg2"
+ inkscape:object-paths="false"
+ inkscape:snap-smooth-nodes="false"
+ inkscape:object-nodes="true"
+ inkscape:snap-grids="false"
+ inkscape:snap-to-guides="true">
+ <inkscape:grid
+ type="xygrid"
+ id="grid4140" />
+ </sodipodi:namedview>
+ <path
+ style="fill:#e0e0e0;fill-opacity:0.99607843;fill-rule:evenodd;stroke:none;stroke-width:1px;stroke-linecap:butt;stroke-linejoin:miter;stroke-opacity:1"
+ d="M 3,15 C 3,15 3.1390243,13.835072 2.827418,12.420858 2.5397814,11.150462 1,9 1,9 1,9 2.9698195,10.552501 3.5930323,11.679078 4.5757909,13.021382 5,15 5,15 Z"
+ id="path4142"
+ inkscape:connector-curvature="0"
+ sodipodi:nodetypes="cccccc" />
+ <path
+ style="fill:#e0e0e0;fill-opacity:0.99607843;fill-rule:evenodd;stroke:none;stroke-width:1px;stroke-linecap:butt;stroke-linejoin:miter;stroke-opacity:1"
+ d="M 5,15 C 5,15 5.2636669,12.287637 5,10.513877 4.8082422,8.8359968 3.4793944,5 3.4793944,5 c 0,0 1.8082423,2.5214194 2.5206056,5 1,3.235912 1,5 1,5 z"
+ id="path4144"
+ inkscape:connector-curvature="0"
+ sodipodi:nodetypes="cccccc" />
+ <path
+ style="fill:#e0e0e0;fill-opacity:0.99607843;fill-rule:evenodd;stroke:none;stroke-width:1px;stroke-linecap:butt;stroke-linejoin:miter;stroke-opacity:1"
+ d="M 7,15 C 7,15 7.5561735,6.4731267 7.5731227,5.3205843 7.6578684,4.2019402 8,1.1355932 8,1.1355932 c 0,0 0.3992971,3.1361329 0.4501446,4.2886753 C 8.5857378,7.0852854 9,15 9,15 Z"
+ id="path4146"
+ inkscape:connector-curvature="0"
+ sodipodi:nodetypes="cccccc" />
+ <path
+ style="fill:#e0e0e0;fill-opacity:0.99607843;fill-rule:evenodd;stroke:none;stroke-width:1px;stroke-linecap:butt;stroke-linejoin:miter;stroke-opacity:1"
+ d="M 9,15 C 9,15 9.5761564,11.857317 10.007611,10.562952 10.747482,8.4130973 12,6 12,6 12,6 11.115351,9.5993511 10.923593,11.181353 10.659926,13.003052 11,15 11,15 Z"
+ id="path4148"
+ inkscape:connector-curvature="0"
+ sodipodi:nodetypes="cccccc" />
+ <path
+ style="fill:#e0e0e0;fill-opacity:0.99607843;fill-rule:evenodd;stroke:none;stroke-width:1px;stroke-linecap:butt;stroke-linejoin:miter;stroke-opacity:1"
+ d="m 11,15 c 0,0 -0.08106,-0.693375 0.733907,-2.395225 C 12.596817,10.735137 15,9 15,9 15,9 13.425134,10.982994 12.925634,12.583274 12.434254,14.309094 13,15 13,15 Z"
+ id="path4148-1"
+ inkscape:connector-curvature="0"
+ sodipodi:nodetypes="cccccc" />
+</svg>
diff --git a/game/addons/zylann.hterrain/tools/icons/icon_grass.svg.import b/game/addons/zylann.hterrain/tools/icons/icon_grass.svg.import
new file mode 100644
index 0000000..ad48667
--- /dev/null
+++ b/game/addons/zylann.hterrain/tools/icons/icon_grass.svg.import
@@ -0,0 +1,37 @@
+[remap]
+
+importer="texture"
+type="CompressedTexture2D"
+uid="uid://coicfqoi4spg0"
+path="res://.godot/imported/icon_grass.svg-6a20eb11bc23d46b8a4c0f365f95554b.ctex"
+metadata={
+"vram_texture": false
+}
+
+[deps]
+
+source_file="res://addons/zylann.hterrain/tools/icons/icon_grass.svg"
+dest_files=["res://.godot/imported/icon_grass.svg-6a20eb11bc23d46b8a4c0f365f95554b.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
+svg/scale=1.0
+editor/scale_with_editor_scale=false
+editor/convert_colors_with_editor_theme=false
diff --git a/game/addons/zylann.hterrain/tools/icons/icon_heightmap_color.svg b/game/addons/zylann.hterrain/tools/icons/icon_heightmap_color.svg
new file mode 100644
index 0000000..2b20f6a
--- /dev/null
+++ b/game/addons/zylann.hterrain/tools/icons/icon_heightmap_color.svg
@@ -0,0 +1,90 @@
+<?xml version="1.0" encoding="UTF-8" standalone="no"?>
+<svg
+ xmlns:dc="http://purl.org/dc/elements/1.1/"
+ xmlns:cc="http://creativecommons.org/ns#"
+ xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#"
+ xmlns:svg="http://www.w3.org/2000/svg"
+ xmlns="http://www.w3.org/2000/svg"
+ xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
+ xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
+ width="16"
+ height="16"
+ version="1.1"
+ viewBox="0 0 16 16"
+ id="svg2"
+ inkscape:version="0.91 r13725"
+ sodipodi:docname="icon_heightmap_color.svg">
+ <metadata
+ id="metadata24">
+ <rdf:RDF>
+ <cc:Work
+ rdf:about="">
+ <dc:format>image/svg+xml</dc:format>
+ <dc:type
+ rdf:resource="http://purl.org/dc/dcmitype/StillImage" />
+ <dc:title></dc:title>
+ </cc:Work>
+ </rdf:RDF>
+ </metadata>
+ <defs
+ id="defs22" />
+ <sodipodi:namedview
+ pagecolor="#ffffff"
+ bordercolor="#666666"
+ borderopacity="1"
+ objecttolerance="10"
+ gridtolerance="10"
+ guidetolerance="10"
+ inkscape:pageopacity="0"
+ inkscape:pageshadow="2"
+ inkscape:window-width="1144"
+ inkscape:window-height="700"
+ id="namedview20"
+ showgrid="false"
+ inkscape:zoom="29.5"
+ inkscape:cx="7.9557659"
+ inkscape:cy="7.5167559"
+ inkscape:window-x="196"
+ inkscape:window-y="116"
+ inkscape:window-maximized="0"
+ inkscape:current-layer="svg2" />
+ <g
+ transform="translate(0 -1036.4)"
+ id="g4">
+ <path
+ transform="translate(0 1036.4)"
+ d="m13.303 1c-0.4344 0-0.86973 0.16881-1.2012 0.50586l-1.4688 1.4941h4.3418c0.082839-0.52789-0.072596-1.0872-0.47266-1.4941-0.33144-0.33705-0.76482-0.50586-1.1992-0.50586z"
+ fill="#ff7070"
+ id="path6" />
+ <path
+ transform="translate(0 1036.4)"
+ d="m10.633 3l-1.9668 2h4.8008l1.0352-1.0527c0.2628-0.2673 0.41824-0.60049 0.47266-0.94727h-4.3418z"
+ fill="#ffeb70"
+ id="path8" />
+ <path
+ transform="translate(0 1036.4)"
+ d="m8.666 5l-1.9648 2h4.7988l1.9668-2h-4.8008z"
+ fill="#9dff70"
+ id="path10" />
+ <path
+ transform="translate(0 1036.4)"
+ d="m6.7012 7l-1.4004 1.4238 0.56641 0.57617h3.668l1.9648-2h-4.7988z"
+ fill="#70ffb9"
+ id="path12" />
+ <path
+ transform="translate(0 1036.4)"
+ d="m5.8672 9l1.834 1.8652 1.834-1.8652h-3.668zm-1.752 0.57812c-0.48501-0.048725-0.90521 0.12503-1.1953 0.45508-0.21472 0.24426-0.35243 0.57797-0.39844 0.9668h3.5625c-0.10223-0.1935-0.22224-0.37965-0.38281-0.54297-0.55011-0.55955-1.1009-0.83018-1.5859-0.87891z"
+ fill="#70deff"
+ id="path14" />
+ <path
+ transform="translate(0 1036.4)"
+ d="m1.3242 13c0.18414 0.24071 0.43707 0.53374 0.83789 0.94141 0.88382 0.899 2.6552 0.67038 3.5391-0.22852 0.20747-0.21103 0.36064-0.45476 0.4707-0.71289h-4.8477z"
+ fill="#ff70ac"
+ id="path16" />
+ <path
+ transform="translate(0 1036.4)"
+ d="m2.5215 11c-0.0105 0.088737-0.021484 0.17696-0.021484 0.27148 0 1.3947-2.2782 0.28739-1.1758 1.7285h4.8477c0.27363-0.64173 0.24047-1.3785-0.087891-2h-3.5625z"
+ fill="#9f70ff"
+ id="path18" />
+ </g>
+</svg>
diff --git a/game/addons/zylann.hterrain/tools/icons/icon_heightmap_color.svg.import b/game/addons/zylann.hterrain/tools/icons/icon_heightmap_color.svg.import
new file mode 100644
index 0000000..a72fade
--- /dev/null
+++ b/game/addons/zylann.hterrain/tools/icons/icon_heightmap_color.svg.import
@@ -0,0 +1,37 @@
+[remap]
+
+importer="texture"
+type="CompressedTexture2D"
+uid="uid://t1m58v6iitwb"
+path="res://.godot/imported/icon_heightmap_color.svg-2b3375697cab4a6c7b8d933fc7f2b982.ctex"
+metadata={
+"vram_texture": false
+}
+
+[deps]
+
+source_file="res://addons/zylann.hterrain/tools/icons/icon_heightmap_color.svg"
+dest_files=["res://.godot/imported/icon_heightmap_color.svg-2b3375697cab4a6c7b8d933fc7f2b982.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
+svg/scale=1.0
+editor/scale_with_editor_scale=false
+editor/convert_colors_with_editor_theme=false
diff --git a/game/addons/zylann.hterrain/tools/icons/icon_heightmap_data.svg b/game/addons/zylann.hterrain/tools/icons/icon_heightmap_data.svg
new file mode 100644
index 0000000..03c0caf
--- /dev/null
+++ b/game/addons/zylann.hterrain/tools/icons/icon_heightmap_data.svg
@@ -0,0 +1,61 @@
+<?xml version="1.0" encoding="UTF-8" standalone="no"?>
+<svg
+ xmlns:dc="http://purl.org/dc/elements/1.1/"
+ xmlns:cc="http://creativecommons.org/ns#"
+ xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#"
+ xmlns:svg="http://www.w3.org/2000/svg"
+ xmlns="http://www.w3.org/2000/svg"
+ xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
+ xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
+ width="16"
+ height="16"
+ version="1.1"
+ viewBox="0 0 16 16"
+ id="svg2"
+ inkscape:version="0.91 r13725"
+ sodipodi:docname="icon_heightmap_data.svg">
+ <metadata
+ id="metadata12">
+ <rdf:RDF>
+ <cc:Work
+ rdf:about="">
+ <dc:format>image/svg+xml</dc:format>
+ <dc:type
+ rdf:resource="http://purl.org/dc/dcmitype/StillImage" />
+ </cc:Work>
+ </rdf:RDF>
+ </metadata>
+ <defs
+ id="defs10" />
+ <sodipodi:namedview
+ pagecolor="#ffffff"
+ bordercolor="#666666"
+ borderopacity="1"
+ objecttolerance="10"
+ gridtolerance="10"
+ guidetolerance="10"
+ inkscape:pageopacity="0"
+ inkscape:pageshadow="2"
+ inkscape:window-width="1920"
+ inkscape:window-height="1017"
+ id="namedview8"
+ showgrid="true"
+ inkscape:object-nodes="true"
+ inkscape:zoom="29.5"
+ inkscape:cx="8.3604136"
+ inkscape:cy="6.4516445"
+ inkscape:window-x="-8"
+ inkscape:window-y="32"
+ inkscape:window-maximized="1"
+ inkscape:current-layer="svg2">
+ <inkscape:grid
+ type="xygrid"
+ id="grid4140" />
+ </sodipodi:namedview>
+ <path
+ style="fill:#ffd684;fill-opacity:0.99607843;fill-rule:evenodd;stroke:none;stroke-width:1px;stroke-linecap:butt;stroke-linejoin:miter;stroke-opacity:1"
+ d="m 1,15 0,-6 4,-7 3,6 2,-2 5,5 0,4 z"
+ id="path4142"
+ inkscape:connector-curvature="0"
+ sodipodi:nodetypes="cccccccc" />
+</svg>
diff --git a/game/addons/zylann.hterrain/tools/icons/icon_heightmap_data.svg.import b/game/addons/zylann.hterrain/tools/icons/icon_heightmap_data.svg.import
new file mode 100644
index 0000000..9db362d
--- /dev/null
+++ b/game/addons/zylann.hterrain/tools/icons/icon_heightmap_data.svg.import
@@ -0,0 +1,37 @@
+[remap]
+
+importer="texture"
+type="CompressedTexture2D"
+uid="uid://cd13q0qxqt8cd"
+path="res://.godot/imported/icon_heightmap_data.svg-00236b6035ce13dd687a19d98237bdbd.ctex"
+metadata={
+"vram_texture": false
+}
+
+[deps]
+
+source_file="res://addons/zylann.hterrain/tools/icons/icon_heightmap_data.svg"
+dest_files=["res://.godot/imported/icon_heightmap_data.svg-00236b6035ce13dd687a19d98237bdbd.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
+svg/scale=1.0
+editor/scale_with_editor_scale=false
+editor/convert_colors_with_editor_theme=false
diff --git a/game/addons/zylann.hterrain/tools/icons/icon_heightmap_erode.svg b/game/addons/zylann.hterrain/tools/icons/icon_heightmap_erode.svg
new file mode 100644
index 0000000..22824a2
--- /dev/null
+++ b/game/addons/zylann.hterrain/tools/icons/icon_heightmap_erode.svg
@@ -0,0 +1,102 @@
+<?xml version="1.0" encoding="UTF-8" standalone="no"?>
+<svg
+ xmlns:dc="http://purl.org/dc/elements/1.1/"
+ xmlns:cc="http://creativecommons.org/ns#"
+ xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#"
+ xmlns:svg="http://www.w3.org/2000/svg"
+ xmlns="http://www.w3.org/2000/svg"
+ xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
+ xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
+ width="16"
+ height="16"
+ version="1.1"
+ viewBox="0 0 16 16"
+ id="svg2"
+ inkscape:version="0.92.3 (2405546, 2018-03-11)"
+ sodipodi:docname="icon_heightmap_erode.svg">
+ <metadata
+ id="metadata12">
+ <rdf:RDF>
+ <cc:Work
+ rdf:about="">
+ <dc:format>image/svg+xml</dc:format>
+ <dc:type
+ rdf:resource="http://purl.org/dc/dcmitype/StillImage" />
+ <dc:title></dc:title>
+ </cc:Work>
+ </rdf:RDF>
+ </metadata>
+ <defs
+ id="defs10" />
+ <sodipodi:namedview
+ pagecolor="#ffffff"
+ bordercolor="#666666"
+ borderopacity="1"
+ objecttolerance="10"
+ gridtolerance="10"
+ guidetolerance="10"
+ inkscape:pageopacity="0"
+ inkscape:pageshadow="2"
+ inkscape:window-width="1461"
+ inkscape:window-height="826"
+ id="namedview8"
+ showgrid="true"
+ inkscape:snap-grids="true"
+ inkscape:snap-global="true"
+ inkscape:snap-bbox="false"
+ inkscape:snap-page="true"
+ inkscape:object-nodes="true"
+ inkscape:zoom="22.627417"
+ inkscape:cx="6.6927042"
+ inkscape:cy="3.6274493"
+ inkscape:window-x="213"
+ inkscape:window-y="134"
+ inkscape:window-maximized="0"
+ inkscape:current-layer="svg2">
+ <inkscape:grid
+ type="xygrid"
+ id="grid4142" />
+ </sodipodi:namedview>
+ <path
+ style="fill:#e0e0e0;fill-opacity:0.99607843;fill-rule:evenodd;stroke:none;stroke-width:1px;stroke-linecap:butt;stroke-linejoin:miter;stroke-opacity:1"
+ d="M 1,15 V 11 H 1.40625 C 2.40625,11 4,9 5,8 c 3,3 4,-2 5,-3 2,4 4.44321,5.97721 4.752221,6 H 15 v 4 z"
+ id="path4144"
+ inkscape:connector-curvature="0"
+ sodipodi:nodetypes="ccccccccc" />
+ <path
+ style="fill:none;stroke:#e0e0e0;stroke-width:0.69999999;stroke-linecap:butt;stroke-linejoin:miter;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:0.99607843"
+ d="M 7.4375,5.34375 6,7"
+ id="path4518"
+ inkscape:connector-curvature="0"
+ sodipodi:nodetypes="cc" />
+ <path
+ style="fill:none;stroke:#e0e0e0;stroke-width:0.69999999;stroke-linecap:butt;stroke-linejoin:miter;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:0.99607843"
+ d="M 3.4375,5.34375 2,7"
+ id="path4518-0"
+ inkscape:connector-curvature="0"
+ sodipodi:nodetypes="cc" />
+ <path
+ style="fill:none;stroke:#e0e0e0;stroke-width:0.69999999;stroke-linecap:butt;stroke-linejoin:miter;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:0.99607843"
+ d="M 3.4375,1.96875 2,3.625"
+ id="path4518-6"
+ inkscape:connector-curvature="0"
+ sodipodi:nodetypes="cc" />
+ <path
+ style="fill:none;stroke:#e0e0e0;stroke-width:0.69999999;stroke-linecap:butt;stroke-linejoin:miter;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:0.99607843"
+ d="M 7.4375,1.9375 6,3.59375"
+ id="path4518-7"
+ inkscape:connector-curvature="0"
+ sodipodi:nodetypes="cc" />
+ <path
+ style="fill:none;stroke:#e0e0e0;stroke-width:0.69999999;stroke-linecap:butt;stroke-linejoin:miter;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:0.99607843"
+ d="M 14.03125,5.34375 12.59375,7"
+ id="path4518-5"
+ inkscape:connector-curvature="0"
+ sodipodi:nodetypes="cc" />
+ <path
+ style="fill:none;stroke:#e0e0e0;stroke-width:0.69999999;stroke-linecap:butt;stroke-linejoin:miter;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:0.99607843"
+ d="M 13,2 11.5625,3.65625"
+ id="path4518-3"
+ inkscape:connector-curvature="0"
+ sodipodi:nodetypes="cc" />
+</svg>
diff --git a/game/addons/zylann.hterrain/tools/icons/icon_heightmap_erode.svg.import b/game/addons/zylann.hterrain/tools/icons/icon_heightmap_erode.svg.import
new file mode 100644
index 0000000..c1424e6
--- /dev/null
+++ b/game/addons/zylann.hterrain/tools/icons/icon_heightmap_erode.svg.import
@@ -0,0 +1,37 @@
+[remap]
+
+importer="texture"
+type="CompressedTexture2D"
+uid="uid://ci3jif8wx2tig"
+path="res://.godot/imported/icon_heightmap_erode.svg-fad285f0810d69bec16027ac0257c223.ctex"
+metadata={
+"vram_texture": false
+}
+
+[deps]
+
+source_file="res://addons/zylann.hterrain/tools/icons/icon_heightmap_erode.svg"
+dest_files=["res://.godot/imported/icon_heightmap_erode.svg-fad285f0810d69bec16027ac0257c223.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
+svg/scale=1.0
+editor/scale_with_editor_scale=false
+editor/convert_colors_with_editor_theme=false
diff --git a/game/addons/zylann.hterrain/tools/icons/icon_heightmap_flatten.svg b/game/addons/zylann.hterrain/tools/icons/icon_heightmap_flatten.svg
new file mode 100644
index 0000000..c4d3268
--- /dev/null
+++ b/game/addons/zylann.hterrain/tools/icons/icon_heightmap_flatten.svg
@@ -0,0 +1,116 @@
+<?xml version="1.0" encoding="UTF-8" standalone="no"?>
+<svg
+ xmlns:dc="http://purl.org/dc/elements/1.1/"
+ xmlns:cc="http://creativecommons.org/ns#"
+ xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#"
+ xmlns:svg="http://www.w3.org/2000/svg"
+ xmlns="http://www.w3.org/2000/svg"
+ xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
+ xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
+ width="16"
+ height="16"
+ version="1.1"
+ viewBox="0 0 16 16"
+ id="svg2"
+ inkscape:version="0.91 r13725"
+ sodipodi:docname="icon_heightmap_flatten.svg">
+ <metadata
+ id="metadata12">
+ <rdf:RDF>
+ <cc:Work
+ rdf:about="">
+ <dc:format>image/svg+xml</dc:format>
+ <dc:type
+ rdf:resource="http://purl.org/dc/dcmitype/StillImage" />
+ <dc:title></dc:title>
+ </cc:Work>
+ </rdf:RDF>
+ </metadata>
+ <defs
+ id="defs10" />
+ <sodipodi:namedview
+ pagecolor="#ffffff"
+ bordercolor="#666666"
+ borderopacity="1"
+ objecttolerance="10"
+ gridtolerance="10"
+ guidetolerance="10"
+ inkscape:pageopacity="0"
+ inkscape:pageshadow="2"
+ inkscape:window-width="1461"
+ inkscape:window-height="826"
+ id="namedview8"
+ showgrid="true"
+ inkscape:snap-grids="true"
+ inkscape:snap-global="true"
+ inkscape:snap-bbox="false"
+ inkscape:snap-page="true"
+ inkscape:object-nodes="true"
+ inkscape:zoom="32"
+ inkscape:cx="9.7058237"
+ inkscape:cy="8.5274381"
+ inkscape:window-x="365"
+ inkscape:window-y="160"
+ inkscape:window-maximized="0"
+ inkscape:current-layer="svg2">
+ <inkscape:grid
+ type="xygrid"
+ id="grid4142" />
+ </sodipodi:namedview>
+ <rect
+ style="fill:#e0e0e0;fill-opacity:0.99607843;stroke:none;stroke-width:1;stroke-linecap:butt;stroke-linejoin:bevel;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:0.99607843"
+ id="rect5378"
+ width="1"
+ height="1"
+ x="1"
+ y="14" />
+ <rect
+ style="fill:#e0e0e0;fill-opacity:0.99607843;stroke:none;stroke-width:1;stroke-linecap:butt;stroke-linejoin:bevel;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:0.99607843"
+ id="rect5380"
+ width="1"
+ height="1"
+ x="1"
+ y="12" />
+ <rect
+ style="fill:#e0e0e0;fill-opacity:0.99607843;stroke:none;stroke-width:1;stroke-linecap:butt;stroke-linejoin:bevel;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:0.99607843"
+ id="rect5382"
+ width="1"
+ height="1"
+ x="1"
+ y="10" />
+ <rect
+ style="fill:#e0e0e0;fill-opacity:0.99607843;stroke:none;stroke-width:1;stroke-linecap:butt;stroke-linejoin:bevel;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:0.99607843"
+ id="rect5384"
+ width="1"
+ height="1"
+ x="1"
+ y="8" />
+ <rect
+ style="fill:#e0e0e0;fill-opacity:0.99607843;stroke:none;stroke-width:1;stroke-linecap:butt;stroke-linejoin:bevel;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:0.99607843"
+ id="rect5386"
+ width="1"
+ height="1"
+ x="1"
+ y="6" />
+ <rect
+ style="fill:#e0e0e0;fill-opacity:0.99607843;stroke:none;stroke-width:1;stroke-linecap:butt;stroke-linejoin:bevel;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:0.99607843"
+ id="rect5388"
+ width="1"
+ height="1"
+ x="1"
+ y="4" />
+ <rect
+ style="fill:#e0e0e0;fill-opacity:0.99607843;stroke:none;stroke-width:1;stroke-linecap:butt;stroke-linejoin:bevel;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:0.99607843"
+ id="rect5390"
+ width="1"
+ height="1"
+ x="1"
+ y="2" />
+ <rect
+ style="fill:#e0e0e0;fill-opacity:0.99607843;stroke:none;stroke-width:1;stroke-linecap:butt;stroke-linejoin:bevel;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:0.99607843"
+ id="rect5394"
+ width="12"
+ height="7"
+ x="3"
+ y="8" />
+</svg>
diff --git a/game/addons/zylann.hterrain/tools/icons/icon_heightmap_flatten.svg.import b/game/addons/zylann.hterrain/tools/icons/icon_heightmap_flatten.svg.import
new file mode 100644
index 0000000..9c1c4bf
--- /dev/null
+++ b/game/addons/zylann.hterrain/tools/icons/icon_heightmap_flatten.svg.import
@@ -0,0 +1,37 @@
+[remap]
+
+importer="texture"
+type="CompressedTexture2D"
+uid="uid://6bnt8lvvabf1"
+path="res://.godot/imported/icon_heightmap_flatten.svg-3d183c33fce9f34c419c53418ef26264.ctex"
+metadata={
+"vram_texture": false
+}
+
+[deps]
+
+source_file="res://addons/zylann.hterrain/tools/icons/icon_heightmap_flatten.svg"
+dest_files=["res://.godot/imported/icon_heightmap_flatten.svg-3d183c33fce9f34c419c53418ef26264.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
+svg/scale=1.0
+editor/scale_with_editor_scale=false
+editor/convert_colors_with_editor_theme=false
diff --git a/game/addons/zylann.hterrain/tools/icons/icon_heightmap_level.svg b/game/addons/zylann.hterrain/tools/icons/icon_heightmap_level.svg
new file mode 100644
index 0000000..d7c27e9
--- /dev/null
+++ b/game/addons/zylann.hterrain/tools/icons/icon_heightmap_level.svg
@@ -0,0 +1,72 @@
+<?xml version="1.0" encoding="UTF-8" standalone="no"?>
+<svg
+ xmlns:dc="http://purl.org/dc/elements/1.1/"
+ xmlns:cc="http://creativecommons.org/ns#"
+ xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#"
+ xmlns:svg="http://www.w3.org/2000/svg"
+ xmlns="http://www.w3.org/2000/svg"
+ xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
+ xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
+ width="16"
+ height="16"
+ version="1.1"
+ viewBox="0 0 16 16"
+ id="svg2"
+ inkscape:version="0.92.3 (2405546, 2018-03-11)"
+ sodipodi:docname="icon_heightmap_level.svg">
+ <metadata
+ id="metadata12">
+ <rdf:RDF>
+ <cc:Work
+ rdf:about="">
+ <dc:format>image/svg+xml</dc:format>
+ <dc:type
+ rdf:resource="http://purl.org/dc/dcmitype/StillImage" />
+ <dc:title />
+ </cc:Work>
+ </rdf:RDF>
+ </metadata>
+ <defs
+ id="defs10" />
+ <sodipodi:namedview
+ pagecolor="#ffffff"
+ bordercolor="#666666"
+ borderopacity="1"
+ objecttolerance="10"
+ gridtolerance="10"
+ guidetolerance="10"
+ inkscape:pageopacity="0"
+ inkscape:pageshadow="2"
+ inkscape:window-width="1920"
+ inkscape:window-height="1017"
+ id="namedview8"
+ showgrid="true"
+ inkscape:snap-grids="true"
+ inkscape:snap-global="true"
+ inkscape:snap-bbox="false"
+ inkscape:snap-page="true"
+ inkscape:object-nodes="true"
+ inkscape:zoom="45.254834"
+ inkscape:cx="4.155788"
+ inkscape:cy="7.8347394"
+ inkscape:window-x="-8"
+ inkscape:window-y="32"
+ inkscape:window-maximized="1"
+ inkscape:current-layer="svg2">
+ <inkscape:grid
+ type="xygrid"
+ id="grid4142" />
+ </sodipodi:namedview>
+ <path
+ style="fill:#e0e0e0;fill-opacity:0.99607843;fill-rule:evenodd;stroke:none;stroke-width:1px;stroke-linecap:butt;stroke-linejoin:miter;stroke-opacity:1"
+ d="M 1,6 C 2,8 4,8.9722718 8,8.9722718 12,8.9722718 14,10 15,12 v 3 H 1 Z"
+ id="path5360"
+ inkscape:connector-curvature="0"
+ sodipodi:nodetypes="cscccc" />
+ <path
+ style="fill:#e0e0e0;fill-opacity:0.99607843;fill-rule:evenodd;stroke:none;stroke-width:1px;stroke-linecap:butt;stroke-linejoin:miter;stroke-opacity:1"
+ d="m 6.8785322,3.363427 c 0,0 2.1754294,2.616635 4.0821628,3.2996386 0,0 1.356257,0.5732925 2.410224,0.2317564 0,0 0.585083,-0.023232 0.850573,-0.894822 C 14.572655,4.057836 13.309359,3.7206006 13.309359,3.7206006 11.88355,3.1179083 11.514409,4.5359712 11.514409,4.5359712 10.709977,5.2535353 6.8785322,3.363427 6.8785322,3.363427 Z"
+ id="path4136"
+ inkscape:connector-curvature="0"
+ sodipodi:nodetypes="ccccccc" />
+</svg>
diff --git a/game/addons/zylann.hterrain/tools/icons/icon_heightmap_level.svg.import b/game/addons/zylann.hterrain/tools/icons/icon_heightmap_level.svg.import
new file mode 100644
index 0000000..29294ec
--- /dev/null
+++ b/game/addons/zylann.hterrain/tools/icons/icon_heightmap_level.svg.import
@@ -0,0 +1,37 @@
+[remap]
+
+importer="texture"
+type="CompressedTexture2D"
+uid="uid://c6trw4wyo86x4"
+path="res://.godot/imported/icon_heightmap_level.svg-0abbb78afcf28f4da15188c85861a768.ctex"
+metadata={
+"vram_texture": false
+}
+
+[deps]
+
+source_file="res://addons/zylann.hterrain/tools/icons/icon_heightmap_level.svg"
+dest_files=["res://.godot/imported/icon_heightmap_level.svg-0abbb78afcf28f4da15188c85861a768.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
+svg/scale=1.0
+editor/scale_with_editor_scale=false
+editor/convert_colors_with_editor_theme=false
diff --git a/game/addons/zylann.hterrain/tools/icons/icon_heightmap_lower.svg b/game/addons/zylann.hterrain/tools/icons/icon_heightmap_lower.svg
new file mode 100644
index 0000000..87101c3
--- /dev/null
+++ b/game/addons/zylann.hterrain/tools/icons/icon_heightmap_lower.svg
@@ -0,0 +1,66 @@
+<?xml version="1.0" encoding="UTF-8" standalone="no"?>
+<svg
+ xmlns:dc="http://purl.org/dc/elements/1.1/"
+ xmlns:cc="http://creativecommons.org/ns#"
+ xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#"
+ xmlns:svg="http://www.w3.org/2000/svg"
+ xmlns="http://www.w3.org/2000/svg"
+ xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
+ xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
+ width="16"
+ height="16"
+ version="1.1"
+ viewBox="0 0 16 16"
+ id="svg2"
+ inkscape:version="0.91 r13725"
+ sodipodi:docname="icon_heightmap_lower.svg">
+ <metadata
+ id="metadata12">
+ <rdf:RDF>
+ <cc:Work
+ rdf:about="">
+ <dc:format>image/svg+xml</dc:format>
+ <dc:type
+ rdf:resource="http://purl.org/dc/dcmitype/StillImage" />
+ <dc:title></dc:title>
+ </cc:Work>
+ </rdf:RDF>
+ </metadata>
+ <defs
+ id="defs10" />
+ <sodipodi:namedview
+ pagecolor="#ffffff"
+ bordercolor="#666666"
+ borderopacity="1"
+ objecttolerance="10"
+ gridtolerance="10"
+ guidetolerance="10"
+ inkscape:pageopacity="0"
+ inkscape:pageshadow="2"
+ inkscape:window-width="1461"
+ inkscape:window-height="826"
+ id="namedview8"
+ showgrid="true"
+ inkscape:snap-grids="true"
+ inkscape:snap-global="true"
+ inkscape:snap-bbox="false"
+ inkscape:snap-page="true"
+ inkscape:object-nodes="true"
+ inkscape:zoom="32"
+ inkscape:cx="7.4686221"
+ inkscape:cy="9.0616705"
+ inkscape:window-x="70"
+ inkscape:window-y="136"
+ inkscape:window-maximized="0"
+ inkscape:current-layer="svg2">
+ <inkscape:grid
+ type="xygrid"
+ id="grid4142" />
+ </sodipodi:namedview>
+ <path
+ style="fill:#e0e0e0;fill-opacity:0.99607843;fill-rule:evenodd;stroke:none;stroke-width:1px;stroke-linecap:butt;stroke-linejoin:miter;stroke-opacity:1"
+ d="M 1,15 1,5 2,5 c 2.0108349,-0.022097 2.9891651,5 5,5 l 2,0 c 2.032932,-0.022097 3.011262,-4.9779029 5,-5 l 1,0 0,10 z"
+ id="path4144"
+ inkscape:connector-curvature="0"
+ sodipodi:nodetypes="ccccccccc" />
+</svg>
diff --git a/game/addons/zylann.hterrain/tools/icons/icon_heightmap_lower.svg.import b/game/addons/zylann.hterrain/tools/icons/icon_heightmap_lower.svg.import
new file mode 100644
index 0000000..97163b3
--- /dev/null
+++ b/game/addons/zylann.hterrain/tools/icons/icon_heightmap_lower.svg.import
@@ -0,0 +1,37 @@
+[remap]
+
+importer="texture"
+type="CompressedTexture2D"
+uid="uid://da02ckrgusaoe"
+path="res://.godot/imported/icon_heightmap_lower.svg-5bb5cae46ea03f9d65d6c497a65882db.ctex"
+metadata={
+"vram_texture": false
+}
+
+[deps]
+
+source_file="res://addons/zylann.hterrain/tools/icons/icon_heightmap_lower.svg"
+dest_files=["res://.godot/imported/icon_heightmap_lower.svg-5bb5cae46ea03f9d65d6c497a65882db.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
+svg/scale=1.0
+editor/scale_with_editor_scale=false
+editor/convert_colors_with_editor_theme=false
diff --git a/game/addons/zylann.hterrain/tools/icons/icon_heightmap_mask.svg b/game/addons/zylann.hterrain/tools/icons/icon_heightmap_mask.svg
new file mode 100644
index 0000000..751b683
--- /dev/null
+++ b/game/addons/zylann.hterrain/tools/icons/icon_heightmap_mask.svg
@@ -0,0 +1,66 @@
+<?xml version="1.0" encoding="UTF-8" standalone="no"?>
+<svg
+ xmlns:dc="http://purl.org/dc/elements/1.1/"
+ xmlns:cc="http://creativecommons.org/ns#"
+ xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#"
+ xmlns:svg="http://www.w3.org/2000/svg"
+ xmlns="http://www.w3.org/2000/svg"
+ xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
+ xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
+ width="16"
+ height="16"
+ version="1.1"
+ viewBox="0 0 16 16"
+ id="svg2"
+ inkscape:version="0.91 r13725"
+ sodipodi:docname="icon_heightmap_mask.svg">
+ <metadata
+ id="metadata12">
+ <rdf:RDF>
+ <cc:Work
+ rdf:about="">
+ <dc:format>image/svg+xml</dc:format>
+ <dc:type
+ rdf:resource="http://purl.org/dc/dcmitype/StillImage" />
+ <dc:title />
+ </cc:Work>
+ </rdf:RDF>
+ </metadata>
+ <defs
+ id="defs10" />
+ <sodipodi:namedview
+ pagecolor="#ffffff"
+ bordercolor="#666666"
+ borderopacity="1"
+ objecttolerance="10"
+ gridtolerance="10"
+ guidetolerance="10"
+ inkscape:pageopacity="0"
+ inkscape:pageshadow="2"
+ inkscape:window-width="1589"
+ inkscape:window-height="856"
+ id="namedview8"
+ showgrid="true"
+ inkscape:snap-grids="true"
+ inkscape:snap-global="true"
+ inkscape:snap-bbox="false"
+ inkscape:snap-page="true"
+ inkscape:object-nodes="true"
+ inkscape:zoom="32"
+ inkscape:cx="6.8076485"
+ inkscape:cy="7.7003818"
+ inkscape:window-x="137"
+ inkscape:window-y="97"
+ inkscape:window-maximized="0"
+ inkscape:current-layer="svg2">
+ <inkscape:grid
+ type="xygrid"
+ id="grid4142" />
+ </sodipodi:namedview>
+ <path
+ style="fill:#e0e0e0;fill-opacity:0.99607843;fill-rule:evenodd;stroke:none;stroke-width:1px;stroke-linecap:butt;stroke-linejoin:miter;stroke-opacity:1"
+ d="m 11.49953,1.171875 c -1.166824,0 -2.3207558,1.1527185 -2.3336474,1.7003605 l 0,2.9105135 -3.5004705,3.4581554 -2.3336469,-2.305437 c -5.615338,5.7635926 0,11.2633516 5.8341174,5.7635926 l -2.333647,-2.305437 3.5004704,-3.4581556 3.062153,0 C 13.820285,6.9354674 15,5.7917546 15,4.6300305 Z m -0.960556,2.305437 c 0,0 -0.003,-0.7398046 0.73179,-0.7780123 l 2.253012,2.2556182 c 0.01289,0.6877366 -0.857423,0.6367931 -0.857423,0.6367931 l -2.140272,0.012736 z m -5.12495,5.9773548 0.7999793,0.7969852 -2.4970952,1.875551 z"
+ id="path4134"
+ inkscape:connector-curvature="0"
+ sodipodi:nodetypes="ccccccccccccccccccccc" />
+</svg>
diff --git a/game/addons/zylann.hterrain/tools/icons/icon_heightmap_mask.svg.import b/game/addons/zylann.hterrain/tools/icons/icon_heightmap_mask.svg.import
new file mode 100644
index 0000000..7305e92
--- /dev/null
+++ b/game/addons/zylann.hterrain/tools/icons/icon_heightmap_mask.svg.import
@@ -0,0 +1,37 @@
+[remap]
+
+importer="texture"
+type="CompressedTexture2D"
+uid="uid://btb4kkafvn67j"
+path="res://.godot/imported/icon_heightmap_mask.svg-3fad663c59a229c1c6c17c4e8d5bad09.ctex"
+metadata={
+"vram_texture": false
+}
+
+[deps]
+
+source_file="res://addons/zylann.hterrain/tools/icons/icon_heightmap_mask.svg"
+dest_files=["res://.godot/imported/icon_heightmap_mask.svg-3fad663c59a229c1c6c17c4e8d5bad09.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
+svg/scale=1.0
+editor/scale_with_editor_scale=false
+editor/convert_colors_with_editor_theme=false
diff --git a/game/addons/zylann.hterrain/tools/icons/icon_heightmap_node.svg b/game/addons/zylann.hterrain/tools/icons/icon_heightmap_node.svg
new file mode 100644
index 0000000..00fb889
--- /dev/null
+++ b/game/addons/zylann.hterrain/tools/icons/icon_heightmap_node.svg
@@ -0,0 +1,61 @@
+<?xml version="1.0" encoding="UTF-8" standalone="no"?>
+<svg
+ xmlns:dc="http://purl.org/dc/elements/1.1/"
+ xmlns:cc="http://creativecommons.org/ns#"
+ xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#"
+ xmlns:svg="http://www.w3.org/2000/svg"
+ xmlns="http://www.w3.org/2000/svg"
+ xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
+ xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
+ width="16"
+ height="16"
+ version="1.1"
+ viewBox="0 0 16 16"
+ id="svg2"
+ inkscape:version="0.91 r13725"
+ sodipodi:docname="icon_heightmap_node.svg">
+ <metadata
+ id="metadata12">
+ <rdf:RDF>
+ <cc:Work
+ rdf:about="">
+ <dc:format>image/svg+xml</dc:format>
+ <dc:type
+ rdf:resource="http://purl.org/dc/dcmitype/StillImage" />
+ </cc:Work>
+ </rdf:RDF>
+ </metadata>
+ <defs
+ id="defs10" />
+ <sodipodi:namedview
+ pagecolor="#ffffff"
+ bordercolor="#666666"
+ borderopacity="1"
+ objecttolerance="10"
+ gridtolerance="10"
+ guidetolerance="10"
+ inkscape:pageopacity="0"
+ inkscape:pageshadow="2"
+ inkscape:window-width="1920"
+ inkscape:window-height="1017"
+ id="namedview8"
+ showgrid="true"
+ inkscape:object-nodes="true"
+ inkscape:zoom="29.5"
+ inkscape:cx="3.7841424"
+ inkscape:cy="7.6041869"
+ inkscape:window-x="-8"
+ inkscape:window-y="32"
+ inkscape:window-maximized="1"
+ inkscape:current-layer="svg2">
+ <inkscape:grid
+ type="xygrid"
+ id="grid4140" />
+ </sodipodi:namedview>
+ <path
+ style="fill:#fc9c9c;fill-opacity:0.99607843;fill-rule:evenodd;stroke:none;stroke-width:1px;stroke-linecap:butt;stroke-linejoin:miter;stroke-opacity:1"
+ d="m 1,15 0,-6 4,-7 3,6 2,-2 5,5 0,4 z"
+ id="path4142"
+ inkscape:connector-curvature="0"
+ sodipodi:nodetypes="cccccccc" />
+</svg>
diff --git a/game/addons/zylann.hterrain/tools/icons/icon_heightmap_node.svg.import b/game/addons/zylann.hterrain/tools/icons/icon_heightmap_node.svg.import
new file mode 100644
index 0000000..aa38f33
--- /dev/null
+++ b/game/addons/zylann.hterrain/tools/icons/icon_heightmap_node.svg.import
@@ -0,0 +1,37 @@
+[remap]
+
+importer="texture"
+type="CompressedTexture2D"
+uid="uid://ofs3osrmxfi"
+path="res://.godot/imported/icon_heightmap_node.svg-0b776ad0015c7d9d9553b161b36e70fe.ctex"
+metadata={
+"vram_texture": false
+}
+
+[deps]
+
+source_file="res://addons/zylann.hterrain/tools/icons/icon_heightmap_node.svg"
+dest_files=["res://.godot/imported/icon_heightmap_node.svg-0b776ad0015c7d9d9553b161b36e70fe.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
+svg/scale=1.0
+editor/scale_with_editor_scale=false
+editor/convert_colors_with_editor_theme=false
diff --git a/game/addons/zylann.hterrain/tools/icons/icon_heightmap_node_large.svg b/game/addons/zylann.hterrain/tools/icons/icon_heightmap_node_large.svg
new file mode 100644
index 0000000..00fb889
--- /dev/null
+++ b/game/addons/zylann.hterrain/tools/icons/icon_heightmap_node_large.svg
@@ -0,0 +1,61 @@
+<?xml version="1.0" encoding="UTF-8" standalone="no"?>
+<svg
+ xmlns:dc="http://purl.org/dc/elements/1.1/"
+ xmlns:cc="http://creativecommons.org/ns#"
+ xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#"
+ xmlns:svg="http://www.w3.org/2000/svg"
+ xmlns="http://www.w3.org/2000/svg"
+ xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
+ xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
+ width="16"
+ height="16"
+ version="1.1"
+ viewBox="0 0 16 16"
+ id="svg2"
+ inkscape:version="0.91 r13725"
+ sodipodi:docname="icon_heightmap_node.svg">
+ <metadata
+ id="metadata12">
+ <rdf:RDF>
+ <cc:Work
+ rdf:about="">
+ <dc:format>image/svg+xml</dc:format>
+ <dc:type
+ rdf:resource="http://purl.org/dc/dcmitype/StillImage" />
+ </cc:Work>
+ </rdf:RDF>
+ </metadata>
+ <defs
+ id="defs10" />
+ <sodipodi:namedview
+ pagecolor="#ffffff"
+ bordercolor="#666666"
+ borderopacity="1"
+ objecttolerance="10"
+ gridtolerance="10"
+ guidetolerance="10"
+ inkscape:pageopacity="0"
+ inkscape:pageshadow="2"
+ inkscape:window-width="1920"
+ inkscape:window-height="1017"
+ id="namedview8"
+ showgrid="true"
+ inkscape:object-nodes="true"
+ inkscape:zoom="29.5"
+ inkscape:cx="3.7841424"
+ inkscape:cy="7.6041869"
+ inkscape:window-x="-8"
+ inkscape:window-y="32"
+ inkscape:window-maximized="1"
+ inkscape:current-layer="svg2">
+ <inkscape:grid
+ type="xygrid"
+ id="grid4140" />
+ </sodipodi:namedview>
+ <path
+ style="fill:#fc9c9c;fill-opacity:0.99607843;fill-rule:evenodd;stroke:none;stroke-width:1px;stroke-linecap:butt;stroke-linejoin:miter;stroke-opacity:1"
+ d="m 1,15 0,-6 4,-7 3,6 2,-2 5,5 0,4 z"
+ id="path4142"
+ inkscape:connector-curvature="0"
+ sodipodi:nodetypes="cccccccc" />
+</svg>
diff --git a/game/addons/zylann.hterrain/tools/icons/icon_heightmap_node_large.svg.import b/game/addons/zylann.hterrain/tools/icons/icon_heightmap_node_large.svg.import
new file mode 100644
index 0000000..3478c92
--- /dev/null
+++ b/game/addons/zylann.hterrain/tools/icons/icon_heightmap_node_large.svg.import
@@ -0,0 +1,37 @@
+[remap]
+
+importer="texture"
+type="CompressedTexture2D"
+uid="uid://sdaddk8wxjin"
+path="res://.godot/imported/icon_heightmap_node_large.svg-4b8ff9077cb0d8dc06efcf638cce1edb.ctex"
+metadata={
+"vram_texture": false
+}
+
+[deps]
+
+source_file="res://addons/zylann.hterrain/tools/icons/icon_heightmap_node_large.svg"
+dest_files=["res://.godot/imported/icon_heightmap_node_large.svg-4b8ff9077cb0d8dc06efcf638cce1edb.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
+svg/scale=8.0
+editor/scale_with_editor_scale=false
+editor/convert_colors_with_editor_theme=false
diff --git a/game/addons/zylann.hterrain/tools/icons/icon_heightmap_paint.svg b/game/addons/zylann.hterrain/tools/icons/icon_heightmap_paint.svg
new file mode 100644
index 0000000..72503fa
--- /dev/null
+++ b/game/addons/zylann.hterrain/tools/icons/icon_heightmap_paint.svg
@@ -0,0 +1,5 @@
+<svg width="16" height="16" version="1.1" viewBox="0 0 16 16" xmlns="http://www.w3.org/2000/svg">
+<g transform="translate(0 -1036.4)">
+<path d="m2.9208 1046.4c-0.26373 0.3-0.4204 0.7296-0.4204 1.2383 0 1.6277-3.1381-0.1781-0.33757 2.6703 0.88382 0.899 2.6544 0.6701 3.5382-0.2288 0.88384-0.899 0.88382-2.3565 0-3.2554-1.1002-1.1191-2.2001-1.0845-2.7803-0.4244zm2.3802-1.6103 2.4005 2.4416 6.8014-6.9177c0.66286-0.6742 0.66286-1.7673 0-2.4415-0.66288-0.6741-1.7376-0.6741-2.4005 0z" fill="#e0e0e0" fill-opacity=".99608"/>
+</g>
+</svg>
diff --git a/game/addons/zylann.hterrain/tools/icons/icon_heightmap_paint.svg.import b/game/addons/zylann.hterrain/tools/icons/icon_heightmap_paint.svg.import
new file mode 100644
index 0000000..8d325c1
--- /dev/null
+++ b/game/addons/zylann.hterrain/tools/icons/icon_heightmap_paint.svg.import
@@ -0,0 +1,37 @@
+[remap]
+
+importer="texture"
+type="CompressedTexture2D"
+uid="uid://cgw6r4eg1msvn"
+path="res://.godot/imported/icon_heightmap_paint.svg-ad4c1d13ab344959f8e60b793d52d80d.ctex"
+metadata={
+"vram_texture": false
+}
+
+[deps]
+
+source_file="res://addons/zylann.hterrain/tools/icons/icon_heightmap_paint.svg"
+dest_files=["res://.godot/imported/icon_heightmap_paint.svg-ad4c1d13ab344959f8e60b793d52d80d.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
+svg/scale=1.0
+editor/scale_with_editor_scale=false
+editor/convert_colors_with_editor_theme=false
diff --git a/game/addons/zylann.hterrain/tools/icons/icon_heightmap_raise.svg b/game/addons/zylann.hterrain/tools/icons/icon_heightmap_raise.svg
new file mode 100644
index 0000000..0e3033f
--- /dev/null
+++ b/game/addons/zylann.hterrain/tools/icons/icon_heightmap_raise.svg
@@ -0,0 +1,66 @@
+<?xml version="1.0" encoding="UTF-8" standalone="no"?>
+<svg
+ xmlns:dc="http://purl.org/dc/elements/1.1/"
+ xmlns:cc="http://creativecommons.org/ns#"
+ xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#"
+ xmlns:svg="http://www.w3.org/2000/svg"
+ xmlns="http://www.w3.org/2000/svg"
+ xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
+ xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
+ width="16"
+ height="16"
+ version="1.1"
+ viewBox="0 0 16 16"
+ id="svg2"
+ inkscape:version="0.91 r13725"
+ sodipodi:docname="icon_heightmap_raise.svg">
+ <metadata
+ id="metadata12">
+ <rdf:RDF>
+ <cc:Work
+ rdf:about="">
+ <dc:format>image/svg+xml</dc:format>
+ <dc:type
+ rdf:resource="http://purl.org/dc/dcmitype/StillImage" />
+ <dc:title />
+ </cc:Work>
+ </rdf:RDF>
+ </metadata>
+ <defs
+ id="defs10" />
+ <sodipodi:namedview
+ pagecolor="#ffffff"
+ bordercolor="#666666"
+ borderopacity="1"
+ objecttolerance="10"
+ gridtolerance="10"
+ guidetolerance="10"
+ inkscape:pageopacity="0"
+ inkscape:pageshadow="2"
+ inkscape:window-width="1461"
+ inkscape:window-height="826"
+ id="namedview8"
+ showgrid="true"
+ inkscape:snap-grids="true"
+ inkscape:snap-global="true"
+ inkscape:snap-bbox="false"
+ inkscape:snap-page="true"
+ inkscape:object-nodes="true"
+ inkscape:zoom="32"
+ inkscape:cx="9.7284466"
+ inkscape:cy="8.2340886"
+ inkscape:window-x="213"
+ inkscape:window-y="134"
+ inkscape:window-maximized="0"
+ inkscape:current-layer="svg2">
+ <inkscape:grid
+ type="xygrid"
+ id="grid4142" />
+ </sodipodi:namedview>
+ <path
+ style="fill:#e0e0e0;fill-opacity:0.99607843;fill-rule:evenodd;stroke:none;stroke-width:1px;stroke-linecap:butt;stroke-linejoin:miter;stroke-opacity:1"
+ d="m 1,15 0,-5 1,0 C 4.0108349,10 5.0112622,5 7,5 l 2,0 c 2.010835,-0.022097 3.033359,5 5,5 l 1,0 0,5 z"
+ id="path4144"
+ inkscape:connector-curvature="0"
+ sodipodi:nodetypes="ccccccccc" />
+</svg>
diff --git a/game/addons/zylann.hterrain/tools/icons/icon_heightmap_raise.svg.import b/game/addons/zylann.hterrain/tools/icons/icon_heightmap_raise.svg.import
new file mode 100644
index 0000000..b856d27
--- /dev/null
+++ b/game/addons/zylann.hterrain/tools/icons/icon_heightmap_raise.svg.import
@@ -0,0 +1,37 @@
+[remap]
+
+importer="texture"
+type="CompressedTexture2D"
+uid="uid://0polw88tpkl4"
+path="res://.godot/imported/icon_heightmap_raise.svg-16ae516b9460ce83d04d965ed6b9989a.ctex"
+metadata={
+"vram_texture": false
+}
+
+[deps]
+
+source_file="res://addons/zylann.hterrain/tools/icons/icon_heightmap_raise.svg"
+dest_files=["res://.godot/imported/icon_heightmap_raise.svg-16ae516b9460ce83d04d965ed6b9989a.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
+svg/scale=1.0
+editor/scale_with_editor_scale=false
+editor/convert_colors_with_editor_theme=false
diff --git a/game/addons/zylann.hterrain/tools/icons/icon_heightmap_smooth.svg b/game/addons/zylann.hterrain/tools/icons/icon_heightmap_smooth.svg
new file mode 100644
index 0000000..c9d586a
--- /dev/null
+++ b/game/addons/zylann.hterrain/tools/icons/icon_heightmap_smooth.svg
@@ -0,0 +1,72 @@
+<?xml version="1.0" encoding="UTF-8" standalone="no"?>
+<svg
+ xmlns:dc="http://purl.org/dc/elements/1.1/"
+ xmlns:cc="http://creativecommons.org/ns#"
+ xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#"
+ xmlns:svg="http://www.w3.org/2000/svg"
+ xmlns="http://www.w3.org/2000/svg"
+ xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
+ xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
+ width="16"
+ height="16"
+ version="1.1"
+ viewBox="0 0 16 16"
+ id="svg2"
+ inkscape:version="0.91 r13725"
+ sodipodi:docname="icon_heightmap_smooth.svg">
+ <metadata
+ id="metadata12">
+ <rdf:RDF>
+ <cc:Work
+ rdf:about="">
+ <dc:format>image/svg+xml</dc:format>
+ <dc:type
+ rdf:resource="http://purl.org/dc/dcmitype/StillImage" />
+ <dc:title />
+ </cc:Work>
+ </rdf:RDF>
+ </metadata>
+ <defs
+ id="defs10" />
+ <sodipodi:namedview
+ pagecolor="#ffffff"
+ bordercolor="#666666"
+ borderopacity="1"
+ objecttolerance="10"
+ gridtolerance="10"
+ guidetolerance="10"
+ inkscape:pageopacity="0"
+ inkscape:pageshadow="2"
+ inkscape:window-width="1461"
+ inkscape:window-height="826"
+ id="namedview8"
+ showgrid="true"
+ inkscape:snap-grids="true"
+ inkscape:snap-global="true"
+ inkscape:snap-bbox="false"
+ inkscape:snap-page="true"
+ inkscape:object-nodes="true"
+ inkscape:zoom="16"
+ inkscape:cx="-4.9969866"
+ inkscape:cy="6.6592524"
+ inkscape:window-x="365"
+ inkscape:window-y="160"
+ inkscape:window-maximized="0"
+ inkscape:current-layer="svg2">
+ <inkscape:grid
+ type="xygrid"
+ id="grid4142" />
+ </sodipodi:namedview>
+ <path
+ style="fill:#e0e0e0;fill-opacity:0.99607843;fill-rule:evenodd;stroke:none;stroke-width:1px;stroke-linecap:butt;stroke-linejoin:miter;stroke-opacity:1"
+ d="m 1,15 0,-4 c 7.46875,-6 14,0 14,0 l 0,4 z"
+ id="path5360"
+ inkscape:connector-curvature="0"
+ sodipodi:nodetypes="ccccc" />
+ <path
+ style="fill:#e0e0e0;fill-opacity:0.99607843;fill-rule:evenodd;stroke:none;stroke-width:1px;stroke-linecap:butt;stroke-linejoin:miter;stroke-opacity:1"
+ d="m 6.364123,3.8274558 c 0,0 2.1754294,2.616635 4.082163,3.2996386 0,0 1.356257,0.5732925 2.410224,0.2317564 0,0 0.585083,-0.023232 0.850573,-0.894822 C 14.058246,4.5218648 12.79495,4.1846294 12.79495,4.1846294 11.369141,3.5819371 11,5 11,5 10.195568,5.7175641 6.364123,3.8274558 6.364123,3.8274558 Z"
+ id="path4136"
+ inkscape:connector-curvature="0"
+ sodipodi:nodetypes="ccccccc" />
+</svg>
diff --git a/game/addons/zylann.hterrain/tools/icons/icon_heightmap_smooth.svg.import b/game/addons/zylann.hterrain/tools/icons/icon_heightmap_smooth.svg.import
new file mode 100644
index 0000000..a7bff5d
--- /dev/null
+++ b/game/addons/zylann.hterrain/tools/icons/icon_heightmap_smooth.svg.import
@@ -0,0 +1,37 @@
+[remap]
+
+importer="texture"
+type="CompressedTexture2D"
+uid="uid://b6hlgjb4fa31c"
+path="res://.godot/imported/icon_heightmap_smooth.svg-1216ccdd3a408b8769b0a0964b7bd3f9.ctex"
+metadata={
+"vram_texture": false
+}
+
+[deps]
+
+source_file="res://addons/zylann.hterrain/tools/icons/icon_heightmap_smooth.svg"
+dest_files=["res://.godot/imported/icon_heightmap_smooth.svg-1216ccdd3a408b8769b0a0964b7bd3f9.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
+svg/scale=1.0
+editor/scale_with_editor_scale=false
+editor/convert_colors_with_editor_theme=false
diff --git a/game/addons/zylann.hterrain/tools/icons/icon_heightmap_unmask.svg b/game/addons/zylann.hterrain/tools/icons/icon_heightmap_unmask.svg
new file mode 100644
index 0000000..24f39a4
--- /dev/null
+++ b/game/addons/zylann.hterrain/tools/icons/icon_heightmap_unmask.svg
@@ -0,0 +1,68 @@
+<?xml version="1.0" encoding="UTF-8" standalone="no"?>
+<svg
+ xmlns:dc="http://purl.org/dc/elements/1.1/"
+ xmlns:cc="http://creativecommons.org/ns#"
+ xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#"
+ xmlns:svg="http://www.w3.org/2000/svg"
+ xmlns="http://www.w3.org/2000/svg"
+ xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
+ xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
+ width="16"
+ height="16"
+ version="1.1"
+ viewBox="0 0 16 16"
+ id="svg2"
+ inkscape:version="0.91 r13725"
+ sodipodi:docname="icon_heightmap_unmask.svg">
+ <metadata
+ id="metadata12">
+ <rdf:RDF>
+ <cc:Work
+ rdf:about="">
+ <dc:format>image/svg+xml</dc:format>
+ <dc:type
+ rdf:resource="http://purl.org/dc/dcmitype/StillImage" />
+ <dc:title></dc:title>
+ </cc:Work>
+ </rdf:RDF>
+ </metadata>
+ <defs
+ id="defs10" />
+ <sodipodi:namedview
+ pagecolor="#ffffff"
+ bordercolor="#666666"
+ borderopacity="1"
+ objecttolerance="10"
+ gridtolerance="10"
+ guidetolerance="10"
+ inkscape:pageopacity="0"
+ inkscape:pageshadow="2"
+ inkscape:window-width="1461"
+ inkscape:window-height="826"
+ id="namedview8"
+ showgrid="true"
+ inkscape:snap-grids="true"
+ inkscape:snap-global="true"
+ inkscape:snap-bbox="false"
+ inkscape:snap-page="true"
+ inkscape:object-nodes="true"
+ inkscape:zoom="32"
+ inkscape:cx="8.4714029"
+ inkscape:cy="7.9844314"
+ inkscape:window-x="70"
+ inkscape:window-y="136"
+ inkscape:window-maximized="0"
+ inkscape:current-layer="svg2">
+ <inkscape:grid
+ type="xygrid"
+ id="grid4142" />
+ </sodipodi:namedview>
+ <rect
+ style="fill:#e0e0e0;fill-opacity:0.99607843;stroke:none;stroke-width:1;stroke-linecap:butt;stroke-linejoin:bevel;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:0.99607843"
+ id="rect4140"
+ width="12"
+ height="12"
+ x="2"
+ y="2"
+ ry="2" />
+</svg>
diff --git a/game/addons/zylann.hterrain/tools/icons/icon_heightmap_unmask.svg.import b/game/addons/zylann.hterrain/tools/icons/icon_heightmap_unmask.svg.import
new file mode 100644
index 0000000..48dc9ea
--- /dev/null
+++ b/game/addons/zylann.hterrain/tools/icons/icon_heightmap_unmask.svg.import
@@ -0,0 +1,37 @@
+[remap]
+
+importer="texture"
+type="CompressedTexture2D"
+uid="uid://b4ya0po3a4nqa"
+path="res://.godot/imported/icon_heightmap_unmask.svg-f88c0addb6f444beecc364dd218d67e9.ctex"
+metadata={
+"vram_texture": false
+}
+
+[deps]
+
+source_file="res://addons/zylann.hterrain/tools/icons/icon_heightmap_unmask.svg"
+dest_files=["res://.godot/imported/icon_heightmap_unmask.svg-f88c0addb6f444beecc364dd218d67e9.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
+svg/scale=1.0
+editor/scale_with_editor_scale=false
+editor/convert_colors_with_editor_theme=false
diff --git a/game/addons/zylann.hterrain/tools/icons/icon_long_arrow_down.svg b/game/addons/zylann.hterrain/tools/icons/icon_long_arrow_down.svg
new file mode 100644
index 0000000..0cc022a
--- /dev/null
+++ b/game/addons/zylann.hterrain/tools/icons/icon_long_arrow_down.svg
@@ -0,0 +1,66 @@
+<?xml version="1.0" encoding="UTF-8" standalone="no"?>
+<svg
+ xmlns:dc="http://purl.org/dc/elements/1.1/"
+ xmlns:cc="http://creativecommons.org/ns#"
+ xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#"
+ xmlns:svg="http://www.w3.org/2000/svg"
+ xmlns="http://www.w3.org/2000/svg"
+ xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
+ xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
+ width="16"
+ height="64"
+ version="1.1"
+ viewBox="0 0 16 64"
+ id="svg2"
+ inkscape:version="0.92.3 (2405546, 2018-03-11)"
+ sodipodi:docname="icon_long_arrow_down.svg">
+ <metadata
+ id="metadata12">
+ <rdf:RDF>
+ <cc:Work
+ rdf:about="">
+ <dc:format>image/svg+xml</dc:format>
+ <dc:type
+ rdf:resource="http://purl.org/dc/dcmitype/StillImage" />
+ <dc:title></dc:title>
+ </cc:Work>
+ </rdf:RDF>
+ </metadata>
+ <defs
+ id="defs10" />
+ <sodipodi:namedview
+ pagecolor="#ffffff"
+ bordercolor="#666666"
+ borderopacity="1"
+ objecttolerance="10"
+ gridtolerance="10"
+ guidetolerance="10"
+ inkscape:pageopacity="0"
+ inkscape:pageshadow="2"
+ inkscape:window-width="1920"
+ inkscape:window-height="1017"
+ id="namedview8"
+ showgrid="true"
+ inkscape:snap-grids="true"
+ inkscape:snap-global="true"
+ inkscape:snap-bbox="false"
+ inkscape:snap-page="true"
+ inkscape:object-nodes="true"
+ inkscape:zoom="11.313709"
+ inkscape:cx="-2.0957389"
+ inkscape:cy="34.028204"
+ inkscape:window-x="-8"
+ inkscape:window-y="32"
+ inkscape:window-maximized="1"
+ inkscape:current-layer="svg2">
+ <inkscape:grid
+ type="xygrid"
+ id="grid4142" />
+ </sodipodi:namedview>
+ <path
+ style="fill:#e0e0e0;fill-opacity:1;stroke:none;stroke-width:1px;stroke-linecap:butt;stroke-linejoin:miter;stroke-opacity:1"
+ d="m 9,4 0,45 h 3 L 7,60 2,49 H 5 L 5,4 Z"
+ id="path816-6"
+ inkscape:connector-curvature="0"
+ sodipodi:nodetypes="cccccccc" />
+</svg>
diff --git a/game/addons/zylann.hterrain/tools/icons/icon_long_arrow_down.svg.import b/game/addons/zylann.hterrain/tools/icons/icon_long_arrow_down.svg.import
new file mode 100644
index 0000000..70f4a55
--- /dev/null
+++ b/game/addons/zylann.hterrain/tools/icons/icon_long_arrow_down.svg.import
@@ -0,0 +1,37 @@
+[remap]
+
+importer="texture"
+type="CompressedTexture2D"
+uid="uid://b6l5dys0awbwd"
+path="res://.godot/imported/icon_long_arrow_down.svg-baa34c94eaf2f9f3533b079350dd260b.ctex"
+metadata={
+"vram_texture": false
+}
+
+[deps]
+
+source_file="res://addons/zylann.hterrain/tools/icons/icon_long_arrow_down.svg"
+dest_files=["res://.godot/imported/icon_long_arrow_down.svg-baa34c94eaf2f9f3533b079350dd260b.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
+svg/scale=1.0
+editor/scale_with_editor_scale=false
+editor/convert_colors_with_editor_theme=false
diff --git a/game/addons/zylann.hterrain/tools/icons/icon_long_arrow_right.svg b/game/addons/zylann.hterrain/tools/icons/icon_long_arrow_right.svg
new file mode 100644
index 0000000..d4e8c2b
--- /dev/null
+++ b/game/addons/zylann.hterrain/tools/icons/icon_long_arrow_right.svg
@@ -0,0 +1,66 @@
+<?xml version="1.0" encoding="UTF-8" standalone="no"?>
+<svg
+ xmlns:dc="http://purl.org/dc/elements/1.1/"
+ xmlns:cc="http://creativecommons.org/ns#"
+ xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#"
+ xmlns:svg="http://www.w3.org/2000/svg"
+ xmlns="http://www.w3.org/2000/svg"
+ xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
+ xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
+ width="64"
+ height="16"
+ version="1.1"
+ viewBox="0 0 64 16"
+ id="svg2"
+ inkscape:version="0.92.3 (2405546, 2018-03-11)"
+ sodipodi:docname="icon_long_arrow_right.svg">
+ <metadata
+ id="metadata12">
+ <rdf:RDF>
+ <cc:Work
+ rdf:about="">
+ <dc:format>image/svg+xml</dc:format>
+ <dc:type
+ rdf:resource="http://purl.org/dc/dcmitype/StillImage" />
+ <dc:title></dc:title>
+ </cc:Work>
+ </rdf:RDF>
+ </metadata>
+ <defs
+ id="defs10" />
+ <sodipodi:namedview
+ pagecolor="#ffffff"
+ bordercolor="#666666"
+ borderopacity="1"
+ objecttolerance="10"
+ gridtolerance="10"
+ guidetolerance="10"
+ inkscape:pageopacity="0"
+ inkscape:pageshadow="2"
+ inkscape:window-width="1920"
+ inkscape:window-height="1017"
+ id="namedview8"
+ showgrid="true"
+ inkscape:snap-grids="true"
+ inkscape:snap-global="true"
+ inkscape:snap-bbox="false"
+ inkscape:snap-page="true"
+ inkscape:object-nodes="true"
+ inkscape:zoom="16"
+ inkscape:cx="26.365888"
+ inkscape:cy="17.303376"
+ inkscape:window-x="-8"
+ inkscape:window-y="32"
+ inkscape:window-maximized="1"
+ inkscape:current-layer="svg2">
+ <inkscape:grid
+ type="xygrid"
+ id="grid4142" />
+ </sodipodi:namedview>
+ <path
+ style="fill:#e0e0e0;fill-opacity:1;stroke:none;stroke-width:1px;stroke-linecap:butt;stroke-linejoin:miter;stroke-opacity:1"
+ d="M 4,6 H 49 V 3 L 60,8 49,13 V 10 H 4 Z"
+ id="path816-6"
+ inkscape:connector-curvature="0"
+ sodipodi:nodetypes="cccccccc" />
+</svg>
diff --git a/game/addons/zylann.hterrain/tools/icons/icon_long_arrow_right.svg.import b/game/addons/zylann.hterrain/tools/icons/icon_long_arrow_right.svg.import
new file mode 100644
index 0000000..4d77e3d
--- /dev/null
+++ b/game/addons/zylann.hterrain/tools/icons/icon_long_arrow_right.svg.import
@@ -0,0 +1,37 @@
+[remap]
+
+importer="texture"
+type="CompressedTexture2D"
+uid="uid://d3vie0tj3ry6k"
+path="res://.godot/imported/icon_long_arrow_right.svg-2e9c5428ca49af0df04372d4de12fdd2.ctex"
+metadata={
+"vram_texture": false
+}
+
+[deps]
+
+source_file="res://addons/zylann.hterrain/tools/icons/icon_long_arrow_right.svg"
+dest_files=["res://.godot/imported/icon_long_arrow_right.svg-2e9c5428ca49af0df04372d4de12fdd2.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
+svg/scale=1.0
+editor/scale_with_editor_scale=false
+editor/convert_colors_with_editor_theme=false
diff --git a/game/addons/zylann.hterrain/tools/icons/icon_minimap_out_of_range_position.svg b/game/addons/zylann.hterrain/tools/icons/icon_minimap_out_of_range_position.svg
new file mode 100644
index 0000000..34e2ab7
--- /dev/null
+++ b/game/addons/zylann.hterrain/tools/icons/icon_minimap_out_of_range_position.svg
@@ -0,0 +1,66 @@
+<?xml version="1.0" encoding="UTF-8" standalone="no"?>
+<svg
+ xmlns:dc="http://purl.org/dc/elements/1.1/"
+ xmlns:cc="http://creativecommons.org/ns#"
+ xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#"
+ xmlns:svg="http://www.w3.org/2000/svg"
+ xmlns="http://www.w3.org/2000/svg"
+ xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
+ xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
+ width="16"
+ height="16"
+ version="1.1"
+ viewBox="0 0 16 16"
+ id="svg2"
+ inkscape:version="0.92.3 (2405546, 2018-03-11)"
+ sodipodi:docname="icon_minimap_out_of_range_position.svg">
+ <metadata
+ id="metadata12">
+ <rdf:RDF>
+ <cc:Work
+ rdf:about="">
+ <dc:format>image/svg+xml</dc:format>
+ <dc:type
+ rdf:resource="http://purl.org/dc/dcmitype/StillImage" />
+ <dc:title></dc:title>
+ </cc:Work>
+ </rdf:RDF>
+ </metadata>
+ <defs
+ id="defs10" />
+ <sodipodi:namedview
+ pagecolor="#ffffff"
+ bordercolor="#666666"
+ borderopacity="1"
+ objecttolerance="10"
+ gridtolerance="10"
+ guidetolerance="10"
+ inkscape:pageopacity="0"
+ inkscape:pageshadow="2"
+ inkscape:window-width="1920"
+ inkscape:window-height="1017"
+ id="namedview8"
+ showgrid="true"
+ inkscape:snap-grids="true"
+ inkscape:snap-global="true"
+ inkscape:snap-bbox="false"
+ inkscape:snap-page="true"
+ inkscape:object-nodes="true"
+ inkscape:zoom="45.254834"
+ inkscape:cx="9.6162478"
+ inkscape:cy="7.4237281"
+ inkscape:window-x="-8"
+ inkscape:window-y="32"
+ inkscape:window-maximized="1"
+ inkscape:current-layer="svg2">
+ <inkscape:grid
+ type="xygrid"
+ id="grid4142" />
+ </sodipodi:namedview>
+ <circle
+ style="opacity:1;fill:#8eb3f3;fill-opacity:1;fill-rule:nonzero;stroke:#000000;stroke-width:0.9997896;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1"
+ id="path1480"
+ cx="7.9972649"
+ cy="8"
+ r="5" />
+</svg>
diff --git a/game/addons/zylann.hterrain/tools/icons/icon_minimap_out_of_range_position.svg.import b/game/addons/zylann.hterrain/tools/icons/icon_minimap_out_of_range_position.svg.import
new file mode 100644
index 0000000..0e73229
--- /dev/null
+++ b/game/addons/zylann.hterrain/tools/icons/icon_minimap_out_of_range_position.svg.import
@@ -0,0 +1,37 @@
+[remap]
+
+importer="texture"
+type="CompressedTexture2D"
+uid="uid://cc47smy24m368"
+path="res://.godot/imported/icon_minimap_out_of_range_position.svg-be0d8e592b6594137b0f40434b64f771.ctex"
+metadata={
+"vram_texture": false
+}
+
+[deps]
+
+source_file="res://addons/zylann.hterrain/tools/icons/icon_minimap_out_of_range_position.svg"
+dest_files=["res://.godot/imported/icon_minimap_out_of_range_position.svg-be0d8e592b6594137b0f40434b64f771.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
+svg/scale=1.0
+editor/scale_with_editor_scale=false
+editor/convert_colors_with_editor_theme=false
diff --git a/game/addons/zylann.hterrain/tools/icons/icon_minimap_position.svg b/game/addons/zylann.hterrain/tools/icons/icon_minimap_position.svg
new file mode 100644
index 0000000..468c52d
--- /dev/null
+++ b/game/addons/zylann.hterrain/tools/icons/icon_minimap_position.svg
@@ -0,0 +1,66 @@
+<?xml version="1.0" encoding="UTF-8" standalone="no"?>
+<svg
+ xmlns:dc="http://purl.org/dc/elements/1.1/"
+ xmlns:cc="http://creativecommons.org/ns#"
+ xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#"
+ xmlns:svg="http://www.w3.org/2000/svg"
+ xmlns="http://www.w3.org/2000/svg"
+ xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
+ xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
+ width="16"
+ height="16"
+ version="1.1"
+ viewBox="0 0 16 16"
+ id="svg2"
+ inkscape:version="0.92.3 (2405546, 2018-03-11)"
+ sodipodi:docname="icon_minimap_position.svg">
+ <metadata
+ id="metadata12">
+ <rdf:RDF>
+ <cc:Work
+ rdf:about="">
+ <dc:format>image/svg+xml</dc:format>
+ <dc:type
+ rdf:resource="http://purl.org/dc/dcmitype/StillImage" />
+ <dc:title></dc:title>
+ </cc:Work>
+ </rdf:RDF>
+ </metadata>
+ <defs
+ id="defs10" />
+ <sodipodi:namedview
+ pagecolor="#ffffff"
+ bordercolor="#666666"
+ borderopacity="1"
+ objecttolerance="10"
+ gridtolerance="10"
+ guidetolerance="10"
+ inkscape:pageopacity="0"
+ inkscape:pageshadow="2"
+ inkscape:window-width="1920"
+ inkscape:window-height="1017"
+ id="namedview8"
+ showgrid="true"
+ inkscape:snap-grids="true"
+ inkscape:snap-global="true"
+ inkscape:snap-bbox="false"
+ inkscape:snap-page="true"
+ inkscape:object-nodes="true"
+ inkscape:zoom="22.627417"
+ inkscape:cx="4.7772707"
+ inkscape:cy="9.4483902"
+ inkscape:window-x="-8"
+ inkscape:window-y="32"
+ inkscape:window-maximized="1"
+ inkscape:current-layer="svg2">
+ <inkscape:grid
+ type="xygrid"
+ id="grid4142" />
+ </sodipodi:namedview>
+ <path
+ style="fill:#8eb3f3;fill-opacity:1;stroke:#000000;stroke-width:1px;stroke-linecap:butt;stroke-linejoin:miter;stroke-opacity:1"
+ d="M 14,8 2,13 4,8 2,3 Z"
+ id="path816"
+ inkscape:connector-curvature="0"
+ sodipodi:nodetypes="ccccc" />
+</svg>
diff --git a/game/addons/zylann.hterrain/tools/icons/icon_minimap_position.svg.import b/game/addons/zylann.hterrain/tools/icons/icon_minimap_position.svg.import
new file mode 100644
index 0000000..5b864e3
--- /dev/null
+++ b/game/addons/zylann.hterrain/tools/icons/icon_minimap_position.svg.import
@@ -0,0 +1,37 @@
+[remap]
+
+importer="texture"
+type="CompressedTexture2D"
+uid="uid://c1el0dmyvaaij"
+path="res://.godot/imported/icon_minimap_position.svg-09c3263e8852c7010dcfa0a85245403d.ctex"
+metadata={
+"vram_texture": false
+}
+
+[deps]
+
+source_file="res://addons/zylann.hterrain/tools/icons/icon_minimap_position.svg"
+dest_files=["res://.godot/imported/icon_minimap_position.svg-09c3263e8852c7010dcfa0a85245403d.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
+svg/scale=1.0
+editor/scale_with_editor_scale=false
+editor/convert_colors_with_editor_theme=false
diff --git a/game/addons/zylann.hterrain/tools/icons/icon_small_circle.svg b/game/addons/zylann.hterrain/tools/icons/icon_small_circle.svg
new file mode 100644
index 0000000..b5a004d
--- /dev/null
+++ b/game/addons/zylann.hterrain/tools/icons/icon_small_circle.svg
@@ -0,0 +1,68 @@
+<?xml version="1.0" encoding="UTF-8" standalone="no"?>
+<svg
+ xmlns:dc="http://purl.org/dc/elements/1.1/"
+ xmlns:cc="http://creativecommons.org/ns#"
+ xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#"
+ xmlns:svg="http://www.w3.org/2000/svg"
+ xmlns="http://www.w3.org/2000/svg"
+ xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
+ xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
+ width="8"
+ height="8"
+ version="1.1"
+ viewBox="0 0 8 8"
+ id="svg2"
+ inkscape:version="0.92.3 (2405546, 2018-03-11)"
+ sodipodi:docname="icon_small_circle.svg">
+ <metadata
+ id="metadata12">
+ <rdf:RDF>
+ <cc:Work
+ rdf:about="">
+ <dc:format>image/svg+xml</dc:format>
+ <dc:type
+ rdf:resource="http://purl.org/dc/dcmitype/StillImage" />
+ <dc:title></dc:title>
+ </cc:Work>
+ </rdf:RDF>
+ </metadata>
+ <defs
+ id="defs10" />
+ <sodipodi:namedview
+ pagecolor="#ffffff"
+ bordercolor="#666666"
+ borderopacity="1"
+ objecttolerance="10"
+ gridtolerance="10"
+ guidetolerance="10"
+ inkscape:pageopacity="0"
+ inkscape:pageshadow="2"
+ inkscape:window-width="1461"
+ inkscape:window-height="826"
+ id="namedview8"
+ showgrid="true"
+ inkscape:snap-grids="true"
+ inkscape:snap-global="true"
+ inkscape:snap-bbox="false"
+ inkscape:snap-page="true"
+ inkscape:object-nodes="true"
+ inkscape:zoom="32"
+ inkscape:cx="3.3932779"
+ inkscape:cy="7.9844314"
+ inkscape:window-x="70"
+ inkscape:window-y="136"
+ inkscape:window-maximized="0"
+ inkscape:current-layer="svg2">
+ <inkscape:grid
+ type="xygrid"
+ id="grid4142" />
+ </sodipodi:namedview>
+ <rect
+ style="fill:#e0e0e0;fill-opacity:0.99607843;stroke:none;stroke-width:0.5;stroke-linecap:butt;stroke-linejoin:bevel;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:0.99607843"
+ id="rect4140"
+ width="6"
+ height="6"
+ x="1"
+ y="1"
+ ry="2.5" />
+</svg>
diff --git a/game/addons/zylann.hterrain/tools/icons/icon_small_circle.svg.import b/game/addons/zylann.hterrain/tools/icons/icon_small_circle.svg.import
new file mode 100644
index 0000000..92cf893
--- /dev/null
+++ b/game/addons/zylann.hterrain/tools/icons/icon_small_circle.svg.import
@@ -0,0 +1,37 @@
+[remap]
+
+importer="texture"
+type="CompressedTexture2D"
+uid="uid://bdkcgtv1r5j31"
+path="res://.godot/imported/icon_small_circle.svg-758362406034e77f78350899f9b2cf34.ctex"
+metadata={
+"vram_texture": false
+}
+
+[deps]
+
+source_file="res://addons/zylann.hterrain/tools/icons/icon_small_circle.svg"
+dest_files=["res://.godot/imported/icon_small_circle.svg-758362406034e77f78350899f9b2cf34.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
+svg/scale=1.0
+editor/scale_with_editor_scale=false
+editor/convert_colors_with_editor_theme=false
diff --git a/game/addons/zylann.hterrain/tools/icons/white.png b/game/addons/zylann.hterrain/tools/icons/white.png
new file mode 100644
index 0000000..dee54f4
--- /dev/null
+++ b/game/addons/zylann.hterrain/tools/icons/white.png
Binary files differ
diff --git a/game/addons/zylann.hterrain/tools/icons/white.png.import b/game/addons/zylann.hterrain/tools/icons/white.png.import
new file mode 100644
index 0000000..a0d73bf
--- /dev/null
+++ b/game/addons/zylann.hterrain/tools/icons/white.png.import
@@ -0,0 +1,35 @@
+[remap]
+
+importer="texture"
+type="CompressedTexture2D"
+uid="uid://dnee27oabcvy2"
+path.s3tc="res://.godot/imported/white.png-06b7d7f95e74cd7f8357ec25a73870fb.s3tc.ctex"
+metadata={
+"imported_formats": ["s3tc_bptc"],
+"vram_texture": true
+}
+
+[deps]
+
+source_file="res://addons/zylann.hterrain/tools/icons/white.png"
+dest_files=["res://.godot/imported/white.png-06b7d7f95e74cd7f8357ec25a73870fb.s3tc.ctex"]
+
+[params]
+
+compress/mode=2
+compress/high_quality=false
+compress/lossy_quality=0.7
+compress/hdr_compression=1
+compress/normal_map=0
+compress/channel_pack=0
+mipmaps/generate=true
+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=0
diff --git a/game/addons/zylann.hterrain/tools/importer/importer_dialog.gd b/game/addons/zylann.hterrain/tools/importer/importer_dialog.gd
new file mode 100644
index 0000000..5f57bb1
--- /dev/null
+++ b/game/addons/zylann.hterrain/tools/importer/importer_dialog.gd
@@ -0,0 +1,312 @@
+@tool
+extends AcceptDialog
+
+const HT_Util = preload("../../util/util.gd")
+const HTerrain = preload("../../hterrain.gd")
+const HTerrainData = preload("../../hterrain_data.gd")
+const HT_Errors = preload("../../util/errors.gd")
+const HT_Logger = preload("../../util/logger.gd")
+const HT_XYZFormat = preload("../../util/xyz_format.gd")
+const HT_Inspector = preload("../inspector/inspector.gd")
+
+signal permanent_change_performed(message)
+
+@onready var _inspector : HT_Inspector = $VBoxContainer/Inspector
+@onready var _errors_label : Label = $VBoxContainer/ColorRect/ScrollContainer/VBoxContainer/Errors
+@onready var _warnings_label : Label = \
+ $VBoxContainer/ColorRect/ScrollContainer/VBoxContainer/Warnings
+
+const RAW_LITTLE_ENDIAN = 0
+const RAW_BIG_ENDIAN = 1
+
+var _terrain : HTerrain = null
+var _logger = HT_Logger.get_for(self)
+
+
+func _init():
+ get_ok_button().hide()
+
+
+func _ready():
+ _inspector.set_prototype({
+ "heightmap": {
+ "type": TYPE_STRING,
+ "usage": "file",
+ "exts": ["raw", "png", "exr", "xyz"]
+ },
+ "raw_endianess": {
+ "type": TYPE_INT,
+ "usage": "enum",
+ "enum_items": ["Little Endian", "Big Endian"],
+ "enabled": false
+ },
+ "min_height": {
+ "type": TYPE_FLOAT,
+ "range": {"min": -2000.0, "max": 2000.0, "step": 0.01},
+ "default_value": 0.0
+ },
+ "max_height": {
+ "type": TYPE_FLOAT,
+ "range": {"min": -2000.0, "max": 2000.0, "step": 0.01},
+ "default_value": 400.0
+ },
+ "splatmap": {
+ "type": TYPE_STRING,
+ "usage": "file",
+ "exts": ["png"]
+ },
+ "colormap": {
+ "type": TYPE_STRING,
+ "usage": "file",
+ "exts": ["png"]
+ }
+ })
+
+ # Testing
+# _errors_label.text = "- Hello World!"
+# _warnings_label.text = "- Yolo Jesus!"
+
+
+func set_terrain(terrain: HTerrain):
+ _terrain = terrain
+
+
+func _notification(what: int):
+ if what == NOTIFICATION_VISIBILITY_CHANGED:
+ # Checking a node set in _ready,
+ # because visibility can also change between _enter_tree and _ready...
+ if visible and _inspector != null:
+ _clear_feedback()
+
+
+static func _format_feedbacks(feed):
+ var a := []
+ for s in feed:
+ a.append("- " + s)
+ return "\n".join(PackedStringArray(a))
+
+
+func _clear_feedback():
+ _errors_label.text = ""
+ _warnings_label.text = ""
+
+
+class HT_ErrorCheckReport:
+ var errors := []
+ var warnings := []
+
+
+func _show_feedback(res: HT_ErrorCheckReport):
+ for e in res.errors:
+ _logger.error(e)
+
+ for w in res.warnings:
+ _logger.warn(w)
+
+ _clear_feedback()
+
+ if len(res.errors) > 0:
+ _errors_label.text = _format_feedbacks(res.errors)
+
+ if len(res.warnings) > 0:
+ _warnings_label.text = _format_feedbacks(res.warnings)
+
+
+func _on_CheckButton_pressed():
+ var res := _validate_form()
+ _show_feedback(res)
+
+
+func _on_ImportButton_pressed():
+ assert(_terrain != null and _terrain.get_data() != null)
+
+ # Verify input to inform the user of potential issues
+ var res := _validate_form()
+ _show_feedback(res)
+
+ if len(res.errors) != 0:
+ _logger.debug("Cannot import due to errors, aborting")
+ return
+
+ var params := {}
+
+ var heightmap_path = _inspector.get_value("heightmap")
+ if heightmap_path != "":
+ var endianess = _inspector.get_value("raw_endianess")
+ params[HTerrainData.CHANNEL_HEIGHT] = {
+ "path": heightmap_path,
+ "min_height": _inspector.get_value("min_height"),
+ "max_height": _inspector.get_value("max_height"),
+ "big_endian": endianess == RAW_BIG_ENDIAN
+ }
+
+ var colormap_path = _inspector.get_value("colormap")
+ if colormap_path != "":
+ params[HTerrainData.CHANNEL_COLOR] = {
+ "path": colormap_path
+ }
+
+ var splatmap_path = _inspector.get_value("splatmap")
+ if splatmap_path != "":
+ params[HTerrainData.CHANNEL_SPLAT] = {
+ "path": splatmap_path
+ }
+
+ var data = _terrain.get_data()
+ data._edit_import_maps(params)
+ emit_signal("permanent_change_performed", "Import maps")
+
+ _logger.debug("Terrain import finished")
+ hide()
+
+
+func _on_CancelButton_pressed():
+ hide()
+
+
+func _on_Inspector_property_changed(key: String, value):
+ if key == "heightmap":
+ var is_raw = value.get_extension().to_lower() == "raw"
+ _inspector.set_property_enabled("raw_endianess", is_raw)
+
+
+func _validate_form() -> HT_ErrorCheckReport:
+ var res := HT_ErrorCheckReport.new()
+
+ var heightmap_path : String = _inspector.get_value("heightmap")
+ var splatmap_path : String = _inspector.get_value("splatmap")
+ var colormap_path : String = _inspector.get_value("colormap")
+
+ if colormap_path == "" and heightmap_path == "" and splatmap_path == "":
+ res.errors.append("No maps specified.")
+ return res
+
+ # If a heightmap is specified, it will override the size of the existing terrain.
+ # If not specified, maps will have to match the resolution of the existing terrain.
+ var heightmap_size := _terrain.get_data().get_resolution()
+
+ if heightmap_path != "":
+ var min_height = _inspector.get_value("min_height")
+ var max_height = _inspector.get_value("max_height")
+
+ if min_height >= max_height:
+ res.errors.append("Minimum height must be lower than maximum height")
+ # Returning early because min and max can be slided,
+ # so we avoid loading other maps every time to do further checks.
+ return res
+
+ var image_size_result = _load_image_size(heightmap_path, _logger)
+ if image_size_result.error_code != OK:
+ res.errors.append(str("Cannot open heightmap file: ", image_size_result.to_string()))
+ return res
+
+ var adjusted_size = HTerrainData.get_adjusted_map_size(
+ image_size_result.width, image_size_result.height)
+
+ if adjusted_size != image_size_result.width:
+ res.warnings.append(
+ "The square resolution deduced from heightmap file size is not power of two + 1.\n" + \
+ "The heightmap will be cropped.")
+
+ heightmap_size = adjusted_size
+
+ if splatmap_path != "":
+ _check_map_size(splatmap_path, "splatmap", heightmap_size, res, _logger)
+
+ if colormap_path != "":
+ _check_map_size(colormap_path, "colormap", heightmap_size, res, _logger)
+
+ return res
+
+
+static func _check_map_size(path: String, map_name: String, heightmap_size: int,
+ res: HT_ErrorCheckReport, logger):
+
+ var size_result := _load_image_size(path, logger)
+ if size_result.error_code != OK:
+ res.errors.append(str("Cannot open splatmap file: ", size_result.to_string()))
+ return
+ var adjusted_size := HTerrainData.get_adjusted_map_size(size_result.width, size_result.height)
+ if adjusted_size != heightmap_size:
+ res.errors.append(str(
+ "The ", map_name,
+ " must have the same resolution as the heightmap (", heightmap_size, ")"))
+ else:
+ if adjusted_size != size_result.width:
+ res.warnings.append(str(
+ "The square resolution deduced from ", map_name,
+ " file size is not power of two + 1.\nThe ",
+ map_name, " will be cropped."))
+
+
+class HT_ImageSizeResult:
+ var width := 0
+ var height := 0
+ var error_code := OK
+ var error_message := ""
+
+ func to_string() -> String:
+ if error_message != "":
+ return error_message
+ return HT_Errors.get_message(error_code)
+
+
+static func _load_image_size(path: String, logger) -> HT_ImageSizeResult:
+ var ext := path.get_extension().to_lower()
+ var result := HT_ImageSizeResult.new()
+
+ if ext == "png" or ext == "exr":
+ # Godot can load these formats natively
+ var im := Image.new()
+ var err := im.load(path)
+ if err != OK:
+ logger.error("An error occurred loading image '{0}', code {1}".format([path, err]))
+ result.error_code = err
+ return result
+
+ result.width = im.get_width()
+ result.height = im.get_height()
+ return result
+
+ elif ext == "raw":
+ var f := FileAccess.open(path, FileAccess.READ)
+ if f == null:
+ var err := FileAccess.get_open_error()
+ logger.error("Error opening file {0}".format([path]))
+ result.error_code = err
+ return result
+
+ # Assume the raw data is square in 16-bit format,
+ # so its size is function of file length
+ var flen := f.get_length()
+ f = null
+ var size_px = HT_Util.integer_square_root(flen / 2)
+ if size_px == -1:
+ result.error_code = ERR_INVALID_DATA
+ result.error_message = "RAW image is not square"
+ return result
+
+ logger.debug("Deduced RAW heightmap resolution: {0}*{1}, for a length of {2}" \
+ .format([size_px, size_px, flen]))
+
+ result.width = size_px
+ result.height = size_px
+ return result
+
+ elif ext == "xyz":
+ var f := FileAccess.open(path, FileAccess.READ)
+ if f == null:
+ var err := FileAccess.get_open_error()
+ logger.error("Error opening file {0}".format([path]))
+ result.error_code = err
+ return result
+
+ var bounds := HT_XYZFormat.load_bounds(f)
+
+ result.width = bounds.image_width
+ result.height = bounds.image_height
+ return result
+
+ else:
+ result.error_code = ERR_FILE_UNRECOGNIZED
+ return result
diff --git a/game/addons/zylann.hterrain/tools/importer/importer_dialog.tscn b/game/addons/zylann.hterrain/tools/importer/importer_dialog.tscn
new file mode 100644
index 0000000..0c2d267
--- /dev/null
+++ b/game/addons/zylann.hterrain/tools/importer/importer_dialog.tscn
@@ -0,0 +1,87 @@
+[gd_scene load_steps=4 format=3 uid="uid://on7x7xkovsc8"]
+
+[ext_resource type="Script" path="res://addons/zylann.hterrain/tools/importer/importer_dialog.gd" id="1"]
+[ext_resource type="PackedScene" path="res://addons/zylann.hterrain/tools/inspector/inspector.tscn" id="2"]
+[ext_resource type="PackedScene" path="res://addons/zylann.hterrain/tools/util/dialog_fitter.tscn" id="3"]
+
+[node name="WindowDialog" type="AcceptDialog"]
+title = "Import maps"
+size = Vector2i(500, 433)
+min_size = Vector2i(500, 380)
+script = ExtResource("1")
+
+[node name="VBoxContainer" type="VBoxContainer" parent="."]
+anchors_preset = 15
+anchor_right = 1.0
+anchor_bottom = 1.0
+offset_left = 8.0
+offset_top = 8.0
+offset_right = -8.0
+offset_bottom = -18.0
+
+[node name="Label" type="Label" parent="VBoxContainer"]
+layout_mode = 2
+text = "Select maps to import. Leave empty if you don't need some."
+
+[node name="Spacer" type="Control" parent="VBoxContainer"]
+custom_minimum_size = Vector2(0, 16)
+layout_mode = 2
+
+[node name="Inspector" parent="VBoxContainer" instance=ExtResource("2")]
+layout_mode = 2
+size_flags_vertical = 3
+
+[node name="ColorRect" type="ColorRect" parent="VBoxContainer"]
+custom_minimum_size = Vector2(0, 100)
+layout_mode = 2
+color = Color(0, 0, 0, 0.417529)
+
+[node name="ScrollContainer" type="ScrollContainer" parent="VBoxContainer/ColorRect"]
+layout_mode = 0
+anchor_right = 1.0
+anchor_bottom = 1.0
+
+[node name="VBoxContainer" type="VBoxContainer" parent="VBoxContainer/ColorRect/ScrollContainer"]
+layout_mode = 2
+
+[node name="Errors" type="Label" parent="VBoxContainer/ColorRect/ScrollContainer/VBoxContainer"]
+self_modulate = Color(1, 0.203125, 0.203125, 1)
+layout_mode = 2
+
+[node name="Warnings" type="Label" parent="VBoxContainer/ColorRect/ScrollContainer/VBoxContainer"]
+self_modulate = Color(1, 0.901428, 0.257813, 1)
+layout_mode = 2
+
+[node name="Spacer2" type="Control" parent="VBoxContainer"]
+custom_minimum_size = Vector2(0, 8)
+layout_mode = 2
+
+[node name="ButtonsArea" type="HBoxContainer" parent="VBoxContainer"]
+layout_mode = 2
+mouse_filter = 0
+alignment = 1
+
+[node name="CheckButton" type="Button" parent="VBoxContainer/ButtonsArea"]
+layout_mode = 2
+text = "Check"
+
+[node name="ImportButton" type="Button" parent="VBoxContainer/ButtonsArea"]
+layout_mode = 2
+text = "Import"
+
+[node name="CancelButton" type="Button" parent="VBoxContainer/ButtonsArea"]
+layout_mode = 2
+text = "Cancel"
+
+[node name="DialogFitter" parent="." instance=ExtResource("3")]
+layout_mode = 3
+anchors_preset = 0
+offset_left = 8.0
+offset_top = 8.0
+offset_right = 492.0
+offset_bottom = 415.0
+
+[connection signal="property_changed" from="VBoxContainer/Inspector" to="." method="_on_Inspector_property_changed"]
+[connection signal="pressed" from="VBoxContainer/ButtonsArea/CheckButton" to="." method="_on_CheckButton_pressed"]
+[connection signal="pressed" from="VBoxContainer/ButtonsArea/ImportButton" to="." method="_on_ImportButton_pressed"]
+[connection signal="pressed" from="VBoxContainer/ButtonsArea/CancelButton" to="." method="_on_CancelButton_pressed"]
diff --git a/game/addons/zylann.hterrain/tools/inspector/inspector.gd b/game/addons/zylann.hterrain/tools/inspector/inspector.gd
new file mode 100644
index 0000000..6039cee
--- /dev/null
+++ b/game/addons/zylann.hterrain/tools/inspector/inspector.gd
@@ -0,0 +1,479 @@
+
+# GDScript implementation of an inspector.
+# It generates controls for a provided list of properties,
+# which is easier to maintain than placing them by hand and connecting things in the editor.
+
+@tool
+extends Control
+
+const USAGE_FILE = "file"
+const USAGE_ENUM = "enum"
+
+signal property_changed(key, value)
+
+# Used for most simple types
+class HT_InspectorEditor:
+ var control = null
+ var getter := Callable()
+ var setter := Callable()
+ var key_label : Label
+
+
+# Used when the control cannot hold the actual value
+class HT_InspectorResourceEditor extends HT_InspectorEditor:
+ var value = null
+ var label = null
+
+ func get_value():
+ return value
+
+ func set_value(v):
+ value = v
+ label.text = "null" if v == null else v.resource_path
+
+
+class HT_InspectorVectorEditor extends HT_InspectorEditor:
+ signal value_changed(v)
+
+ var value := Vector2()
+ var xed = null
+ var yed = null
+
+ func get_value():
+ return value
+
+ func set_value(v):
+ xed.value = v.x
+ yed.value = v.y
+ value = v
+
+ func _component_changed(v, i):
+ value[i] = v
+ value_changed.emit(value)
+
+
+# TODO Rename _schema
+var _prototype = null
+var _edit_signal := true
+# name => editor
+var _editors := {}
+
+# Had to separate the container because otherwise I can't open dialogs properly...
+@onready var _grid_container = get_node("GridContainer")
+@onready var _file_dialog = get_node("OpenFileDialog")
+
+
+func _ready():
+ _file_dialog.visibility_changed.connect(
+ call_deferred.bind("_on_file_dialog_visibility_changed"))
+# Test
+# set_prototype({
+# "seed": {
+# "type": TYPE_INT,
+# "randomizable": true
+# },
+# "base_height": {
+# "type": TYPE_REAL,
+# "range": {"min": -1000.0, "max": 1000.0, "step": 0.1}
+# },
+# "height_range": {
+# "type": TYPE_REAL,
+# "range": {"min": -1000.0, "max": 1000.0, "step": 0.1 },
+# "default_value": 500.0
+# },
+# "streamed": {
+# "type": TYPE_BOOL
+# },
+# "texture": {
+# "type": TYPE_OBJECT,
+# "object_type": Resource
+# }
+# })
+
+
+# TODO Rename clear_schema
+func clear_prototype():
+ _editors.clear()
+ var i = _grid_container.get_child_count() - 1
+ while i >= 0:
+ var child = _grid_container.get_child(i)
+ _grid_container.remove_child(child)
+ child.call_deferred("free")
+ i -= 1
+ _prototype = null
+
+
+func get_value(key: String):
+ var editor = _editors[key]
+ return editor.getter.call()
+
+
+func get_values():
+ var values = {}
+ for key in _editors:
+ var editor = _editors[key]
+ values[key] = editor.getter.call()
+ return values
+
+
+func set_value(key: String, value):
+ var editor = _editors[key]
+ editor.setter.call(value)
+
+
+func set_values(values: Dictionary):
+ for key in values:
+ if _editors.has(key):
+ var editor = _editors[key]
+ var v = values[key]
+ editor.setter.call(v)
+
+
+# TODO Rename set_schema
+func set_prototype(proto: Dictionary):
+ clear_prototype()
+
+ for key in proto:
+ var prop = proto[key]
+
+ var label := Label.new()
+ label.text = str(key).capitalize()
+ _grid_container.add_child(label)
+
+ var editor := _make_editor(key, prop)
+ editor.key_label = label
+
+ if prop.has("default_value"):
+ editor.setter.call(prop.default_value)
+
+ _editors[key] = editor
+
+ if prop.has("enabled"):
+ set_property_enabled(key, prop.enabled)
+
+ _grid_container.add_child(editor.control)
+
+ _prototype = proto
+
+
+func trigger_all_modified():
+ for key in _prototype:
+ var value = _editors[key].getter.call_func()
+ property_changed.emit(key, value)
+
+
+func set_property_enabled(prop_name: String, enabled: bool):
+ var ed = _editors[prop_name]
+
+ if ed.control is BaseButton:
+ ed.control.disabled = not enabled
+
+ elif ed.control is SpinBox:
+ ed.control.editable = enabled
+
+ elif ed.control is LineEdit:
+ ed.control.editable = enabled
+
+ # TODO Support more editors
+
+ var col = ed.key_label.modulate
+ if enabled:
+ col.a = 1.0
+ else:
+ col.a = 0.5
+ ed.key_label.modulate = col
+
+
+func _make_editor(key: String, prop: Dictionary) -> HT_InspectorEditor:
+ var ed : HT_InspectorEditor = null
+
+ var editor : Control = null
+ var getter : Callable
+ var setter : Callable
+ var extra = null
+
+ match prop.type:
+ TYPE_INT, \
+ TYPE_FLOAT:
+ var pre = null
+ if prop.has("randomizable") and prop.randomizable:
+ editor = HBoxContainer.new()
+ pre = Button.new()
+ pre.pressed.connect(_randomize_property_pressed.bind(key))
+ pre.text = "Randomize"
+ editor.add_child(pre)
+
+ if prop.type == TYPE_INT and prop.has("usage") and prop.usage == USAGE_ENUM:
+ # Enumerated value
+ assert(prop.has("enum_items"))
+ var option_button := OptionButton.new()
+
+ for i in len(prop.enum_items):
+ var item = prop.enum_items[i]
+ option_button.add_item(item)
+
+ # TODO We assume index, actually
+ getter = option_button.get_selected_id
+ setter = option_button.select
+ option_button.item_selected.connect(_property_edited.bind(key))
+
+ editor = option_button
+
+ else:
+ # Numeric value
+ var spinbox := SpinBox.new()
+ # Spinboxes have shit UX when not expanded...
+ spinbox.custom_minimum_size = Vector2(120, 16)
+ _setup_range_control(spinbox, prop)
+ spinbox.value_changed.connect(_property_edited.bind(key))
+
+ # TODO In case the type is INT, the getter should return an integer!
+ getter = spinbox.get_value
+ setter = spinbox.set_value
+
+ var show_slider = prop.has("range") \
+ and not (prop.has("slidable") \
+ and prop.slidable == false)
+
+ if show_slider:
+ if editor == null:
+ editor = HBoxContainer.new()
+ var slider := HSlider.new()
+ # Need to give some size because otherwise the slider is hard to click...
+ slider.custom_minimum_size = Vector2(32, 16)
+ _setup_range_control(slider, prop)
+ slider.size_flags_horizontal = Control.SIZE_EXPAND_FILL
+ spinbox.share(slider)
+ editor.add_child(slider)
+ editor.add_child(spinbox)
+ else:
+ spinbox.size_flags_horizontal = Control.SIZE_EXPAND_FILL
+ if editor == null:
+ editor = spinbox
+ else:
+ editor.add_child(spinbox)
+
+ TYPE_STRING:
+ if prop.has("usage") and prop.usage == USAGE_FILE:
+ editor = HBoxContainer.new()
+
+ var line_edit := LineEdit.new()
+ line_edit.size_flags_horizontal = Control.SIZE_EXPAND_FILL
+ editor.add_child(line_edit)
+
+ var exts = []
+ if prop.has("exts"):
+ exts = prop.exts
+
+ var load_button := Button.new()
+ load_button.text = "..."
+ load_button.pressed.connect(_on_ask_load_file.bind(key, exts))
+ editor.add_child(load_button)
+
+ line_edit.text_submitted.connect(_property_edited.bind(key))
+ getter = line_edit.get_text
+ setter = line_edit.set_text
+
+ else:
+ editor = LineEdit.new()
+ editor.text_submitted.connect(_property_edited.bind(key))
+ getter = editor.get_text
+ setter = editor.set_text
+
+ TYPE_COLOR:
+ editor = ColorPickerButton.new()
+ editor.color_changed.connect(_property_edited.bind(key))
+ getter = editor.get_pick_color
+ setter = editor.set_pick_color
+
+ TYPE_BOOL:
+ editor = CheckBox.new()
+ editor.toggled.connect(_property_edited.bind(key))
+ getter = editor.is_pressed
+ setter = editor.set_pressed
+
+ TYPE_OBJECT:
+ # TODO How do I even check inheritance if I work on the class themselves, not instances?
+ if prop.object_type == Resource:
+ editor = HBoxContainer.new()
+
+ var label := Label.new()
+ label.text = "null"
+ label.size_flags_horizontal = Control.SIZE_EXPAND_FILL
+ label.clip_text = true
+ label.horizontal_alignment = HORIZONTAL_ALIGNMENT_RIGHT
+ editor.add_child(label)
+
+ var load_button := Button.new()
+ load_button.text = "Load..."
+ load_button.pressed.connect(_on_ask_load_texture.bind(key))
+ editor.add_child(load_button)
+
+ var clear_button := Button.new()
+ clear_button.text = "Clear"
+ clear_button.pressed.connect(_on_ask_clear_texture.bind(key))
+ editor.add_child(clear_button)
+
+ ed = HT_InspectorResourceEditor.new()
+ ed.label = label
+ getter = ed.get_value
+ setter = ed.set_value
+
+ TYPE_VECTOR2:
+ editor = HBoxContainer.new()
+
+ ed = HT_InspectorVectorEditor.new()
+
+ var xlabel := Label.new()
+ xlabel.text = "x"
+ editor.add_child(xlabel)
+ var xed := SpinBox.new()
+ xed.size_flags_horizontal = Control.SIZE_EXPAND_FILL
+ xed.step = 0.01
+ xed.min_value = -10000
+ xed.max_value = 10000
+ # TODO This will fire twice (for each coordinate), hmmm...
+ xed.value_changed.connect(ed._component_changed.bind(0))
+ editor.add_child(xed)
+
+ var ylabel := Label.new()
+ ylabel.text = "y"
+ editor.add_child(ylabel)
+ var yed = SpinBox.new()
+ yed.size_flags_horizontal = Control.SIZE_EXPAND_FILL
+ yed.step = 0.01
+ yed.min_value = -10000
+ yed.max_value = 10000
+ yed.value_changed.connect(ed._component_changed.bind(1))
+ editor.add_child(yed)
+
+ ed.xed = xed
+ ed.yed = yed
+ ed.value_changed.connect(_property_edited.bind(key))
+ getter = ed.get_value
+ setter = ed.set_value
+
+ _:
+ editor = Label.new()
+ editor.text = "<not editable>"
+ getter = _dummy_getter
+ setter = _dummy_setter
+
+ if not(editor is CheckButton):
+ editor.size_flags_horizontal = Control.SIZE_EXPAND_FILL
+
+ if ed == null:
+ # Default
+ ed = HT_InspectorEditor.new()
+ ed.control = editor
+ ed.getter = getter
+ ed.setter = setter
+
+ return ed
+
+
+static func _setup_range_control(range_control: Range, prop):
+ if prop.type == TYPE_INT:
+ range_control.step = 1
+ range_control.rounded = true
+ else:
+ range_control.step = 0.1
+ if prop.has("range"):
+ range_control.min_value = prop.range.min
+ range_control.max_value = prop.range.max
+ if prop.range.has("step"):
+ range_control.step = prop.range.step
+ else:
+ # Where is INT_MAX??
+ range_control.min_value = -0x7fffffff
+ range_control.max_value = 0x7fffffff
+
+
+func _property_edited(value, key):
+ if _edit_signal:
+ property_changed.emit(key, value)
+
+
+func _randomize_property_pressed(key):
+ var prop = _prototype[key]
+ var v = 0
+
+ # TODO Support range step
+ match prop.type:
+ TYPE_INT:
+ if prop.has("range"):
+ v = randi() % (prop.range.max - prop.range.min) + prop.range.min
+ else:
+ v = randi() - 0x7fffffff
+ TYPE_FLOAT:
+ if prop.has("range"):
+ v = randf_range(prop.range.min, prop.range.max)
+ else:
+ v = randf()
+
+ _editors[key].setter.call(v)
+
+
+func _dummy_getter():
+ pass
+
+
+func _dummy_setter(v):
+ # TODO Could use extra data to store the value anyways?
+ pass
+
+
+func _on_ask_load_texture(key):
+ _open_file_dialog(["*.png ; PNG files"], _on_texture_selected.bind(key),
+ FileDialog.ACCESS_RESOURCES)
+
+
+func _open_file_dialog(filters: Array, callback: Callable, access: int):
+ _file_dialog.access = access
+ _file_dialog.clear_filters()
+ for filter in filters:
+ _file_dialog.add_filter(filter)
+
+ # Can't just use one-shot signals because the dialog could be closed without choosing a file...
+# if not _file_dialog.file_selected.is_connected(callback):
+# _file_dialog.file_selected.connect(callback, Object.CONNECT_ONE_SHOT)
+ _file_dialog.file_selected.connect(callback)
+
+ _file_dialog.popup_centered_ratio(0.7)
+
+
+func _on_file_dialog_visibility_changed():
+ if _file_dialog.visible == false:
+ # Disconnect listeners automatically,
+ # so we can re-use the same dialog with different listeners
+ var cons = _file_dialog.get_signal_connection_list("file_selected")
+ for con in cons:
+ _file_dialog.file_selected.disconnect(con.callable)
+
+
+func _on_texture_selected(path: String, key):
+ var tex = load(path)
+ if tex == null:
+ return
+ var ed = _editors[key]
+ ed.setter.call(tex)
+ _property_edited(tex, key)
+
+
+func _on_ask_clear_texture(key):
+ var ed = _editors[key]
+ ed.setter.call(null)
+ _property_edited(null, key)
+
+
+func _on_ask_load_file(key, exts):
+ var filters := []
+ for ext in exts:
+ filters.append(str("*.", ext, " ; ", ext.to_upper(), " files"))
+ _open_file_dialog(filters, _on_file_selected.bind(key), FileDialog.ACCESS_FILESYSTEM)
+
+
+func _on_file_selected(path, key):
+ var ed = _editors[key]
+ ed.setter.call(path)
+ _property_edited(path, key)
diff --git a/game/addons/zylann.hterrain/tools/inspector/inspector.tscn b/game/addons/zylann.hterrain/tools/inspector/inspector.tscn
new file mode 100644
index 0000000..4bd1395
--- /dev/null
+++ b/game/addons/zylann.hterrain/tools/inspector/inspector.tscn
@@ -0,0 +1,15 @@
+[gd_scene load_steps=2 format=3 uid="uid://dfjip6c4olemn"]
+
+[ext_resource type="Script" path="res://addons/zylann.hterrain/tools/inspector/inspector.gd" id="1"]
+
+[node name="Inspector" type="VBoxContainer"]
+script = ExtResource("1")
+
+[node name="GridContainer" type="GridContainer" parent="."]
+layout_mode = 2
+columns = 2
+
+[node name="OpenFileDialog" type="FileDialog" parent="."]
+title = "Open a File"
+ok_button_text = "Open"
+file_mode = 0
diff --git a/game/addons/zylann.hterrain/tools/load_texture_dialog.gd b/game/addons/zylann.hterrain/tools/load_texture_dialog.gd
new file mode 100644
index 0000000..6d1e874
--- /dev/null
+++ b/game/addons/zylann.hterrain/tools/load_texture_dialog.gd
@@ -0,0 +1,22 @@
+@tool
+extends EditorFileDialog
+
+
+func _init():
+ #access = EditorFileDialog.ACCESS_RESOURCES
+ file_mode = EditorFileDialog.FILE_MODE_OPEN_FILE
+ # TODO I actually want a dialog to load a texture, not specifically a PNG...
+ add_filter("*.png ; PNG files")
+ add_filter("*.jpg ; JPG files")
+ unresizable = false
+ access = EditorFileDialog.ACCESS_RESOURCES
+ close_requested.connect(call_deferred.bind("_on_close"))
+
+
+func _on_close():
+ # Disconnect listeners automatically,
+ # so we can re-use the same dialog with different listeners
+ var cons = get_signal_connection_list("file_selected")
+ for con in cons:
+ file_selected.disconnect(con.callable)
+
diff --git a/game/addons/zylann.hterrain/tools/minimap/minimap.gd b/game/addons/zylann.hterrain/tools/minimap/minimap.gd
new file mode 100644
index 0000000..f71295a
--- /dev/null
+++ b/game/addons/zylann.hterrain/tools/minimap/minimap.gd
@@ -0,0 +1,140 @@
+@tool
+extends Control
+
+const HT_Util = preload("../../util/util.gd")
+const HTerrain = preload("../../hterrain.gd")
+const HTerrainData = preload("../../hterrain_data.gd")
+const HT_MinimapOverlay = preload("./minimap_overlay.gd")
+
+const HT_MinimapShader = preload("./minimap_normal.gdshader")
+# TODO Can't preload because it causes the plugin to fail loading if assets aren't imported
+#const HT_WhiteTexture = preload("../icons/white.png")
+const WHITE_TEXTURE_PATH = "res://addons/zylann.hterrain/tools/icons/white.png"
+
+const MODE_QUADTREE = 0
+const MODE_NORMAL = 1
+
+@onready var _popup_menu : PopupMenu = $PopupMenu
+@onready var _color_rect : ColorRect = $ColorRect
+@onready var _overlay : HT_MinimapOverlay = $Overlay
+
+var _terrain : HTerrain = null
+var _mode := MODE_NORMAL
+var _camera_transform := Transform3D()
+
+
+func _ready():
+ if HT_Util.is_in_edited_scene(self):
+ return
+
+ _set_mode(_mode)
+
+ _popup_menu.add_item("Quadtree mode", MODE_QUADTREE)
+ _popup_menu.add_item("Normal mode", MODE_NORMAL)
+
+
+func set_terrain(node: HTerrain):
+ if _terrain != node:
+ _terrain = node
+ set_process(_terrain != null)
+
+
+func set_camera_transform(ct: Transform3D):
+ if _camera_transform == ct:
+ return
+ if _terrain == null:
+ return
+ var data = _terrain.get_data()
+ if data == null:
+ return
+ var to_local := _terrain.get_internal_transform().affine_inverse()
+ var pos := _get_xz(to_local * _camera_transform.origin)
+ var size := Vector2(data.get_resolution(), data.get_resolution())
+ pos /= size
+ var dir := _get_xz(to_local.basis * (-_camera_transform.basis.z)).normalized()
+ _overlay.set_cursor_position_normalized(pos, dir)
+ _camera_transform = ct
+
+
+static func _get_xz(v: Vector3) -> Vector2:
+ return Vector2(v.x, v.z)
+
+
+func _gui_input(event: InputEvent):
+ if event is InputEventMouseButton:
+ if event.pressed:
+ match event.button_index:
+ MOUSE_BUTTON_RIGHT:
+ _popup_menu.position = get_screen_position() + event.position
+ _popup_menu.popup()
+ MOUSE_BUTTON_LEFT:
+ # Teleport there?
+ pass
+
+
+func _process(delta):
+ if _terrain != null:
+ if _mode == MODE_QUADTREE:
+ queue_redraw()
+ else:
+ _update_normal_material()
+
+
+func _set_mode(mode: int):
+ if mode == MODE_QUADTREE:
+ _color_rect.hide()
+ else:
+ var mat := ShaderMaterial.new()
+ mat.shader = HT_MinimapShader
+ _color_rect.material = mat
+ _color_rect.show()
+ _update_normal_material()
+ _mode = mode
+ queue_redraw()
+
+
+func _update_normal_material():
+ if _terrain == null:
+ return
+ var data : HTerrainData = _terrain.get_data()
+ if data == null:
+ return
+
+ var normalmap = data.get_texture(HTerrainData.CHANNEL_NORMAL)
+ _set_if_changed(_color_rect.material, "u_normalmap", normalmap)
+
+ var globalmap : Texture
+ if data.has_texture(HTerrainData.CHANNEL_GLOBAL_ALBEDO, 0):
+ globalmap = data.get_texture(HTerrainData.CHANNEL_GLOBAL_ALBEDO)
+ if globalmap == null:
+ globalmap = load(WHITE_TEXTURE_PATH)
+ _set_if_changed(_color_rect.material, "u_globalmap", globalmap)
+
+
+# Need to check if it has changed, otherwise Godot's update spinner
+# indicates that the editor keeps redrawing every frame,
+# which is not intended and consumes more power.
+static func _set_if_changed(sm: ShaderMaterial, param: String, v):
+ if sm.get_shader_parameter(param) != v:
+ sm.set_shader_parameter(param, v)
+
+
+func _draw():
+ if _terrain == null:
+ return
+
+ if _mode == MODE_QUADTREE:
+ var lod_count := _terrain.get_lod_count()
+
+ if lod_count > 0:
+ # Fit drawing to rect
+
+ var qsize = 1 << (lod_count - 1)
+ var vsize := size
+ draw_set_transform(Vector2(0, 0), 0, Vector2(vsize.x / qsize, vsize.y / qsize))
+
+ _terrain._edit_debug_draw(self)
+
+
+func _on_PopupMenu_id_pressed(id: int):
+ _set_mode(id)
diff --git a/game/addons/zylann.hterrain/tools/minimap/minimap.tscn b/game/addons/zylann.hterrain/tools/minimap/minimap.tscn
new file mode 100644
index 0000000..b2a7ef5
--- /dev/null
+++ b/game/addons/zylann.hterrain/tools/minimap/minimap.tscn
@@ -0,0 +1,56 @@
+[gd_scene load_steps=7 format=3 uid="uid://cba6k3hrwhrke"]
+
+[ext_resource type="Script" path="res://addons/zylann.hterrain/tools/minimap/minimap.gd" id="1"]
+[ext_resource type="Shader" path="res://addons/zylann.hterrain/tools/minimap/minimap_normal.gdshader" id="2"]
+[ext_resource type="Script" path="res://addons/zylann.hterrain/tools/minimap/minimap_overlay.gd" id="3"]
+[ext_resource type="Texture2D" uid="uid://c1el0dmyvaaij" path="res://addons/zylann.hterrain/tools/icons/icon_minimap_position.svg" id="4"]
+[ext_resource type="Texture2D" uid="uid://cc47smy24m368" path="res://addons/zylann.hterrain/tools/icons/icon_minimap_out_of_range_position.svg" id="5"]
+
+[sub_resource type="ShaderMaterial" id="1"]
+shader = ExtResource("2")
+shader_parameter/u_light_direction = Vector3(0.5, -0.7, 0.2)
+
+[node name="Minimap" type="Control"]
+custom_minimum_size = Vector2(100, 0)
+layout_mode = 3
+anchors_preset = 0
+offset_right = 100.0
+offset_bottom = 100.0
+script = ExtResource("1")
+
+[node name="PopupMenu" type="PopupMenu" parent="."]
+
+[node name="ColorRect" type="ColorRect" parent="."]
+material = SubResource("1")
+layout_mode = 0
+anchor_right = 1.0
+anchor_bottom = 1.0
+mouse_filter = 2
+
+[node name="X" type="ColorRect" parent="."]
+layout_mode = 0
+mouse_filter = 2
+color = Color(0.929412, 0.290196, 0.290196, 0.627451)
+
+[node name="Z" type="ColorRect" parent="."]
+layout_mode = 0
+mouse_filter = 2
+color = Color(0.0784314, 0.501961, 1, 0.627451)
+
+[node name="Y" type="ColorRect" parent="."]
+layout_mode = 0
+mouse_filter = 2
+color = Color(0.207843, 0.835294, 0.152941, 0.627451)
+
+[node name="Overlay" type="Control" parent="."]
+anchors_preset = 0
+anchor_right = 1.0
+anchor_bottom = 1.0
+mouse_filter = 2
+script = ExtResource("3")
+cursor_texture = ExtResource("4")
+out_of_range_texture = ExtResource("5")
+
+[node name="Cursor" type="Sprite2D" parent="Overlay"]
+
+[connection signal="id_pressed" from="PopupMenu" to="." method="_on_PopupMenu_id_pressed"]
diff --git a/game/addons/zylann.hterrain/tools/minimap/minimap_normal.gdshader b/game/addons/zylann.hterrain/tools/minimap/minimap_normal.gdshader
new file mode 100644
index 0000000..0ea3120
--- /dev/null
+++ b/game/addons/zylann.hterrain/tools/minimap/minimap_normal.gdshader
@@ -0,0 +1,24 @@
+shader_type canvas_item;
+
+uniform sampler2D u_normalmap;
+uniform sampler2D u_globalmap;
+uniform vec3 u_light_direction = vec3(0.5, -0.7, 0.2);
+
+vec3 unpack_normal(vec4 rgba) {
+ return rgba.xzy * 2.0 - vec3(1.0);
+}
+
+void fragment() {
+ vec3 albedo = texture(u_globalmap, UV).rgb;
+ // Undo sRGB
+ // TODO I don't know what is correct tbh, this didn't work well
+ //albedo *= pow(albedo, vec3(0.4545));
+ //albedo *= pow(albedo, vec3(1.0 / 0.4545));
+ albedo = sqrt(albedo);
+
+ vec3 normal = unpack_normal(texture(u_normalmap, UV));
+ float g = max(-dot(u_light_direction, normal), 0.0);
+
+ COLOR = vec4(albedo * g, 1.0);
+}
+
diff --git a/game/addons/zylann.hterrain/tools/minimap/minimap_overlay.gd b/game/addons/zylann.hterrain/tools/minimap/minimap_overlay.gd
new file mode 100644
index 0000000..93c6695
--- /dev/null
+++ b/game/addons/zylann.hterrain/tools/minimap/minimap_overlay.gd
@@ -0,0 +1,24 @@
+@tool
+extends Control
+
+
+@export var cursor_texture : Texture
+@export var out_of_range_texture : Texture
+
+@onready var _sprite : Sprite2D = $Cursor
+
+var _pos := Vector2()
+var _rot := 0.0
+
+
+func set_cursor_position_normalized(pos_norm: Vector2, dir: Vector2):
+ if Rect2(0, 0, 1, 1).has_point(pos_norm):
+ _sprite.texture = cursor_texture
+ else:
+ pos_norm.x = clampf(pos_norm.x, 0.0, 1.0)
+ pos_norm.y = clampf(pos_norm.y, 0.0, 1.0)
+ _sprite.texture = out_of_range_texture
+
+ _sprite.position = pos_norm * size
+ _sprite.rotation = dir.angle()
+
diff --git a/game/addons/zylann.hterrain/tools/minimap/ratio_container.gd b/game/addons/zylann.hterrain/tools/minimap/ratio_container.gd
new file mode 100644
index 0000000..1bd6aca
--- /dev/null
+++ b/game/addons/zylann.hterrain/tools/minimap/ratio_container.gd
@@ -0,0 +1,32 @@
+# Simple container keeping its children under the same aspect ratio
+
+@tool
+extends Container
+
+
+@export var ratio := 1.0
+
+
+func _notification(what: int):
+ if what == NOTIFICATION_SORT_CHILDREN:
+ _sort_children2()
+
+
+# TODO Function with ugly name to workaround a Godot 3.1 issue
+# See https://github.com/godotengine/godot/pull/38396
+func _sort_children2():
+ for i in get_child_count():
+ var child = get_child(i)
+ if not (child is Control):
+ continue
+ var w := size.x
+ var h := size.x / ratio
+
+ if h > size.y:
+ h = size.y
+ w = h * ratio
+
+ var rect := Rect2(0, 0, w, h)
+
+ fit_child_in_rect(child, rect)
+
diff --git a/game/addons/zylann.hterrain/tools/normalmap_baker.gd b/game/addons/zylann.hterrain/tools/normalmap_baker.gd
new file mode 100644
index 0000000..e5af37d
--- /dev/null
+++ b/game/addons/zylann.hterrain/tools/normalmap_baker.gd
@@ -0,0 +1,148 @@
+
+# Bakes normals asynchronously in the editor as the heightmap gets modified.
+# It uses the heightmap texture to change the normalmap image, which is then uploaded like an edit.
+# This is probably not a nice method GPU-wise, but it's way faster than GDScript.
+
+@tool
+extends Node
+
+const HTerrainData = preload("../hterrain_data.gd")
+
+const VIEWPORT_SIZE = 64
+
+const STATE_PENDING = 0
+const STATE_PROCESSING = 1
+
+var _viewport : SubViewport = null
+var _ci : Sprite2D = null
+var _pending_tiles_grid := {}
+var _pending_tiles_queue := []
+var _processing_tile = null
+var _terrain_data : HTerrainData = null
+
+
+func _init():
+ assert(VIEWPORT_SIZE <= HTerrainData.MIN_RESOLUTION)
+ _viewport = SubViewport.new()
+ _viewport.size = Vector2(VIEWPORT_SIZE + 2, VIEWPORT_SIZE + 2)
+ _viewport.render_target_update_mode = SubViewport.UPDATE_DISABLED
+ _viewport.render_target_clear_mode = SubViewport.CLEAR_MODE_ALWAYS
+ # We only render 2D, but we don't want the parent world to interfere
+ _viewport.world_3d = World3D.new()
+ _viewport.own_world_3d = true
+ add_child(_viewport)
+
+ var mat = ShaderMaterial.new()
+ mat.shader = load("res://addons/zylann.hterrain/tools/bump2normal_tex.gdshader")
+
+ _ci = Sprite2D.new()
+ _ci.centered = false
+ _ci.material = mat
+ _viewport.add_child(_ci)
+
+ set_process(false)
+
+
+func set_terrain_data(data: HTerrainData):
+ if data == _terrain_data:
+ return
+
+ _pending_tiles_grid.clear()
+ _pending_tiles_queue.clear()
+ _processing_tile = null
+ _ci.texture = null
+ set_process(false)
+
+ if data == null:
+ _terrain_data.map_changed.disconnect(_on_terrain_data_map_changed)
+ _terrain_data.resolution_changed.disconnect(_on_terrain_data_resolution_changed)
+
+ _terrain_data = data
+
+ if _terrain_data != null:
+ _terrain_data.map_changed.connect(_on_terrain_data_map_changed)
+ _terrain_data.resolution_changed.connect(_on_terrain_data_resolution_changed)
+ _ci.texture = data.get_texture(HTerrainData.CHANNEL_HEIGHT)
+
+
+func _on_terrain_data_map_changed(maptype: int, index: int):
+ if maptype == HTerrainData.CHANNEL_HEIGHT:
+ _ci.texture = _terrain_data.get_texture(HTerrainData.CHANNEL_HEIGHT)
+
+
+func _on_terrain_data_resolution_changed():
+ # TODO Workaround issue https://github.com/godotengine/godot/issues/24463
+ _ci.queue_redraw()
+
+
+# TODO Use Vector2i
+func request_tiles_in_region(min_pos: Vector2, size: Vector2):
+ assert(is_inside_tree())
+ assert(_terrain_data != null)
+ var res = _terrain_data.get_resolution()
+
+ min_pos -= Vector2(1, 1)
+ var max_pos = min_pos + size + Vector2(1, 1)
+ var tmin = (min_pos / VIEWPORT_SIZE).floor()
+ var tmax = (max_pos / VIEWPORT_SIZE).ceil()
+ var ntx = res / VIEWPORT_SIZE
+ var nty = res / VIEWPORT_SIZE
+ tmin.x = clamp(tmin.x, 0, ntx)
+ tmin.y = clamp(tmin.y, 0, nty)
+ tmax.x = clamp(tmax.x, 0, ntx)
+ tmax.y = clamp(tmax.y, 0, nty)
+
+ for y in range(tmin.y, tmax.y):
+ for x in range(tmin.x, tmax.x):
+ request_tile(Vector2(x, y))
+
+
+# TODO Use Vector2i
+func request_tile(tpos: Vector2):
+ assert(tpos == tpos.round())
+ if _pending_tiles_grid.has(tpos):
+ var state = _pending_tiles_grid[tpos]
+ if state == STATE_PENDING:
+ return
+ _pending_tiles_grid[tpos] = STATE_PENDING
+ _pending_tiles_queue.push_front(tpos)
+ set_process(true)
+
+
+func _process(delta):
+ if not is_processing():
+ return
+
+ if _processing_tile != null and _terrain_data != null:
+ var src = _viewport.get_texture().get_image()
+ var dst = _terrain_data.get_image(HTerrainData.CHANNEL_NORMAL)
+
+ src.convert(dst.get_format())
+ #src.save_png(str("test_", _processing_tile.x, "_", _processing_tile.y, ".png"))
+ var pos = _processing_tile * VIEWPORT_SIZE
+ var w = src.get_width() - 1
+ var h = src.get_height() - 1
+ dst.blit_rect(src, Rect2i(1, 1, w, h), pos)
+ _terrain_data.notify_region_change(Rect2(pos.x, pos.y, w, h), HTerrainData.CHANNEL_NORMAL)
+
+ if _pending_tiles_grid[_processing_tile] == STATE_PROCESSING:
+ _pending_tiles_grid.erase(_processing_tile)
+ _processing_tile = null
+
+ if _has_pending_tiles():
+ var tpos = _pending_tiles_queue[-1]
+ _pending_tiles_queue.pop_back()
+ # The sprite will be much larger than the viewport due to the size of the heightmap.
+ # We move it around so the part inside the viewport will correspond to the tile.
+ _ci.position = -VIEWPORT_SIZE * tpos + Vector2(1, 1)
+ _viewport.render_target_update_mode = SubViewport.UPDATE_ONCE
+ _processing_tile = tpos
+ _pending_tiles_grid[tpos] = STATE_PROCESSING
+ else:
+ set_process(false)
+
+
+func _has_pending_tiles():
+ return len(_pending_tiles_queue) > 0
+
+
diff --git a/game/addons/zylann.hterrain/tools/packed_textures/packed_texture_util.gd b/game/addons/zylann.hterrain/tools/packed_textures/packed_texture_util.gd
new file mode 100644
index 0000000..4032212
--- /dev/null
+++ b/game/addons/zylann.hterrain/tools/packed_textures/packed_texture_util.gd
@@ -0,0 +1,86 @@
+@tool
+
+const HT_Logger = preload("../../util/logger.gd")
+const HT_Errors = preload("../../util/errors.gd")
+const HT_Result = preload("../util/result.gd")
+
+const _transform_params = [
+ "normalmap_flip_y"
+]
+
+
+# sources: {
+# "a": "path_to_image_where_red_channel_will_be_stored_in_alpha.png",
+# "rgb": "path_to_image_where_rgb_channels_will_be_stored.png"
+# "rgba": "path_to_image.png",
+# "rgb": "#hexcolor"
+# }
+static func generate_image(sources: Dictionary, resolution: int, logger) -> HT_Result:
+ var image := Image.create(resolution, resolution, true, Image.FORMAT_RGBA8)
+
+ var flip_normalmap_y := false
+
+ # TODO Accelerate with GDNative
+ for key in sources:
+ if key in _transform_params:
+ continue
+
+ var src_path : String = sources[key]
+
+ logger.debug(str("Processing source \"", src_path, "\""))
+
+ var src_image : Image
+ if src_path.begins_with("#"):
+ # Plain color
+ var col = Color(src_path)
+ src_image = Image.create(resolution, resolution, false, Image.FORMAT_RGBA8)
+ src_image.fill(col)
+
+ else:
+ # File
+ src_image = Image.new()
+ var err := src_image.load(src_path)
+ if err != OK:
+ return HT_Result.new(false, "Could not open file \"{0}\": {1}" \
+ .format([src_path, HT_Errors.get_message(err)])) \
+ .with_value(err)
+ src_image.decompress()
+
+ src_image.resize(image.get_width(), image.get_height())
+
+ # TODO Support more channel configurations
+ if key == "rgb":
+ for y in image.get_height():
+ for x in image.get_width():
+ var dst_col := image.get_pixel(x, y)
+ var a := dst_col.a
+ dst_col = src_image.get_pixel(x, y)
+ dst_col.a = a
+ image.set_pixel(x, y, dst_col)
+
+ elif key == "a":
+ for y in image.get_height():
+ for x in image.get_width():
+ var dst_col := image.get_pixel(x, y)
+ dst_col.a = src_image.get_pixel(x, y).r
+ image.set_pixel(x, y, dst_col)
+
+ elif key == "rgba":
+ # Meh
+ image.blit_rect(src_image,
+ Rect2i(0, 0, image.get_width(), image.get_height()), Vector2i())
+
+ if sources.has("normalmap_flip_y") and sources.normalmap_flip_y:
+ _flip_normalmap_y(image)
+
+ return HT_Result.new(true).with_value(image)
+
+
+static func _flip_normalmap_y(image: Image):
+ for y in image.get_height():
+ for x in image.get_width():
+ var col := image.get_pixel(x, y)
+ col.g = 1.0 - col.g
+ image.set_pixel(x, y, col)
+
+
diff --git a/game/addons/zylann.hterrain/tools/panel.gd b/game/addons/zylann.hterrain/tools/panel.gd
new file mode 100644
index 0000000..307e008
--- /dev/null
+++ b/game/addons/zylann.hterrain/tools/panel.gd
@@ -0,0 +1,75 @@
+@tool
+extends Control
+
+const HT_DetailEditor = preload("./detail_editor/detail_editor.gd")
+
+
+# Emitted when a texture item is selected
+signal texture_selected(index)
+signal edit_texture_pressed(index)
+signal import_textures_pressed
+
+# Emitted when a detail item is selected (grass painting)
+signal detail_selected(index)
+signal detail_list_changed
+
+
+@onready var _minimap = $HSplitContainer/HSplitContainer/MinimapContainer/Minimap
+@onready var _brush_editor = $HSplitContainer/BrushEditor
+@onready var _texture_editor = $HSplitContainer/HSplitContainer/HSplitContainer/TextureEditor
+@onready var _detail_editor : HT_DetailEditor = \
+ $HSplitContainer/HSplitContainer/HSplitContainer/DetailEditor
+
+
+func setup_dialogs(base_control: Control):
+ _brush_editor.setup_dialogs(base_control)
+
+
+func set_terrain(terrain):
+ _minimap.set_terrain(terrain)
+ _texture_editor.set_terrain(terrain)
+ _detail_editor.set_terrain(terrain)
+
+
+func set_undo_redo(undo_manager: EditorUndoRedoManager):
+ _detail_editor.set_undo_redo(undo_manager)
+
+
+func set_image_cache(image_cache):
+ _detail_editor.set_image_cache(image_cache)
+
+
+func set_camera_transform(cam_transform: Transform3D):
+ _minimap.set_camera_transform(cam_transform)
+
+
+func set_terrain_painter(terrain_painter):
+ _brush_editor.set_terrain_painter(terrain_painter)
+
+
+func _on_TextureEditor_texture_selected(index):
+ texture_selected.emit(index)
+
+
+func _on_DetailEditor_detail_selected(index):
+ detail_selected.emit(index)
+
+
+func set_brush_editor_display_mode(mode):
+ _brush_editor.set_display_mode(mode)
+
+
+func set_detail_layer_index(index):
+ _detail_editor.set_layer_index(index)
+
+
+func _on_DetailEditor_detail_list_changed():
+ detail_list_changed.emit()
+
+
+func _on_TextureEditor_import_pressed():
+ import_textures_pressed.emit()
+
+
+func _on_TextureEditor_edit_pressed(index: int):
+ edit_texture_pressed.emit(index)
diff --git a/game/addons/zylann.hterrain/tools/panel.tscn b/game/addons/zylann.hterrain/tools/panel.tscn
new file mode 100644
index 0000000..33e856f
--- /dev/null
+++ b/game/addons/zylann.hterrain/tools/panel.tscn
@@ -0,0 +1,58 @@
+[gd_scene load_steps=7 format=3 uid="uid://dtdgawtpwfaft"]
+
+[ext_resource type="Script" path="res://addons/zylann.hterrain/tools/panel.gd" id="1"]
+[ext_resource type="PackedScene" uid="uid://bd42ig216p216" path="res://addons/zylann.hterrain/tools/brush/brush_editor.tscn" id="2"]
+[ext_resource type="PackedScene" path="res://addons/zylann.hterrain/tools/texture_editor/texture_editor.tscn" id="3"]
+[ext_resource type="PackedScene" uid="uid://do3c3jse5p7hx" path="res://addons/zylann.hterrain/tools/detail_editor/detail_editor.tscn" id="4"]
+[ext_resource type="PackedScene" uid="uid://cba6k3hrwhrke" path="res://addons/zylann.hterrain/tools/minimap/minimap.tscn" id="5"]
+[ext_resource type="Script" path="res://addons/zylann.hterrain/tools/minimap/ratio_container.gd" id="6"]
+
+[node name="Panel" type="Control"]
+custom_minimum_size = Vector2(400, 120)
+layout_mode = 3
+anchors_preset = 0
+offset_right = 1012.0
+offset_bottom = 120.0
+script = ExtResource("1")
+
+[node name="HSplitContainer" type="HSplitContainer" parent="."]
+layout_mode = 0
+anchor_right = 1.0
+anchor_bottom = 1.0
+offset_left = 4.0
+offset_top = 4.0
+offset_right = -6.0
+offset_bottom = -4.0
+split_offset = 60
+
+[node name="BrushEditor" parent="HSplitContainer" instance=ExtResource("2")]
+layout_mode = 2
+
+[node name="HSplitContainer" type="HSplitContainer" parent="HSplitContainer"]
+layout_mode = 2
+
+[node name="HSplitContainer" type="HSplitContainer" parent="HSplitContainer/HSplitContainer"]
+layout_mode = 2
+size_flags_horizontal = 3
+split_offset = 300
+
+[node name="TextureEditor" parent="HSplitContainer/HSplitContainer/HSplitContainer" instance=ExtResource("3")]
+layout_mode = 2
+size_flags_horizontal = 1
+
+[node name="DetailEditor" parent="HSplitContainer/HSplitContainer/HSplitContainer" instance=ExtResource("4")]
+layout_mode = 2
+
+[node name="MinimapContainer" type="Container" parent="HSplitContainer/HSplitContainer"]
+custom_minimum_size = Vector2(100, 0)
+layout_mode = 2
+script = ExtResource("6")
+
+[node name="Minimap" parent="HSplitContainer/HSplitContainer/MinimapContainer" instance=ExtResource("5")]
+layout_mode = 2
+
+[connection signal="edit_pressed" from="HSplitContainer/HSplitContainer/HSplitContainer/TextureEditor" to="." method="_on_TextureEditor_edit_pressed"]
+[connection signal="import_pressed" from="HSplitContainer/HSplitContainer/HSplitContainer/TextureEditor" to="." method="_on_TextureEditor_import_pressed"]
+[connection signal="texture_selected" from="HSplitContainer/HSplitContainer/HSplitContainer/TextureEditor" to="." method="_on_TextureEditor_texture_selected"]
+[connection signal="detail_list_changed" from="HSplitContainer/HSplitContainer/HSplitContainer/DetailEditor" to="." method="_on_DetailEditor_detail_list_changed"]
+[connection signal="detail_selected" from="HSplitContainer/HSplitContainer/HSplitContainer/DetailEditor" to="." method="_on_DetailEditor_detail_selected"]
diff --git a/game/addons/zylann.hterrain/tools/plugin.gd b/game/addons/zylann.hterrain/tools/plugin.gd
new file mode 100644
index 0000000..741ce8d
--- /dev/null
+++ b/game/addons/zylann.hterrain/tools/plugin.gd
@@ -0,0 +1,884 @@
+@tool # https://www.youtube.com/watch?v=Y7JG63IuaWs
+
+extends EditorPlugin
+
+const HTerrain = preload("../hterrain.gd")
+const HTerrainDetailLayer = preload("../hterrain_detail_layer.gd")
+const HTerrainData = preload("../hterrain_data.gd")
+const HTerrainMesher = preload("../hterrain_mesher.gd")
+const HTerrainTextureSet = preload("../hterrain_texture_set.gd")
+const HT_PreviewGenerator = preload("./preview_generator.gd")
+const HT_TerrainPainter = preload("./brush/terrain_painter.gd")
+const HT_BrushDecal = preload("./brush/decal.gd")
+const HT_Util = preload("../util/util.gd")
+const HT_EditorUtil = preload("./util/editor_util.gd")
+const HT_LoadTextureDialog = preload("./load_texture_dialog.gd")
+const HT_GlobalMapBaker = preload("./globalmap_baker.gd")
+const HT_ImageFileCache = preload("../util/image_file_cache.gd")
+const HT_Logger = preload("../util/logger.gd")
+const HT_EditPanel = preload("./panel.gd")
+const HT_GeneratorDialog = preload("./generator/generator_dialog.gd")
+const HT_TextureSetEditor = preload("./texture_editor/set_editor/texture_set_editor.gd")
+const HT_TextureSetImportEditor = \
+ preload("./texture_editor/set_editor/texture_set_import_editor.gd")
+const HT_ProgressWindow = preload("./progress_window.gd")
+
+const HT_EditPanelScene = preload("./panel.tscn")
+const HT_ProgressWindowScene = preload("./progress_window.tscn")
+const HT_GeneratorDialogScene = preload("./generator/generator_dialog.tscn")
+const HT_ImportDialogScene = preload("./importer/importer_dialog.tscn")
+const HT_GenerateMeshDialogScene = preload("./generate_mesh_dialog.tscn")
+const HT_ResizeDialogScene = preload("./resize_dialog/resize_dialog.tscn")
+const HT_ExportImageDialogScene = preload("./exporter/export_image_dialog.tscn")
+const HT_TextureSetEditorScene = preload("./texture_editor/set_editor/texture_set_editor.tscn")
+const HT_TextureSetImportEditorScene = \
+ preload("./texture_editor/set_editor/texture_set_import_editor.tscn")
+const HT_AboutDialogScene = preload("./about/about_dialog.tscn")
+
+const DOCUMENTATION_URL = "https://hterrain-plugin.readthedocs.io/en/latest"
+
+const MENU_IMPORT_MAPS = 0
+const MENU_GENERATE = 1
+const MENU_BAKE_GLOBALMAP = 2
+const MENU_RESIZE = 3
+const MENU_UPDATE_EDITOR_COLLIDER = 4
+const MENU_GENERATE_MESH = 5
+const MENU_EXPORT_HEIGHTMAP = 6
+const MENU_LOOKDEV = 7
+const MENU_DOCUMENTATION = 8
+const MENU_ABOUT = 9
+
+
+# TODO Rename _terrain
+var _node : HTerrain = null
+
+# GUI
+var _panel : HT_EditPanel = null
+var _toolbar : Container = null
+var _toolbar_brush_buttons := {}
+var _generator_dialog : HT_GeneratorDialog = null
+# TODO Rename _import_terrain_dialog
+var _import_dialog = null
+var _export_image_dialog = null
+
+# This window is only used for operations not triggered by an existing dialog.
+# In Godot it has been solved by automatically reparenting the dialog:
+# https://github.com/godotengine/godot/pull/71209
+# But `get_exclusive_child()` is not exposed. So dialogs triggering a progress
+# dialog may need their own child instance...
+var _progress_window : HT_ProgressWindow = null
+
+var _generate_mesh_dialog = null
+var _preview_generator : HT_PreviewGenerator = null
+var _resize_dialog = null
+var _about_dialog = null
+var _menu_button : MenuButton
+var _lookdev_menu : PopupMenu
+var _texture_set_editor : HT_TextureSetEditor = null
+var _texture_set_import_editor : HT_TextureSetImportEditor = null
+
+var _globalmap_baker : HT_GlobalMapBaker = null
+var _terrain_had_data_previous_frame := false
+var _image_cache : HT_ImageFileCache
+var _terrain_painter : HT_TerrainPainter = null
+var _brush_decal : HT_BrushDecal = null
+var _mouse_pressed := false
+#var _pending_paint_action = null
+var _pending_paint_commit := false
+
+var _logger := HT_Logger.get_for(self)
+
+
+func get_icon(icon_name: String) -> Texture2D:
+ return HT_EditorUtil.load_texture(
+ "res://addons/zylann.hterrain/tools/icons/icon_" + icon_name + ".svg", _logger)
+
+
+func _enter_tree():
+ _logger.debug("HTerrain plugin Enter tree")
+
+ var dpi_scale = get_editor_interface().get_editor_scale()
+ _logger.debug(str("DPI scale: ", dpi_scale))
+
+ add_custom_type("HTerrain", "Node3D", HTerrain, get_icon("heightmap_node"))
+ add_custom_type("HTerrainDetailLayer", "Node3D", HTerrainDetailLayer,
+ get_icon("detail_layer_node"))
+ add_custom_type("HTerrainData", "Resource", HTerrainData, get_icon("heightmap_data"))
+ # TODO Proper texture
+ add_custom_type("HTerrainTextureSet", "Resource", HTerrainTextureSet, null)
+
+ _preview_generator = HT_PreviewGenerator.new()
+ get_editor_interface().get_resource_previewer().add_preview_generator(_preview_generator)
+
+ _terrain_painter = HT_TerrainPainter.new()
+ _terrain_painter.set_brush_size(5)
+ _terrain_painter.get_brush().size_changed.connect(_on_brush_size_changed)
+ add_child(_terrain_painter)
+
+ _brush_decal = HT_BrushDecal.new()
+ _brush_decal.set_size(_terrain_painter.get_brush_size())
+
+ _image_cache = HT_ImageFileCache.new("user://temp_hterrain_image_cache")
+
+ var editor_interface := get_editor_interface()
+ var base_control := editor_interface.get_base_control()
+
+ _panel = HT_EditPanelScene.instantiate()
+ HT_Util.apply_dpi_scale(_panel, dpi_scale)
+ _panel.hide()
+ add_control_to_container(EditorPlugin.CONTAINER_SPATIAL_EDITOR_BOTTOM, _panel)
+ # Apparently _ready() still isn't called at this point...
+ _panel.call_deferred("set_terrain_painter", _terrain_painter)
+ _panel.call_deferred("setup_dialogs", base_control)
+ _panel.set_undo_redo(get_undo_redo())
+ _panel.set_image_cache(_image_cache)
+ _panel.detail_selected.connect(_on_detail_selected)
+ _panel.texture_selected.connect(_on_texture_selected)
+ _panel.detail_list_changed.connect(_update_brush_buttons_availability)
+ _panel.edit_texture_pressed.connect(_on_Panel_edit_texture_pressed)
+ _panel.import_textures_pressed.connect(_on_Panel_import_textures_pressed)
+
+ _toolbar = HBoxContainer.new()
+ add_control_to_container(EditorPlugin.CONTAINER_SPATIAL_EDITOR_MENU, _toolbar)
+ _toolbar.hide()
+
+ var menu := MenuButton.new()
+ menu.set_text("Terrain")
+ menu.get_popup().add_item("Import maps...", MENU_IMPORT_MAPS)
+ menu.get_popup().add_item("Generate...", MENU_GENERATE)
+ menu.get_popup().add_item("Resize...", MENU_RESIZE)
+ menu.get_popup().add_item("Bake global map", MENU_BAKE_GLOBALMAP)
+ menu.get_popup().add_separator()
+ menu.get_popup().add_item("Update Editor Collider", MENU_UPDATE_EDITOR_COLLIDER)
+ menu.get_popup().add_separator()
+ menu.get_popup().add_item("Generate mesh (heavy)", MENU_GENERATE_MESH)
+ menu.get_popup().add_separator()
+ menu.get_popup().add_item("Export heightmap", MENU_EXPORT_HEIGHTMAP)
+ menu.get_popup().add_separator()
+ _lookdev_menu = PopupMenu.new()
+ _lookdev_menu.name = "LookdevMenu"
+ _lookdev_menu.about_to_popup.connect(_on_lookdev_menu_about_to_show)
+ _lookdev_menu.id_pressed.connect(_on_lookdev_menu_id_pressed)
+ menu.get_popup().add_child(_lookdev_menu)
+ menu.get_popup().add_submenu_item("Lookdev", _lookdev_menu.name, MENU_LOOKDEV)
+ menu.get_popup().id_pressed.connect(_menu_item_selected)
+ menu.get_popup().add_separator()
+ menu.get_popup().add_item("Documentation", MENU_DOCUMENTATION)
+ menu.get_popup().add_item("About HTerrain...", MENU_ABOUT)
+ _toolbar.add_child(menu)
+ _menu_button = menu
+
+ var mode_icons := {}
+ mode_icons[HT_TerrainPainter.MODE_RAISE] = get_icon("heightmap_raise")
+ mode_icons[HT_TerrainPainter.MODE_LOWER] = get_icon("heightmap_lower")
+ mode_icons[HT_TerrainPainter.MODE_SMOOTH] = get_icon("heightmap_smooth")
+ mode_icons[HT_TerrainPainter.MODE_FLATTEN] = get_icon("heightmap_flatten")
+ mode_icons[HT_TerrainPainter.MODE_SPLAT] = get_icon("heightmap_paint")
+ mode_icons[HT_TerrainPainter.MODE_COLOR] = get_icon("heightmap_color")
+ mode_icons[HT_TerrainPainter.MODE_DETAIL] = get_icon("grass")
+ mode_icons[HT_TerrainPainter.MODE_MASK] = get_icon("heightmap_mask")
+ mode_icons[HT_TerrainPainter.MODE_LEVEL] = get_icon("heightmap_level")
+ mode_icons[HT_TerrainPainter.MODE_ERODE] = get_icon("heightmap_erode")
+
+ var mode_tooltips := {}
+ mode_tooltips[HT_TerrainPainter.MODE_RAISE] = "Raise height"
+ mode_tooltips[HT_TerrainPainter.MODE_LOWER] = "Lower height"
+ mode_tooltips[HT_TerrainPainter.MODE_SMOOTH] = "Smooth height"
+ mode_tooltips[HT_TerrainPainter.MODE_FLATTEN] = "Flatten (flatten to a specific height)"
+ mode_tooltips[HT_TerrainPainter.MODE_SPLAT] = "Texture paint"
+ mode_tooltips[HT_TerrainPainter.MODE_COLOR] = "Color paint"
+ mode_tooltips[HT_TerrainPainter.MODE_DETAIL] = "Grass paint"
+ mode_tooltips[HT_TerrainPainter.MODE_MASK] = "Cut holes"
+ mode_tooltips[HT_TerrainPainter.MODE_LEVEL] = "Level (smoothly flattens to average)"
+ mode_tooltips[HT_TerrainPainter.MODE_ERODE] = "Erode"
+
+ _toolbar.add_child(VSeparator.new())
+
+ # I want modes to be in that order in the GUI
+ var ordered_brush_modes := [
+ HT_TerrainPainter.MODE_RAISE,
+ HT_TerrainPainter.MODE_LOWER,
+ HT_TerrainPainter.MODE_SMOOTH,
+ HT_TerrainPainter.MODE_LEVEL,
+ HT_TerrainPainter.MODE_FLATTEN,
+ HT_TerrainPainter.MODE_ERODE,
+ HT_TerrainPainter.MODE_SPLAT,
+ HT_TerrainPainter.MODE_COLOR,
+ HT_TerrainPainter.MODE_DETAIL,
+ HT_TerrainPainter.MODE_MASK
+ ]
+
+ var mode_group := ButtonGroup.new()
+
+ for mode in ordered_brush_modes:
+ var button := Button.new()
+ button.flat = true
+ button.icon = mode_icons[mode]
+ button.tooltip_text = mode_tooltips[mode]
+ button.set_toggle_mode(true)
+ button.set_button_group(mode_group)
+
+ if mode == _terrain_painter.get_mode():
+ button.button_pressed = true
+
+ button.pressed.connect(_on_mode_selected.bind(mode))
+ _toolbar.add_child(button)
+
+ _toolbar_brush_buttons[mode] = button
+
+ _generator_dialog = HT_GeneratorDialogScene.instantiate()
+ _generator_dialog.set_image_cache(_image_cache)
+ _generator_dialog.set_undo_redo(get_undo_redo())
+ base_control.add_child(_generator_dialog)
+ _generator_dialog.apply_dpi_scale(dpi_scale)
+
+ _import_dialog = HT_ImportDialogScene.instantiate()
+ _import_dialog.permanent_change_performed.connect(_on_permanent_change_performed)
+ HT_Util.apply_dpi_scale(_import_dialog, dpi_scale)
+ base_control.add_child(_import_dialog)
+
+ _progress_window = HT_ProgressWindowScene.instantiate()
+ base_control.add_child(_progress_window)
+
+ _generate_mesh_dialog = HT_GenerateMeshDialogScene.instantiate()
+ _generate_mesh_dialog.generate_selected.connect(_on_GenerateMeshDialog_generate_selected)
+ HT_Util.apply_dpi_scale(_generate_mesh_dialog, dpi_scale)
+ base_control.add_child(_generate_mesh_dialog)
+
+ _resize_dialog = HT_ResizeDialogScene.instantiate()
+ _resize_dialog.permanent_change_performed.connect(_on_permanent_change_performed)
+ HT_Util.apply_dpi_scale(_resize_dialog, dpi_scale)
+ base_control.add_child(_resize_dialog)
+
+ _globalmap_baker = HT_GlobalMapBaker.new()
+ _globalmap_baker.progress_notified.connect(_progress_window.handle_progress)
+ _globalmap_baker.permanent_change_performed.connect(_on_permanent_change_performed)
+ add_child(_globalmap_baker)
+
+ _export_image_dialog = HT_ExportImageDialogScene.instantiate()
+ HT_Util.apply_dpi_scale(_export_image_dialog, dpi_scale)
+ base_control.add_child(_export_image_dialog)
+ # Need to call deferred because in the specific case where you start the editor
+ # with the plugin enabled, _ready won't be called at this point
+ _export_image_dialog.call_deferred("setup_dialogs", base_control)
+
+ _about_dialog = HT_AboutDialogScene.instantiate()
+ HT_Util.apply_dpi_scale(_about_dialog, dpi_scale)
+ base_control.add_child(_about_dialog)
+
+ _texture_set_editor = HT_TextureSetEditorScene.instantiate()
+ _texture_set_editor.set_undo_redo(get_undo_redo())
+ HT_Util.apply_dpi_scale(_texture_set_editor, dpi_scale)
+ base_control.add_child(_texture_set_editor)
+ _texture_set_editor.call_deferred("setup_dialogs", base_control)
+
+ _texture_set_import_editor = HT_TextureSetImportEditorScene.instantiate()
+ _texture_set_import_editor.set_undo_redo(get_undo_redo())
+ _texture_set_import_editor.set_editor_file_system(
+ get_editor_interface().get_resource_filesystem())
+ HT_Util.apply_dpi_scale(_texture_set_import_editor, dpi_scale)
+ base_control.add_child(_texture_set_import_editor)
+ _texture_set_import_editor.call_deferred("setup_dialogs", base_control)
+
+ _texture_set_editor.import_selected.connect(_on_TextureSetEditor_import_selected)
+
+
+func _exit_tree():
+ _logger.debug("HTerrain plugin Exit tree")
+
+ # Make sure we release all references to edited stuff
+ _edit(null)
+
+ _panel.queue_free()
+ _panel = null
+
+ _toolbar.queue_free()
+ _toolbar = null
+
+ _generator_dialog.queue_free()
+ _generator_dialog = null
+
+ _import_dialog.queue_free()
+ _import_dialog = null
+
+ _progress_window.queue_free()
+ _progress_window = null
+
+ _generate_mesh_dialog.queue_free()
+ _generate_mesh_dialog = null
+
+ _resize_dialog.queue_free()
+ _resize_dialog = null
+
+ _export_image_dialog.queue_free()
+ _export_image_dialog = null
+
+ _about_dialog.queue_free()
+ _about_dialog = null
+
+ _texture_set_editor.queue_free()
+ _texture_set_editor = null
+
+ _texture_set_import_editor.queue_free()
+ _texture_set_import_editor = null
+
+ get_editor_interface().get_resource_previewer().remove_preview_generator(_preview_generator)
+ _preview_generator = null
+
+ # TODO Manual clear cuz it can't do it automatically due to a Godot bug
+ _image_cache.clear()
+
+ # TODO https://github.com/godotengine/godot/issues/6254#issuecomment-246139694
+ # This was supposed to be automatic, but was never implemented it seems...
+ remove_custom_type("HTerrain")
+ remove_custom_type("HTerrainDetailLayer")
+ remove_custom_type("HTerrainData")
+ remove_custom_type("HTerrainTextureSet")
+
+
+func _handles(object):
+ return _get_terrain_from_object(object) != null
+
+
+func _edit(object):
+ _logger.debug(str("Edit ", object))
+
+ var node = _get_terrain_from_object(object)
+
+ if _node != null:
+ _node.tree_exited.disconnect(_terrain_exited_scene)
+
+ _node = node
+
+ if _node != null:
+ _node.tree_exited.connect(_terrain_exited_scene)
+
+ _update_brush_buttons_availability()
+
+ _panel.set_terrain(_node)
+ _generator_dialog.set_terrain(_node)
+ _import_dialog.set_terrain(_node)
+ _terrain_painter.set_terrain(_node)
+ _brush_decal.set_terrain(_node)
+ _generate_mesh_dialog.set_terrain(_node)
+ _resize_dialog.set_terrain(_node)
+ _export_image_dialog.set_terrain(_node)
+
+ if object is HTerrainDetailLayer:
+ # Auto-select layer for painting
+ if object.is_layer_index_valid():
+ _panel.set_detail_layer_index(object.get_layer_index())
+ _on_detail_selected(object.get_layer_index())
+
+ _update_toolbar_menu_availability()
+
+
+static func _get_terrain_from_object(object):
+ if object != null and object is Node3D:
+ if not object.is_inside_tree():
+ return null
+ if object is HTerrain:
+ return object
+ if object is HTerrainDetailLayer and object.get_parent() is HTerrain:
+ return object.get_parent()
+ return null
+
+
+func _update_brush_buttons_availability():
+ if _node == null:
+ return
+ if _node.get_data() != null:
+ var data = _node.get_data()
+ var has_details = (data.get_map_count(HTerrainData.CHANNEL_DETAIL) > 0)
+
+ if has_details:
+ var button = _toolbar_brush_buttons[HT_TerrainPainter.MODE_DETAIL]
+ button.disabled = false
+ else:
+ var button = _toolbar_brush_buttons[HT_TerrainPainter.MODE_DETAIL]
+ if button.button_pressed:
+ _select_brush_mode(HT_TerrainPainter.MODE_RAISE)
+ button.disabled = true
+
+
+func _update_toolbar_menu_availability():
+ var data_available := false
+ if _node != null and _node.get_data() != null:
+ data_available = true
+ var popup : PopupMenu = _menu_button.get_popup()
+ for i in popup.get_item_count():
+ #var id = popup.get_item_id(i)
+ # Turn off items if there is no data for them to work on
+ if data_available:
+ popup.set_item_disabled(i, false)
+ popup.set_item_tooltip(i, "")
+ else:
+ popup.set_item_disabled(i, true)
+ popup.set_item_tooltip(i, "Terrain has no data")
+
+
+func _make_visible(visible: bool):
+ _panel.set_visible(visible)
+ _toolbar.set_visible(visible)
+ _brush_decal.update_visibility()
+
+ # TODO Workaround https://github.com/godotengine/godot/issues/6459
+ # When the user selects another node,
+ # I want the plugin to release its references to the terrain.
+ # This is important because if we don't do that, some modified resources will still be
+ # loaded in memory, so if the user closes the scene and reopens it later, the changes will
+ # still be partially present, and this is not expected.
+ if not visible:
+ _edit(null)
+
+
+# TODO Can't hint return as `Vector2?` because it's nullable
+func _get_pointed_cell_position(mouse_position: Vector2, p_camera: Camera3D):# -> Vector2:
+ # Need to do an extra conversion in case the editor viewport is in half-resolution mode
+ var viewport := p_camera.get_viewport()
+ var viewport_container : Control = viewport.get_parent()
+ var screen_pos = mouse_position * Vector2(viewport.size) / viewport_container.size
+
+ var origin = p_camera.project_ray_origin(screen_pos)
+ var dir = p_camera.project_ray_normal(screen_pos)
+
+ var ray_distance := p_camera.far * 1.2
+ return _node.cell_raycast(origin, dir, ray_distance)
+
+
+func _forward_3d_gui_input(p_camera: Camera3D, p_event: InputEvent) -> int:
+ if _node == null || _node.get_data() == null:
+ return AFTER_GUI_INPUT_PASS
+
+ _node._edit_update_viewer_position(p_camera)
+ _panel.set_camera_transform(p_camera.global_transform)
+
+ var captured_event = false
+
+ if p_event is InputEventMouseButton:
+ var mb = p_event
+
+ if mb.button_index == MOUSE_BUTTON_LEFT or mb.button_index == MOUSE_BUTTON_RIGHT:
+ if mb.pressed == false:
+ _mouse_pressed = false
+
+ # Need to check modifiers before capturing the event,
+ # because they are used in navigation schemes
+ if (not mb.ctrl_pressed) and (not mb.alt_pressed) and mb.button_index == MOUSE_BUTTON_LEFT:
+ if mb.pressed:
+ # TODO Allow to paint on click
+ # TODO `pressure` is not available in button press events
+ # So I have to assume zero to avoid discontinuities with move events
+ #_terrain_painter.paint_input(hit_pos_in_cells, 0.0)
+ _mouse_pressed = true
+
+ captured_event = true
+
+ if not _mouse_pressed:
+ # Just finished painting
+ _pending_paint_commit = true
+ _terrain_painter.get_brush().on_paint_end()
+
+ if _terrain_painter.get_mode() == HT_TerrainPainter.MODE_FLATTEN \
+ and _terrain_painter.has_meta("pick_height") \
+ and _terrain_painter.get_meta("pick_height"):
+ _terrain_painter.set_meta("pick_height", false)
+ # Pick height
+ var hit_pos_in_cells = _get_pointed_cell_position(mb.position, p_camera)
+ if hit_pos_in_cells != null:
+ var h = _node.get_data().get_height_at(
+ int(hit_pos_in_cells.x), int(hit_pos_in_cells.y))
+ _logger.debug("Picking height {0}".format([h]))
+ _terrain_painter.set_flatten_height(h)
+
+ elif p_event is InputEventMouseMotion:
+ var mm = p_event
+ var hit_pos_in_cells = _get_pointed_cell_position(mm.position, p_camera)
+ if hit_pos_in_cells != null:
+ _brush_decal.set_position(Vector3(hit_pos_in_cells.x, 0, hit_pos_in_cells.y))
+
+ if _mouse_pressed:
+ if Input.is_mouse_button_pressed(MOUSE_BUTTON_LEFT):
+ _terrain_painter.paint_input(hit_pos_in_cells, mm.pressure)
+ captured_event = true
+
+ # This is in case the data or textures change as the user edits the terrain,
+ # to keep the decal working without having to noodle around with nested signals
+ _brush_decal.update_visibility()
+
+ if captured_event:
+ return AFTER_GUI_INPUT_STOP
+ return AFTER_GUI_INPUT_PASS
+
+
+func _process(delta: float):
+ if _node == null:
+ return
+
+ var has_data = (_node.get_data() != null)
+
+ if _pending_paint_commit:
+ if has_data:
+ if not _terrain_painter.is_operation_pending():
+ _pending_paint_commit = false
+ if _terrain_painter.has_modified_chunks():
+ _logger.debug("Paint completed")
+ var changes : Dictionary = _terrain_painter.commit()
+ _paint_completed(changes)
+ else:
+ _pending_paint_commit = false
+
+ # Poll presence of data resource
+ if has_data != _terrain_had_data_previous_frame:
+ _terrain_had_data_previous_frame = has_data
+ _update_toolbar_menu_availability()
+
+
+func _paint_completed(changes: Dictionary):
+ var time_before = Time.get_ticks_msec()
+
+ var heightmap_data = _node.get_data()
+ assert(heightmap_data != null)
+
+ var chunk_positions : Array = changes.chunk_positions
+ # Should not create an UndoRedo action if nothing changed
+ assert(len(chunk_positions) > 0)
+ var changed_maps : Array = changes.maps
+
+ var action_name := "Modify HTerrainData "
+ for i in len(changed_maps):
+ var mm = changed_maps[i]
+ var map_debug_name := HTerrainData.get_map_debug_name(mm.map_type, mm.map_index)
+ if i > 0:
+ action_name += " and "
+ action_name += map_debug_name
+
+ var redo_maps := []
+ var undo_maps := []
+ var chunk_size := _terrain_painter.get_undo_chunk_size()
+
+ for map in changed_maps:
+ # Cache images to disk so RAM does not continuously go up (or at least much slower)
+ for chunks in [map.chunk_initial_datas, map.chunk_final_datas]:
+ for i in len(chunks):
+ var im : Image = chunks[i]
+ chunks[i] = _image_cache.save_image(im)
+
+ redo_maps.append({
+ "map_type": map.map_type,
+ "map_index": map.map_index,
+ "chunks": map.chunk_final_datas
+ })
+ undo_maps.append({
+ "map_type": map.map_type,
+ "map_index": map.map_index,
+ "chunks": map.chunk_initial_datas
+ })
+
+ var undo_data := {
+ "chunk_positions": chunk_positions,
+ "chunk_size": chunk_size,
+ "maps": undo_maps
+ }
+ var redo_data := {
+ "chunk_positions": chunk_positions,
+ "chunk_size": chunk_size,
+ "maps": redo_maps
+ }
+
+# {
+# chunk_positions: [Vector2, Vector2, ...]
+# chunk_size: int
+# maps: [
+# {
+# map_type: int
+# map_index: int
+# chunks: [
+# int, int, ...
+# ]
+# },
+# ...
+# ]
+# }
+
+ var ur := get_undo_redo()
+
+ ur.create_action(action_name)
+ ur.add_do_method(heightmap_data, "_edit_apply_undo", redo_data, _image_cache)
+ ur.add_undo_method(heightmap_data, "_edit_apply_undo", undo_data, _image_cache)
+
+ # Small hack here:
+ # commit_actions executes the do method, however terrain modifications are heavy ones,
+ # so we don't really want to re-run an update in every chunk that was modified during painting.
+ # The data is already in its final state,
+ # so we just prevent the resource from applying changes here.
+ heightmap_data._edit_set_disable_apply_undo(true)
+ ur.commit_action()
+ heightmap_data._edit_set_disable_apply_undo(false)
+
+ var time_spent = Time.get_ticks_msec() - time_before
+ _logger.debug(str(action_name, " | ", len(chunk_positions), " chunks | ", time_spent, " ms"))
+
+
+func _terrain_exited_scene():
+ _logger.debug("HTerrain exited the scene")
+ _edit(null)
+
+
+func _menu_item_selected(id: int):
+ _logger.debug(str("Menu item selected ", id))
+
+ match id:
+ MENU_IMPORT_MAPS:
+ _import_dialog.popup_centered()
+
+ MENU_GENERATE:
+ _generator_dialog.popup_centered()
+
+ MENU_BAKE_GLOBALMAP:
+ var data = _node.get_data()
+ if data != null:
+ _globalmap_baker.bake(_node)
+
+ MENU_RESIZE:
+ _resize_dialog.popup_centered()
+
+ MENU_UPDATE_EDITOR_COLLIDER:
+ # This is for editor tools to be able to use terrain collision.
+ # It's not automatic because keeping this collider up to date is
+ # expensive, but not too bad IMO because that feature is not often
+ # used in editor for now.
+ # If users complain too much about this, there are ways to improve it:
+ #
+ # 1) When the terrain gets deselected, update the terrain collider
+ # in a thread automatically. This is still expensive but should
+ # be easy to do.
+ #
+ # 2) Bullet actually support modifying the heights dynamically,
+ # as long as we stay within min and max bounds,
+ # so PR a change to the Godot heightmap collider to support passing
+ # a Float Image directly, and make it so the data is in sync
+ # (no CoW plz!!). It's trickier than 1) but almost free.
+ #
+ _node.update_collider()
+
+ MENU_GENERATE_MESH:
+ if _node != null and _node.get_data() != null:
+ _generate_mesh_dialog.popup_centered()
+
+ MENU_EXPORT_HEIGHTMAP:
+ if _node != null and _node.get_data() != null:
+ _export_image_dialog.popup_centered()
+
+ MENU_LOOKDEV:
+ # No actions here, it's a submenu
+ pass
+
+ MENU_DOCUMENTATION:
+ OS.shell_open(DOCUMENTATION_URL)
+
+ MENU_ABOUT:
+ _about_dialog.popup_centered()
+
+
+func _on_lookdev_menu_about_to_show():
+ _lookdev_menu.clear()
+ _lookdev_menu.add_check_item("Disabled")
+ _lookdev_menu.set_item_checked(0, not _node.is_lookdev_enabled())
+ _lookdev_menu.add_separator()
+ var terrain_data : HTerrainData = _node.get_data()
+ if terrain_data == null:
+ _lookdev_menu.add_item("No terrain data")
+ _lookdev_menu.set_item_disabled(0, true)
+ else:
+ for map_type in HTerrainData.CHANNEL_COUNT:
+ var count := terrain_data.get_map_count(map_type)
+ for map_index in count:
+ var map_name := HTerrainData.get_map_debug_name(map_type, map_index)
+ var lookdev_item_index := _lookdev_menu.get_item_count()
+ _lookdev_menu.add_item(map_name, lookdev_item_index)
+ _lookdev_menu.set_item_metadata(lookdev_item_index, {
+ "map_type": map_type,
+ "map_index": map_index
+ })
+
+
+func _on_lookdev_menu_id_pressed(id: int):
+ var meta = _lookdev_menu.get_item_metadata(id)
+ if meta == null:
+ _node.set_lookdev_enabled(false)
+ else:
+ _node.set_lookdev_enabled(true)
+ var data : HTerrainData = _node.get_data()
+ var map_texture = data.get_texture(meta.map_type, meta.map_index)
+ _node.set_lookdev_shader_param("u_map", map_texture)
+ _lookdev_menu.set_item_checked(0, not _node.is_lookdev_enabled())
+
+
+func _on_mode_selected(mode: int):
+ _logger.debug(str("On mode selected ", mode))
+ _terrain_painter.set_mode(mode)
+ _panel.set_brush_editor_display_mode(mode)
+
+
+func _on_texture_selected(index: int):
+ # Switch to texture paint mode when a texture is selected
+ _select_brush_mode(HT_TerrainPainter.MODE_SPLAT)
+ _terrain_painter.set_texture_index(index)
+
+
+func _on_detail_selected(index: int):
+ # Switch to detail paint mode when a detail item is selected
+ _select_brush_mode(HT_TerrainPainter.MODE_DETAIL)
+ _terrain_painter.set_detail_index(index)
+
+
+func _select_brush_mode(mode: int):
+ _toolbar_brush_buttons[mode].button_pressed = true
+ _on_mode_selected(mode)
+
+
+static func get_size_from_raw_length(flen: int):
+ var side_len = roundf(sqrt(float(flen/2)))
+ return int(side_len)
+
+
+func _on_GenerateMeshDialog_generate_selected(lod: int):
+ var data := _node.get_data()
+ if data == null:
+ _logger.error("Terrain has no data, cannot generate mesh")
+ return
+ var heightmap := data.get_image(HTerrainData.CHANNEL_HEIGHT)
+ var scale := _node.map_scale
+ var mesh := HTerrainMesher.make_heightmap_mesh(heightmap, lod, scale, _logger)
+ var mi := MeshInstance3D.new()
+ mi.name = str(_node.name, "_FullMesh")
+ mi.mesh = mesh
+ mi.transform = _node.transform
+ _node.get_parent().add_child(mi)
+ mi.set_owner(get_editor_interface().get_edited_scene_root())
+
+
+# TODO Workaround for https://github.com/Zylann/godot_heightmap_plugin/issues/101
+func _on_permanent_change_performed(message: String):
+ var data := _node.get_data()
+ if data == null:
+ _logger.error("Terrain has no data, cannot mark it as changed")
+ return
+ var ur := get_undo_redo()
+ ur.create_action(message)
+ ur.add_do_method(data, "_dummy_function")
+ #ur.add_undo_method(data, "_dummy_function")
+ ur.commit_action()
+
+
+func _on_brush_size_changed(size):
+ _brush_decal.set_size(size)
+
+
+func _on_Panel_edit_texture_pressed(index: int):
+ var ts := _node.get_texture_set()
+ _texture_set_editor.set_texture_set(ts)
+ _texture_set_editor.select_slot(index)
+ _texture_set_editor.popup_centered()
+
+
+func _on_TextureSetEditor_import_selected():
+ _open_texture_set_import_editor()
+
+
+func _on_Panel_import_textures_pressed():
+ _open_texture_set_import_editor()
+
+
+func _open_texture_set_import_editor():
+ var ts := _node.get_texture_set()
+ _texture_set_import_editor.set_texture_set(ts)
+ _texture_set_import_editor.popup_centered()
+
+
+################
+# DEBUG LAND
+
+# TEST
+#func _physics_process(delta):
+# if Input.is_key_pressed(KEY_KP_0):
+# _debug_spawn_collider_indicators()
+
+
+func _debug_spawn_collider_indicators():
+ var root = get_editor_interface().get_edited_scene_root()
+ var terrain := HT_Util.find_first_node(root, HTerrain) as HTerrain
+ if terrain == null:
+ return
+
+ var test_root : Node3D
+ if not terrain.has_node("__DEBUG"):
+ test_root = Node3D.new()
+ test_root.name = "__DEBUG"
+ terrain.add_child(test_root)
+ else:
+ test_root = terrain.get_node("__DEBUG")
+
+ var space_state := terrain.get_world_3d().direct_space_state
+ var hit_material := StandardMaterial3D.new()
+ hit_material.albedo_color = Color(0, 1, 1)
+ var cube := BoxMesh.new()
+
+ for zi in 16:
+ for xi in 16:
+ var hit_name := str(xi, "_", zi)
+ var pos := Vector3(xi * 16, 1000, zi * 16)
+
+ var query := PhysicsRayQueryParameters3D.new()
+ query.from = pos
+ query.to = pos + Vector3(0, -2000, 0)
+
+ var hit := space_state.intersect_ray(query)
+
+ var mi : MeshInstance3D
+ if not test_root.has_node(hit_name):
+ mi = MeshInstance3D.new()
+ mi.name = hit_name
+ mi.material_override = hit_material
+ mi.mesh = cube
+ test_root.add_child(mi)
+ else:
+ mi = test_root.get_node(hit_name)
+ if hit.is_empty():
+ mi.hide()
+ else:
+ mi.show()
+ mi.position = hit.position
+
+
+func _spawn_vertical_bound_boxes():
+ var data := _node.get_data()
+# var sy = data._chunked_vertical_bounds_size_y
+# var sx = data._chunked_vertical_bounds_size_x
+ var mat := StandardMaterial3D.new()
+ mat.transparency = BaseMaterial3D.TRANSPARENCY_ALPHA
+ mat.albedo_color = Color(1,1,1,0.2)
+ for cy in range(30, 60):
+ for cx in range(30, 60):
+ var vb := data._chunked_vertical_bounds.get_pixel(cx, cy)
+ var minv := vb.r
+ var maxv := vb.g
+ var mi := MeshInstance3D.new()
+ mi.mesh = BoxMesh.new()
+ var cs := HTerrainData.VERTICAL_BOUNDS_CHUNK_SIZE
+ mi.mesh.size = Vector3(cs, maxv - minv, cs)
+ mi.position = Vector3(
+ (float(cx) + 0.5) * cs,
+ minv + mi.mesh.size.y * 0.5,
+ (float(cy) + 0.5) * cs)
+ mi.position *= _node.map_scale
+ mi.scale = _node.map_scale
+ mi.material_override = mat
+ _node.add_child(mi)
+ mi.owner = get_editor_interface().get_edited_scene_root()
+
+# if p_event is InputEventKey:
+# if p_event.pressed == false:
+# if p_event.scancode == KEY_SPACE and p_event.control:
+# _spawn_vertical_bound_boxes()
diff --git a/game/addons/zylann.hterrain/tools/preview_generator.gd b/game/addons/zylann.hterrain/tools/preview_generator.gd
new file mode 100644
index 0000000..4b6e3db
--- /dev/null
+++ b/game/addons/zylann.hterrain/tools/preview_generator.gd
@@ -0,0 +1,65 @@
+@tool
+extends EditorResourcePreviewGenerator
+
+const HTerrainData = preload("../hterrain_data.gd")
+const HT_Errors = preload("../util/errors.gd")
+const HT_Logger = preload("../util/logger.gd")
+
+var _logger = HT_Logger.get_for(self)
+
+
+func _generate(res: Resource, size: Vector2i, metadata: Dictionary) -> Texture2D:
+ if res == null or not (res is HTerrainData):
+ return null
+ var normalmap = res.get_image(HTerrainData.CHANNEL_NORMAL)
+ if normalmap == null:
+ return null
+ return _generate_internal(normalmap, size)
+
+
+func _generate_from_path(path: String, size: Vector2i, metadata: Dictionary) -> Texture2D:
+ if not path.ends_with("." + HTerrainData.META_EXTENSION):
+ return null
+ var data_dir := path.get_base_dir()
+ var normals_fname := str(HTerrainData.get_channel_name(HTerrainData.CHANNEL_NORMAL), ".png")
+ var normals_path := data_dir.path_join(normals_fname)
+ var normals := Image.new()
+ var err := normals.load(normals_path)
+ if err != OK:
+ _logger.error("Could not load '{0}', error {1}" \
+ .format([normals_path, HT_Errors.get_message(err)]))
+ return null
+ return _generate_internal(normals, size)
+
+
+func _handles(type: String) -> bool:
+ return type == "Resource"
+
+
+static func _generate_internal(normals: Image, size: Vector2) -> Texture2D:
+ var im := Image.create(size.x, size.y, false, Image.FORMAT_RGB8)
+
+ var light_dir := Vector3(-1, -0.5, -1).normalized()
+
+ for y in im.get_height():
+ for x in im.get_width():
+
+ var fx := float(x) / float(im.get_width())
+ var fy := float(im.get_height() - y - 1) / float(im.get_height())
+ var mx := int(fx * normals.get_width())
+ var my := int(fy * normals.get_height())
+
+ var n := _decode_normal(normals.get_pixel(mx, my))
+
+ var ndot := -n.dot(light_dir)
+ var gs := clampf(0.5 * ndot + 0.5, 0.0, 1.0)
+ var col := Color(gs, gs, gs, 1.0)
+
+ im.set_pixel(x, y, col)
+
+ var tex := ImageTexture.create_from_image(im)
+ return tex
+
+
+static func _decode_normal(c: Color) -> Vector3:
+ return Vector3(2.0 * c.r - 1.0, 2.0 * c.b - 1.0, 2.0 * c.g - 1.0)
diff --git a/game/addons/zylann.hterrain/tools/progress_window.gd b/game/addons/zylann.hterrain/tools/progress_window.gd
new file mode 100644
index 0000000..791717f
--- /dev/null
+++ b/game/addons/zylann.hterrain/tools/progress_window.gd
@@ -0,0 +1,32 @@
+@tool
+extends AcceptDialog
+
+
+#onready var _label = get_node("VBoxContainer/Label")
+@onready var _progress_bar : ProgressBar = $VBoxContainer/ProgressBar
+
+
+func _init():
+ get_ok_button().hide()
+
+
+func _show_progress(message, progress):
+ self.title = message
+ _progress_bar.ratio = progress
+
+
+func handle_progress(info: Dictionary):
+ if info.has("finished") and info.finished:
+ hide()
+
+ else:
+ if not visible:
+ popup_centered()
+
+ var message = ""
+ if info.has("message"):
+ message = info.message
+
+ _show_progress(info.message, info.progress)
+ # TODO Have builtin modal progress bar
+ # https://github.com/godotengine/godot/issues/17763
diff --git a/game/addons/zylann.hterrain/tools/progress_window.tscn b/game/addons/zylann.hterrain/tools/progress_window.tscn
new file mode 100644
index 0000000..e6d7d19
--- /dev/null
+++ b/game/addons/zylann.hterrain/tools/progress_window.tscn
@@ -0,0 +1,22 @@
+[gd_scene load_steps=2 format=3 uid="uid://b0f3h46ugtni6"]
+
+[ext_resource type="Script" path="res://addons/zylann.hterrain/tools/progress_window.gd" id="1"]
+
+[node name="WindowDialog" type="AcceptDialog"]
+title = ""
+size = Vector2i(400, 100)
+min_size = Vector2i(400, 40)
+script = ExtResource("1")
+
+[node name="VBoxContainer" type="VBoxContainer" parent="."]
+anchors_preset = 15
+anchor_right = 1.0
+anchor_bottom = 1.0
+offset_left = 8.0
+offset_top = 8.0
+offset_right = -8.0
+offset_bottom = -8.0
+
+[node name="ProgressBar" type="ProgressBar" parent="VBoxContainer"]
+layout_mode = 2
+step = 1.0
diff --git a/game/addons/zylann.hterrain/tools/resize_dialog/resize_dialog.gd b/game/addons/zylann.hterrain/tools/resize_dialog/resize_dialog.gd
new file mode 100644
index 0000000..617e457
--- /dev/null
+++ b/game/addons/zylann.hterrain/tools/resize_dialog/resize_dialog.gd
@@ -0,0 +1,174 @@
+@tool
+extends AcceptDialog
+
+const HT_Util = preload("../../util/util.gd")
+const HT_Logger = preload("../../util/logger.gd")
+const HTerrain = preload("../../hterrain.gd")
+const HTerrainData = preload("../../hterrain_data.gd")
+
+const ANCHOR_TOP_LEFT = 0
+const ANCHOR_TOP = 1
+const ANCHOR_TOP_RIGHT = 2
+const ANCHOR_LEFT = 3
+const ANCHOR_CENTER = 4
+const ANCHOR_RIGHT = 5
+const ANCHOR_BOTTOM_LEFT = 6
+const ANCHOR_BOTTOM = 7
+const ANCHOR_BOTTOM_RIGHT = 8
+const ANCHOR_COUNT = 9
+
+const _anchor_dirs = [
+ [-1, -1],
+ [0, -1],
+ [1, -1],
+ [-1, 0],
+ [0, 0],
+ [1, 0],
+ [-1, 1],
+ [0, 1],
+ [1, 1]
+]
+
+const _anchor_icon_names = [
+ "anchor_top_left",
+ "anchor_top",
+ "anchor_top_right",
+ "anchor_left",
+ "anchor_center",
+ "anchor_right",
+ "anchor_bottom_left",
+ "anchor_bottom",
+ "anchor_bottom_right"
+]
+
+signal permanent_change_performed(message)
+
+@onready var _resolution_dropdown : OptionButton = $VBoxContainer/GridContainer/ResolutionDropdown
+@onready var _stretch_checkbox : CheckBox = $VBoxContainer/GridContainer/StretchCheckBox
+@onready var _anchor_control : Control = $VBoxContainer/GridContainer/HBoxContainer/AnchorControl
+
+const _resolutions = HTerrainData.SUPPORTED_RESOLUTIONS
+
+var _anchor_buttons := []
+var _anchor_buttons_grid := {}
+var _anchor_button_group : ButtonGroup = null
+var _selected_anchor = ANCHOR_TOP_LEFT
+var _logger = HT_Logger.get_for(self)
+
+var _terrain : HTerrain = null
+
+
+func set_terrain(terrain: HTerrain):
+ _terrain = terrain
+
+
+static func _get_icon(name) -> Texture2D:
+ return load("res://addons/zylann.hterrain/tools/icons/icon_" + name + ".svg")
+
+
+func _init():
+ get_ok_button().hide()
+
+
+func _ready():
+ if HT_Util.is_in_edited_scene(self):
+ return
+ # TEST
+ #show()
+
+ for i in len(_resolutions):
+ _resolution_dropdown.add_item(str(_resolutions[i]), i)
+
+ _anchor_button_group = ButtonGroup.new()
+ _anchor_buttons.resize(ANCHOR_COUNT)
+ var x := 0
+ var y := 0
+ for i in _anchor_control.get_child_count():
+ var child_node = _anchor_control.get_child(i)
+ assert(child_node is Button)
+ var child := child_node as Button
+ child.toggle_mode = true
+ child.custom_minimum_size = child.size
+ child.icon = null
+ child.pressed.connect(_on_AnchorButton_pressed.bind(i, x, y))
+ child.button_group = _anchor_button_group
+ _anchor_buttons[i] = child
+ _anchor_buttons_grid[Vector2(x, y)] = child
+ x += 1
+ if x >= 3:
+ x = 0
+ y += 1
+
+ _anchor_buttons[_selected_anchor].button_pressed = true
+ # The signal apparently doesn't trigger in this case
+ _on_AnchorButton_pressed(_selected_anchor, 0, 0)
+
+
+func _notification(what: int):
+ if what == NOTIFICATION_VISIBILITY_CHANGED:
+ if visible:
+ # Select current resolution
+ if _terrain != null and _terrain.get_data() != null:
+ var res := _terrain.get_data().get_resolution()
+ for i in len(_resolutions):
+ if res == _resolutions[i]:
+ _resolution_dropdown.select(i)
+ break
+
+
+func _on_AnchorButton_pressed(anchor0: int, x0: int, y0: int):
+ _selected_anchor = anchor0
+
+ for button in _anchor_buttons:
+ button.icon = null
+
+ for anchor in ANCHOR_COUNT:
+ var d = _anchor_dirs[anchor]
+ var nx = x0 + d[0]
+ var ny = y0 + d[1]
+ var k = Vector2(nx, ny)
+ if not _anchor_buttons_grid.has(k):
+ continue
+ var button : Button = _anchor_buttons_grid[k]
+ var icon := _get_icon(_anchor_icon_names[anchor])
+ button.icon = icon
+
+
+func _set_anchor_control_active(active: bool):
+ for button in _anchor_buttons:
+ button.disabled = not active
+
+
+func _on_ResolutionDropdown_item_selected(id):
+ pass
+
+
+func _on_StretchCheckBox_toggled(button_pressed: bool):
+ _set_anchor_control_active(not button_pressed)
+
+
+func _on_ApplyButton_pressed():
+ var stretch = _stretch_checkbox.button_pressed
+ var res = _resolutions[_resolution_dropdown.get_selected_id()]
+ var dir = _anchor_dirs[_selected_anchor]
+ _apply(res, stretch, Vector2(dir[0], dir[1]))
+ hide()
+
+
+func _on_CancelButton_pressed():
+ hide()
+
+
+func _apply(p_resolution: int, p_stretch: bool, p_anchor: Vector2):
+ if _terrain == null:
+ _logger.error("Cannot apply resize, terrain is not set")
+ return
+
+ var data = _terrain.get_data()
+ if data == null:
+ _logger.error("Cannot apply resize, terrain has no data")
+ return
+
+ data.resize(p_resolution, p_stretch, p_anchor)
+ data.notify_full_change()
+ permanent_change_performed.emit("Resize terrain")
diff --git a/game/addons/zylann.hterrain/tools/resize_dialog/resize_dialog.tscn b/game/addons/zylann.hterrain/tools/resize_dialog/resize_dialog.tscn
new file mode 100644
index 0000000..c0678ae
--- /dev/null
+++ b/game/addons/zylann.hterrain/tools/resize_dialog/resize_dialog.tscn
@@ -0,0 +1,159 @@
+[gd_scene load_steps=7 format=3 uid="uid://gt402qqhab7j"]
+
+[ext_resource type="Script" path="res://addons/zylann.hterrain/tools/resize_dialog/resize_dialog.gd" id="1"]
+[ext_resource type="Texture2D" uid="uid://b4ya0po3a4nqa" path="res://addons/zylann.hterrain/tools/icons/icon_heightmap_unmask.svg" id="2"]
+[ext_resource type="Texture2D" uid="uid://d3vie0tj3ry6k" path="res://addons/zylann.hterrain/tools/icons/icon_long_arrow_right.svg" id="3"]
+[ext_resource type="Texture2D" uid="uid://b6l5dys0awbwd" path="res://addons/zylann.hterrain/tools/icons/icon_long_arrow_down.svg" id="4"]
+[ext_resource type="Texture2D" uid="uid://bdkcgtv1r5j31" path="res://addons/zylann.hterrain/tools/icons/icon_small_circle.svg" id="5"]
+[ext_resource type="PackedScene" path="res://addons/zylann.hterrain/tools/util/dialog_fitter.tscn" id="6"]
+
+[node name="ResizeDialog" type="AcceptDialog"]
+title = "Resize terrain"
+size = Vector2i(300, 201)
+min_size = Vector2i(300, 200)
+script = ExtResource("1")
+
+[node name="VBoxContainer" type="VBoxContainer" parent="."]
+anchors_preset = 15
+anchor_right = 1.0
+anchor_bottom = 1.0
+offset_left = 8.0
+offset_top = 8.0
+offset_right = -8.0
+offset_bottom = -18.0
+
+[node name="GridContainer" type="GridContainer" parent="VBoxContainer"]
+layout_mode = 2
+columns = 2
+
+[node name="Label" type="Label" parent="VBoxContainer/GridContainer"]
+layout_mode = 2
+text = "Resolution"
+
+[node name="ResolutionDropdown" type="OptionButton" parent="VBoxContainer/GridContainer"]
+layout_mode = 2
+size_flags_horizontal = 3
+toggle_mode = false
+
+[node name="Label3" type="Label" parent="VBoxContainer/GridContainer"]
+layout_mode = 2
+text = "Stretch"
+
+[node name="StretchCheckBox" type="CheckBox" parent="VBoxContainer/GridContainer"]
+layout_mode = 2
+
+[node name="Label2" type="Label" parent="VBoxContainer/GridContainer"]
+layout_mode = 2
+text = "Direction"
+
+[node name="HBoxContainer" type="HBoxContainer" parent="VBoxContainer/GridContainer"]
+layout_mode = 2
+
+[node name="AnchorControl" type="GridContainer" parent="VBoxContainer/GridContainer/HBoxContainer"]
+layout_mode = 2
+columns = 3
+
+[node name="TopLeftButton" type="Button" parent="VBoxContainer/GridContainer/HBoxContainer/AnchorControl"]
+layout_mode = 2
+icon = ExtResource("2")
+
+[node name="TopButton" type="Button" parent="VBoxContainer/GridContainer/HBoxContainer/AnchorControl"]
+layout_mode = 2
+icon = ExtResource("2")
+
+[node name="TopRightButton" type="Button" parent="VBoxContainer/GridContainer/HBoxContainer/AnchorControl"]
+layout_mode = 2
+icon = ExtResource("2")
+
+[node name="LeftButton" type="Button" parent="VBoxContainer/GridContainer/HBoxContainer/AnchorControl"]
+layout_mode = 2
+icon = ExtResource("2")
+
+[node name="CenterButton" type="Button" parent="VBoxContainer/GridContainer/HBoxContainer/AnchorControl"]
+layout_mode = 2
+icon = ExtResource("2")
+
+[node name="RightButton" type="Button" parent="VBoxContainer/GridContainer/HBoxContainer/AnchorControl"]
+layout_mode = 2
+icon = ExtResource("2")
+
+[node name="ButtomLeftButton" type="Button" parent="VBoxContainer/GridContainer/HBoxContainer/AnchorControl"]
+layout_mode = 2
+icon = ExtResource("2")
+
+[node name="ButtomButton" type="Button" parent="VBoxContainer/GridContainer/HBoxContainer/AnchorControl"]
+layout_mode = 2
+icon = ExtResource("2")
+
+[node name="BottomRightButton" type="Button" parent="VBoxContainer/GridContainer/HBoxContainer/AnchorControl"]
+layout_mode = 2
+icon = ExtResource("2")
+
+[node name="Reference" type="Control" parent="VBoxContainer/GridContainer/HBoxContainer"]
+custom_minimum_size = Vector2(100, 0)
+layout_mode = 2
+
+[node name="XArrow" type="TextureRect" parent="VBoxContainer/GridContainer/HBoxContainer/Reference"]
+modulate = Color(1, 0.292969, 0.292969, 1)
+layout_mode = 0
+anchor_right = 1.0
+offset_left = 9.0
+offset_bottom = 16.0
+texture = ExtResource("3")
+
+[node name="ZArrow" type="TextureRect" parent="VBoxContainer/GridContainer/HBoxContainer/Reference"]
+modulate = Color(0.292969, 0.602295, 1, 1)
+layout_mode = 0
+anchor_bottom = 1.0
+offset_top = 10.0
+offset_right = 16.0
+texture = ExtResource("4")
+
+[node name="ZLabel" type="Label" parent="VBoxContainer/GridContainer/HBoxContainer/Reference"]
+layout_mode = 0
+offset_left = 14.0
+offset_top = 54.0
+offset_right = 22.0
+offset_bottom = 68.0
+text = "Z"
+
+[node name="XLabel" type="Label" parent="VBoxContainer/GridContainer/HBoxContainer/Reference"]
+layout_mode = 0
+offset_left = 52.0
+offset_top = 14.0
+offset_right = 60.0
+offset_bottom = 28.0
+text = "X"
+
+[node name="Origin" type="TextureRect" parent="VBoxContainer/GridContainer/HBoxContainer/Reference"]
+layout_mode = 0
+offset_left = 3.0
+offset_top = 4.0
+offset_right = 11.0
+offset_bottom = 12.0
+texture = ExtResource("5")
+
+[node name="HBoxContainer" type="HBoxContainer" parent="VBoxContainer"]
+layout_mode = 2
+alignment = 1
+
+[node name="ApplyButton" type="Button" parent="VBoxContainer/HBoxContainer"]
+layout_mode = 2
+text = "Apply (no undo)"
+
+[node name="CancelButton" type="Button" parent="VBoxContainer/HBoxContainer"]
+layout_mode = 2
+text = "Cancel"
+
+[node name="DialogFitter" parent="." instance=ExtResource("6")]
+layout_mode = 3
+anchors_preset = 0
+offset_left = 8.0
+offset_top = 8.0
+offset_right = 292.0
+offset_bottom = 183.0
+
+[connection signal="item_selected" from="VBoxContainer/GridContainer/ResolutionDropdown" to="." method="_on_ResolutionDropdown_item_selected"]
+[connection signal="toggled" from="VBoxContainer/GridContainer/StretchCheckBox" to="." method="_on_StretchCheckBox_toggled"]
+[connection signal="pressed" from="VBoxContainer/HBoxContainer/ApplyButton" to="." method="_on_ApplyButton_pressed"]
+[connection signal="pressed" from="VBoxContainer/HBoxContainer/CancelButton" to="." method="_on_CancelButton_pressed"]
diff --git a/game/addons/zylann.hterrain/tools/terrain_preview.gd b/game/addons/zylann.hterrain/tools/terrain_preview.gd
new file mode 100644
index 0000000..1c64b6d
--- /dev/null
+++ b/game/addons/zylann.hterrain/tools/terrain_preview.gd
@@ -0,0 +1,143 @@
+@tool
+extends SubViewportContainer
+
+const PREVIEW_MESH_LOD = 2
+
+const HTerrainMesher = preload("../hterrain_mesher.gd")
+const HT_Util = preload("../util/util.gd")
+
+signal dragged(relative, button_mask)
+
+@onready var _viewport : SubViewport = $Viewport
+@onready var _mesh_instance : MeshInstance3D = $Viewport/MeshInstance
+@onready var _camera : Camera3D = $Viewport/Camera
+@onready var _light : DirectionalLight3D = $Viewport/DirectionalLight
+
+# Use the simplest shader
+var _shader : Shader = load("res://addons/zylann.hterrain/shaders/simple4_lite.gdshader")
+var _yaw := 0.0
+var _pitch := -PI / 6.0
+var _distance := 0.0
+var _default_distance := 0.0
+var _sea_outline : MeshInstance3D = null
+var _sea_plane : MeshInstance3D = null
+var _mesh_resolution := 0
+
+
+func _ready():
+ if _sea_outline == null:
+ var mesh := HT_Util.create_wirecube_mesh()
+ var mat2 := StandardMaterial3D.new()
+ mat2.shading_mode = BaseMaterial3D.SHADING_MODE_UNSHADED
+ mat2.albedo_color = Color(0, 0.5, 1)
+ mesh.surface_set_material(0, mat2)
+ _sea_outline = MeshInstance3D.new()
+ _sea_outline.mesh = mesh
+ _viewport.add_child(_sea_outline)
+
+ if _sea_plane == null:
+ var mesh := PlaneMesh.new()
+ mesh.size = Vector2(1, 1)
+ var mat2 := StandardMaterial3D.new()
+ mat2.shading_mode = BaseMaterial3D.SHADING_MODE_UNSHADED
+ mat2.albedo_color = Color(0, 0.5, 1, 0.5)
+ mat2.transparency = BaseMaterial3D.TRANSPARENCY_ALPHA
+ mesh.material = mat2
+ _sea_plane = MeshInstance3D.new()
+ _sea_plane.mesh = mesh
+ _sea_plane.hide()
+ _viewport.add_child(_sea_plane)
+
+
+func setup(heights_texture: Texture2D, normals_texture: Texture2D):
+ var terrain_size := heights_texture.get_width()
+ var mesh_resolution := terrain_size / PREVIEW_MESH_LOD
+
+ if _mesh_resolution != mesh_resolution or not (_mesh_instance.mesh is ArrayMesh):
+ _mesh_resolution = mesh_resolution
+ var mesh := HTerrainMesher.make_flat_chunk(
+ _mesh_resolution, _mesh_resolution, PREVIEW_MESH_LOD, 0)
+ _mesh_instance.mesh = mesh
+ _default_distance = _mesh_instance.get_aabb().size.x
+ _distance = _default_distance
+ #_mesh_instance.translation -= 0.5 * Vector3(terrain_size, 0, terrain_size)
+ _update_camera()
+
+ var mat : ShaderMaterial = _mesh_instance.mesh.surface_get_material(0)
+
+ if mat == null:
+ mat = ShaderMaterial.new()
+ mat.shader = _shader
+ _mesh_instance.mesh.surface_set_material(0, mat)
+
+ mat.set_shader_parameter("u_terrain_heightmap", heights_texture)
+ mat.set_shader_parameter("u_terrain_normalmap", normals_texture)
+ mat.set_shader_parameter("u_terrain_inverse_transform", Transform3D())
+ mat.set_shader_parameter("u_terrain_normal_basis", Basis())
+
+ var aabb := _mesh_instance.get_aabb()
+ _sea_outline.scale = aabb.size
+
+ aabb = _mesh_instance.get_aabb()
+ _sea_plane.scale = Vector3(aabb.size.x, 1, aabb.size.z)
+ _sea_plane.position = Vector3(aabb.size.x, 0, aabb.size.z) / 2.0
+
+
+func set_sea_visible(visible: bool):
+ _sea_plane.visible = visible
+
+
+func set_shadows_enabled(enabled: bool):
+ _light.shadow_enabled = enabled
+
+
+func _update_camera():
+ var aabb := _mesh_instance.get_aabb()
+ var target := aabb.position + 0.5 * aabb.size
+ var trans := Transform3D()
+ trans.basis = Basis(Quaternion(Vector3(0, 1, 0), _yaw) * Quaternion(Vector3(1, 0, 0), _pitch))
+ var back := trans.basis.z
+ trans.origin = target + back * _distance
+ _camera.transform = trans
+
+
+func cleanup():
+ if _mesh_instance != null:
+ var mat : ShaderMaterial = _mesh_instance.mesh.surface_get_material(0)
+ assert(mat != null)
+ mat.set_shader_parameter("u_terrain_heightmap", null)
+ mat.set_shader_parameter("u_terrain_normalmap", null)
+
+
+func _gui_input(event: InputEvent):
+ if HT_Util.is_in_edited_scene(self):
+ return
+
+ if event is InputEventMouseMotion:
+ if event.button_mask & MOUSE_BUTTON_MASK_MIDDLE:
+ var d : Vector2 = 0.01 * event.relative
+ _yaw -= d.x
+ _pitch -= d.y
+ _update_camera()
+ else:
+ var rel : Vector2 = 0.01 * event.relative
+ # Align dragging to view rotation
+ rel = -rel.rotated(-_yaw)
+ dragged.emit(rel, event.button_mask)
+
+ elif event is InputEventMouseButton:
+ if event.pressed:
+
+ var factor := 1.2
+ var max_factor := 10.0
+ var min_distance := _default_distance / max_factor
+ var max_distance := _default_distance
+
+ # Zoom in/out
+ if event.button_index == MOUSE_BUTTON_WHEEL_DOWN:
+ _distance = clampf(_distance * factor, min_distance, max_distance)
+ _update_camera()
+
+ elif event.button_index == MOUSE_BUTTON_WHEEL_UP:
+ _distance = clampf(_distance / factor, min_distance, max_distance)
+ _update_camera()
diff --git a/game/addons/zylann.hterrain/tools/terrain_preview.tscn b/game/addons/zylann.hterrain/tools/terrain_preview.tscn
new file mode 100644
index 0000000..0b87a16
--- /dev/null
+++ b/game/addons/zylann.hterrain/tools/terrain_preview.tscn
@@ -0,0 +1,38 @@
+[gd_scene load_steps=3 format=3 uid="uid://bue2flijnxa3p"]
+
+[ext_resource type="Script" path="res://addons/zylann.hterrain/tools/terrain_preview.gd" id="1"]
+
+[sub_resource type="PlaneMesh" id="3"]
+size = Vector2(256, 256)
+
+[node name="TerrainPreview" type="SubViewportContainer"]
+anchors_preset = 15
+anchor_right = 1.0
+anchor_bottom = 1.0
+mouse_filter = 0
+stretch = true
+script = ExtResource("1")
+
+[node name="Viewport" type="SubViewport" parent="."]
+own_world_3d = true
+handle_input_locally = false
+size = Vector2i(1152, 648)
+render_target_update_mode = 4
+
+[node name="DirectionalLight" type="DirectionalLight3D" parent="Viewport"]
+transform = Transform3D(-0.901211, 0.315056, -0.297588, 0, 0.686666, 0.726973, 0.433381, 0.655156, -0.618831, 0, 0, 0)
+light_bake_mode = 1
+shadow_enabled = true
+shadow_bias = 0.5
+directional_shadow_mode = 1
+directional_shadow_max_distance = 1000.0
+
+[node name="MeshInstance" type="MeshInstance3D" parent="Viewport"]
+mesh = SubResource("3")
+
+[node name="Camera" type="Camera3D" parent="Viewport"]
+transform = Transform3D(-1, 3.31486e-08, -8.08945e-08, 0, 0.925325, 0.379176, 8.74228e-08, 0.379176, -0.925325, -2.25312e-05, 145.456, -348.286)
+current = true
+fov = 70.0
+near = 1.0
+far = 1000.0
diff --git a/game/addons/zylann.hterrain/tools/texture_editor/display_alpha.gdshader b/game/addons/zylann.hterrain/tools/texture_editor/display_alpha.gdshader
new file mode 100644
index 0000000..7ac1b98
--- /dev/null
+++ b/game/addons/zylann.hterrain/tools/texture_editor/display_alpha.gdshader
@@ -0,0 +1,6 @@
+shader_type canvas_item;
+
+void fragment() {
+ float a = texture(TEXTURE, UV).a;
+ COLOR = vec4(a, a, a, 1.0);
+}
diff --git a/game/addons/zylann.hterrain/tools/texture_editor/display_alpha_material.tres b/game/addons/zylann.hterrain/tools/texture_editor/display_alpha_material.tres
new file mode 100644
index 0000000..34ef863
--- /dev/null
+++ b/game/addons/zylann.hterrain/tools/texture_editor/display_alpha_material.tres
@@ -0,0 +1,9 @@
+[gd_resource type="ShaderMaterial" load_steps=2 format=2]
+
+[ext_resource path="res://addons/zylann.hterrain/tools/texture_editor/display_alpha.gdshader" type="Shader" id=1]
+
+[resource]
+
+render_priority = 0
+shader = ExtResource( 1 )
+
diff --git a/game/addons/zylann.hterrain/tools/texture_editor/display_alpha_slice.gdshader b/game/addons/zylann.hterrain/tools/texture_editor/display_alpha_slice.gdshader
new file mode 100644
index 0000000..384e1a2
--- /dev/null
+++ b/game/addons/zylann.hterrain/tools/texture_editor/display_alpha_slice.gdshader
@@ -0,0 +1,9 @@
+shader_type canvas_item;
+
+uniform sampler2DArray u_texture_array;
+uniform float u_index;
+
+void fragment() {
+ vec4 col = texture(u_texture_array, vec3(UV.x, UV.y, u_index));
+ COLOR = vec4(col.a, col.a, col.a, 1.0);
+}
diff --git a/game/addons/zylann.hterrain/tools/texture_editor/display_color.gdshader b/game/addons/zylann.hterrain/tools/texture_editor/display_color.gdshader
new file mode 100644
index 0000000..dcc1fda
--- /dev/null
+++ b/game/addons/zylann.hterrain/tools/texture_editor/display_color.gdshader
@@ -0,0 +1,7 @@
+shader_type canvas_item;
+
+void fragment() {
+ // TODO Have an option to "undo" sRGB, for funzies?
+ vec4 col = texture(TEXTURE, UV);
+ COLOR = vec4(col.rgb, 1.0);
+}
diff --git a/game/addons/zylann.hterrain/tools/texture_editor/display_color_material.tres b/game/addons/zylann.hterrain/tools/texture_editor/display_color_material.tres
new file mode 100644
index 0000000..094400f
--- /dev/null
+++ b/game/addons/zylann.hterrain/tools/texture_editor/display_color_material.tres
@@ -0,0 +1,6 @@
+[gd_resource type="ShaderMaterial" load_steps=2 format=2]
+
+[ext_resource path="res://addons/zylann.hterrain/tools/texture_editor/display_color.gdshader" type="Shader" id=1]
+
+[resource]
+shader = ExtResource( 1 )
diff --git a/game/addons/zylann.hterrain/tools/texture_editor/display_color_slice.gdshader b/game/addons/zylann.hterrain/tools/texture_editor/display_color_slice.gdshader
new file mode 100644
index 0000000..d2e4f5c
--- /dev/null
+++ b/game/addons/zylann.hterrain/tools/texture_editor/display_color_slice.gdshader
@@ -0,0 +1,9 @@
+shader_type canvas_item;
+
+uniform sampler2DArray u_texture_array;
+uniform float u_index;
+
+void fragment() {
+ vec4 col = texture(u_texture_array, vec3(UV.x, UV.y, u_index));
+ COLOR = vec4(col.rgb, 1.0);
+}
diff --git a/game/addons/zylann.hterrain/tools/texture_editor/display_normal.gdshader b/game/addons/zylann.hterrain/tools/texture_editor/display_normal.gdshader
new file mode 100644
index 0000000..665af53
--- /dev/null
+++ b/game/addons/zylann.hterrain/tools/texture_editor/display_normal.gdshader
@@ -0,0 +1,28 @@
+shader_type canvas_item;
+
+uniform float u_strength = 1.0;
+uniform bool u_flip_y = false;
+
+vec3 unpack_normal(vec4 rgba) {
+ vec3 n = rgba.xzy * 2.0 - vec3(1.0);
+ // Had to negate Z because it comes from Y in the normal map,
+ // and OpenGL-style normal maps are Y-up.
+ n.z *= -1.0;
+ return n;
+}
+
+vec3 pack_normal(vec3 n) {
+ n.z *= -1.0;
+ return 0.5 * (n.xzy + vec3(1.0));
+}
+
+void fragment() {
+ vec4 col = texture(TEXTURE, UV);
+ vec3 n = unpack_normal(col);
+ n = normalize(mix(n, vec3(-n.x, n.y, -n.z), 0.5 - 0.5 * u_strength));
+ if (u_flip_y) {
+ n.z = -n.z;
+ }
+ col.rgb = pack_normal(n);
+ COLOR = vec4(col.rgb, 1.0);
+}
diff --git a/game/addons/zylann.hterrain/tools/texture_editor/flow_container.gd b/game/addons/zylann.hterrain/tools/texture_editor/flow_container.gd
new file mode 100644
index 0000000..4425134
--- /dev/null
+++ b/game/addons/zylann.hterrain/tools/texture_editor/flow_container.gd
@@ -0,0 +1,41 @@
+@tool
+extends Container
+
+const SEPARATION = 2
+
+
+func _notification(what: int):
+ if what == NOTIFICATION_SORT_CHILDREN:
+ _sort_children2()
+
+
+# TODO Function with ugly name to workaround a Godot 3.1 issue
+# See https://github.com/godotengine/godot/pull/38396
+func _sort_children2():
+ var max_x := size.x - SEPARATION
+ var pos := Vector2(SEPARATION, SEPARATION)
+ var line_height := 0
+
+ for i in get_child_count():
+ var child_node = get_child(i)
+ if not child_node is Control:
+ continue
+ var child := child_node as Control
+
+ var rect := child.get_rect()
+
+ if rect.size.y > line_height:
+ line_height = rect.size.y
+
+ if pos.x + rect.size.x > max_x:
+ pos.x = SEPARATION
+ pos.y += line_height + SEPARATION
+ line_height = rect.size.y
+
+ rect.position = pos
+ fit_child_in_rect(child, rect)
+
+ pos.x += rect.size.x + SEPARATION
+
+ custom_minimum_size.y = pos.y + line_height
+
diff --git a/game/addons/zylann.hterrain/tools/texture_editor/set_editor/source_file_item_editor.gd b/game/addons/zylann.hterrain/tools/texture_editor/set_editor/source_file_item_editor.gd
new file mode 100644
index 0000000..834accb
--- /dev/null
+++ b/game/addons/zylann.hterrain/tools/texture_editor/set_editor/source_file_item_editor.gd
@@ -0,0 +1,60 @@
+@tool
+extends Control
+
+# TODO Can't preload because it causes the plugin to fail loading if assets aren't imported
+#const HT_EmptyTexture = preload("../../icons/empty.png")
+const EMPTY_TEXTURE_PATH = "res://addons/zylann.hterrain/tools/icons/empty.png"
+
+signal load_pressed
+signal clear_pressed
+
+
+@onready var _label : Label = $Label
+@onready var _texture_rect : TextureRect = $TextureRect
+
+@onready var _buttons = [
+ $LoadButton,
+ $ClearButton
+]
+
+var _material : Material
+var _is_empty := true
+
+
+func set_label(text: String):
+ _label.text = text
+
+
+func set_texture(tex: Texture):
+ if tex == null:
+ _texture_rect.texture = load(EMPTY_TEXTURE_PATH)
+ _texture_rect.material = null
+ _is_empty = true
+ else:
+ _texture_rect.texture = tex
+ _texture_rect.material = _material
+ _is_empty = false
+
+
+func set_texture_tooltip(msg: String):
+ _texture_rect.tooltip_text = msg
+
+
+func _on_LoadButton_pressed():
+ load_pressed.emit()
+
+
+func _on_ClearButton_pressed():
+ clear_pressed.emit()
+
+
+func set_material(mat: Material):
+ _material = mat
+ if not _is_empty:
+ _texture_rect.material = _material
+
+
+func set_enabled(enabled: bool):
+ for b in _buttons:
+ b.disabled = not enabled
+
diff --git a/game/addons/zylann.hterrain/tools/texture_editor/set_editor/source_file_item_editor.tscn b/game/addons/zylann.hterrain/tools/texture_editor/set_editor/source_file_item_editor.tscn
new file mode 100644
index 0000000..9907e0c
--- /dev/null
+++ b/game/addons/zylann.hterrain/tools/texture_editor/set_editor/source_file_item_editor.tscn
@@ -0,0 +1,29 @@
+[gd_scene load_steps=2 format=3 uid="uid://dqgaomu3tr1ym"]
+
+[ext_resource type="Script" path="res://addons/zylann.hterrain/tools/texture_editor/set_editor/source_file_item_editor.gd" id="2"]
+
+[node name="SourceFileItem" type="VBoxContainer"]
+offset_right = 128.0
+offset_bottom = 194.0
+script = ExtResource("2")
+
+[node name="Label" type="Label" parent="."]
+layout_mode = 2
+text = "Albedo"
+
+[node name="TextureRect" type="TextureRect" parent="."]
+custom_minimum_size = Vector2(128, 128)
+layout_mode = 2
+expand_mode = 1
+stretch_mode = 1
+
+[node name="LoadButton" type="Button" parent="."]
+layout_mode = 2
+text = "Load..."
+
+[node name="ClearButton" type="Button" parent="."]
+layout_mode = 2
+text = "Clear"
+
+[connection signal="pressed" from="LoadButton" to="." method="_on_LoadButton_pressed"]
+[connection signal="pressed" from="ClearButton" to="." method="_on_ClearButton_pressed"]
diff --git a/game/addons/zylann.hterrain/tools/texture_editor/set_editor/texture_set_editor.gd b/game/addons/zylann.hterrain/tools/texture_editor/set_editor/texture_set_editor.gd
new file mode 100644
index 0000000..9d11bef
--- /dev/null
+++ b/game/addons/zylann.hterrain/tools/texture_editor/set_editor/texture_set_editor.gd
@@ -0,0 +1,529 @@
+@tool
+extends AcceptDialog
+
+const HTerrainTextureSet = preload("../../../hterrain_texture_set.gd")
+const HT_EditorUtil = preload("../../util/editor_util.gd")
+const HT_Util = preload("../../../util/util.gd")
+const HT_Logger = preload("../../../util/logger.gd")
+
+const HT_ColorShader = preload("../display_color.gdshader")
+const HT_ColorSliceShader = preload("../display_color_slice.gdshader")
+const HT_AlphaShader = preload("../display_alpha.gdshader")
+const HT_AlphaSliceShader = preload("../display_alpha_slice.gdshader")
+# TODO Can't preload because it causes the plugin to fail loading if assets aren't imported
+#const HT_EmptyTexture = preload("../../icons/empty.png")
+const EMPTY_TEXTURE_PATH = "res://addons/zylann.hterrain/tools/icons/empty.png"
+
+signal import_selected
+
+@onready var _slots_list : ItemList = $VB/HS/VB/SlotsList
+@onready var _albedo_preview : TextureRect = $VB/HS/VB2/GC/AlbedoPreview
+@onready var _bump_preview : TextureRect = $VB/HS/VB2/GC/BumpPreview
+@onready var _normal_preview : TextureRect = $VB/HS/VB2/GC/NormalPreview
+@onready var _roughness_preview : TextureRect = $VB/HS/VB2/GC/RoughnessPreview
+@onready var _load_albedo_button : Button = $VB/HS/VB2/GC/LoadAlbedo
+@onready var _load_normal_button : Button = $VB/HS/VB2/GC/LoadNormal
+@onready var _clear_albedo_button : Button = $VB/HS/VB2/GC/ClearAlbedo
+@onready var _clear_normal_button : Button = $VB/HS/VB2/GC/ClearNormal
+@onready var _mode_selector : OptionButton = $VB/HS/VB2/GC2/ModeSelector
+@onready var _add_slot_button : Button = $VB/HS/VB/HB/AddSlot
+@onready var _remove_slot_button : Button = $VB/HS/VB/HB/RemoveSlot
+
+var _texture_set : HTerrainTextureSet
+var _undo_redo_manager : EditorUndoRedoManager
+
+var _mode_confirmation_dialog : ConfirmationDialog
+var _delete_slot_confirmation_dialog : ConfirmationDialog
+var _load_texture_dialog : ConfirmationDialog
+var _load_texture_array_dialog : ConfirmationDialog
+var _load_texture_type := -1
+
+var _logger = HT_Logger.get_for(self)
+
+
+func _init():
+ get_ok_button().hide()
+
+
+func _ready():
+ if HT_Util.is_in_edited_scene(self):
+ return
+ for id in HTerrainTextureSet.MODE_COUNT:
+ var mode_name = HTerrainTextureSet.get_import_mode_name(id)
+ _mode_selector.add_item(mode_name, id)
+
+
+func setup_dialogs(parent: Node):
+ var d = HT_EditorUtil.create_open_texture_dialog()
+ d.file_selected.connect(_on_LoadTextureDialog_file_selected)
+ _load_texture_dialog = d
+ add_child(d)
+
+ d = HT_EditorUtil.create_open_texture_array_dialog()
+ d.file_selected.connect(_on_LoadTextureArrayDialog_file_selected)
+ _load_texture_array_dialog = d
+ add_child(d)
+
+ d = ConfirmationDialog.new()
+ d.confirmed.connect(_on_ModeConfirmationDialog_confirmed)
+ # This is ridiculous.
+ # See https://github.com/godotengine/godot/issues/17460
+# d.connect("modal_closed", self, "_on_ModeConfirmationDialog_cancelled")
+# d.get_close_button().connect("pressed", self, "_on_ModeConfirmationDialog_cancelled")
+# d.get_cancel().connect("pressed", self, "_on_ModeConfirmationDialog_cancelled")
+ _mode_confirmation_dialog = d
+ add_child(d)
+
+
+func _notification(what: int):
+ if HT_Util.is_in_edited_scene(self):
+ return
+
+ if what == NOTIFICATION_EXIT_TREE:
+ # Have to check for null in all of them,
+ # because otherwise it breaks in the scene editor...
+ if _load_texture_dialog != null:
+ _load_texture_dialog.queue_free()
+ if _load_texture_array_dialog != null:
+ _load_texture_array_dialog.queue_free()
+
+ if what == NOTIFICATION_VISIBILITY_CHANGED:
+ if not visible:
+ # Cleanup referenced resources
+ set_texture_set(null)
+
+
+func set_undo_redo(ur: EditorUndoRedoManager):
+ _undo_redo_manager = ur
+
+
+func set_texture_set(texture_set: HTerrainTextureSet):
+ if _texture_set == texture_set:
+ return
+
+ if _texture_set != null:
+ _texture_set.changed.disconnect(_on_texture_set_changed)
+
+ _texture_set = texture_set
+
+ if _texture_set != null:
+ _texture_set.changed.connect(_on_texture_set_changed)
+ _update_ui_from_data()
+
+
+func _on_texture_set_changed():
+ _update_ui_from_data()
+
+
+func _update_ui_from_data():
+ var prev_selected_items = _slots_list.get_selected_items()
+
+ _slots_list.clear()
+
+ var slots_count := _texture_set.get_slots_count()
+ for slot_index in slots_count:
+ _slots_list.add_item("Texture {0}".format([slot_index]))
+
+ _set_selected_id(_mode_selector, _texture_set.get_mode())
+
+ if _slots_list.get_item_count() > 0:
+ if len(prev_selected_items) > 0:
+ var i : int = prev_selected_items[0]
+ if i >= _slots_list.get_item_count():
+ i = _slots_list.get_item_count() - 1
+ _select_slot(i)
+ else:
+ _select_slot(0)
+ else:
+ _clear_previews()
+
+ var max_slots := HTerrainTextureSet.get_max_slots_for_mode(_texture_set.get_mode())
+ _add_slot_button.disabled = slots_count >= max_slots
+ _remove_slot_button.disabled = slots_count == 0
+
+ var buttons := [
+ _load_albedo_button,
+ _load_normal_button,
+ _clear_albedo_button,
+ _clear_normal_button
+ ]
+
+ if _texture_set.get_mode() == HTerrainTextureSet.MODE_TEXTURES:
+ _add_slot_button.visible = true
+ _remove_slot_button.visible = true
+ _load_albedo_button.text = "Load..."
+ _load_normal_button.text = "Load..."
+
+ for b in buttons:
+ b.disabled = (slots_count == 0)
+
+ else:
+ _add_slot_button.visible = false
+ _remove_slot_button.visible = false
+ _load_albedo_button.text = "Load Array..."
+ _load_normal_button.text = "Load Array..."
+
+ for b in buttons:
+ b.disabled = false
+
+
+static func _set_selected_id(ob: OptionButton, id: int):
+ for i in ob.get_item_count():
+ if ob.get_item_id(i) == id:
+ ob.selected = i
+ break
+
+
+func select_slot(slot_index: int):
+ var count = _texture_set.get_slots_count()
+ if count > 0:
+ if slot_index >= count:
+ slot_index = count - 1
+ _select_slot(slot_index)
+
+
+func _clear_previews():
+ var empty_texture : Texture2D = load(EMPTY_TEXTURE_PATH)
+ if empty_texture == null:
+ _logger.error(str("Failed to load empty texture ", EMPTY_TEXTURE_PATH))
+
+ _albedo_preview.texture = empty_texture
+ _bump_preview.texture = empty_texture
+ _normal_preview.texture = empty_texture
+ _roughness_preview.texture = empty_texture
+
+ _albedo_preview.tooltip_text = _get_resource_path_or_empty(null)
+ _bump_preview.tooltip_text = _get_resource_path_or_empty(null)
+ _normal_preview.tooltip_text = _get_resource_path_or_empty(null)
+ _roughness_preview.tooltip_text = _get_resource_path_or_empty(null)
+
+
+func _select_slot(slot_index: int):
+ assert(slot_index >= 0)
+ assert(slot_index < _texture_set.get_slots_count())
+
+ var empty_texture : Texture2D = load(EMPTY_TEXTURE_PATH)
+ if empty_texture == null:
+ _logger.error(str("Failed to load empty texture ", EMPTY_TEXTURE_PATH))
+
+ if _texture_set.get_mode() == HTerrainTextureSet.MODE_TEXTURES:
+ var albedo_tex := \
+ _texture_set.get_texture(slot_index, HTerrainTextureSet.TYPE_ALBEDO_BUMP)
+ var normal_tex := \
+ _texture_set.get_texture(slot_index, HTerrainTextureSet.TYPE_NORMAL_ROUGHNESS)
+
+ _albedo_preview.texture = albedo_tex if albedo_tex != null else empty_texture
+ _bump_preview.texture = albedo_tex if albedo_tex != null else empty_texture
+ _normal_preview.texture = normal_tex if normal_tex != null else empty_texture
+ _roughness_preview.texture = normal_tex if normal_tex != null else empty_texture
+
+ _albedo_preview.tooltip_text = _get_resource_path_or_empty(albedo_tex)
+ _bump_preview.tooltip_text = _get_resource_path_or_empty(albedo_tex)
+ _normal_preview.tooltip_text = _get_resource_path_or_empty(normal_tex)
+ _roughness_preview.tooltip_text = _get_resource_path_or_empty(normal_tex)
+
+ _albedo_preview.material.shader = HT_ColorShader
+ _bump_preview.material.shader = HT_AlphaShader
+ _normal_preview.material.shader = HT_ColorShader
+ _roughness_preview.material.shader = HT_AlphaShader
+
+ _albedo_preview.material.set_shader_parameter("u_texture_array", null)
+ _bump_preview.material.set_shader_parameter("u_texture_array", null)
+ _normal_preview.material.set_shader_parameter("u_texture_array", null)
+ _roughness_preview.material.set_shader_parameter("u_texture_array", null)
+
+ else:
+ var albedo_tex := _texture_set.get_texture_array(HTerrainTextureSet.TYPE_ALBEDO_BUMP)
+ var normal_tex := _texture_set.get_texture_array(HTerrainTextureSet.TYPE_NORMAL_ROUGHNESS)
+
+ _albedo_preview.texture = empty_texture
+ _bump_preview.texture = empty_texture
+ _normal_preview.texture = empty_texture
+ _roughness_preview.texture = empty_texture
+
+ _albedo_preview.tooltip_text = _get_resource_path_or_empty(albedo_tex)
+ _bump_preview.tooltip_text = _get_resource_path_or_empty(albedo_tex)
+ _normal_preview.tooltip_text = _get_resource_path_or_empty(normal_tex)
+ _roughness_preview.tooltip_text = _get_resource_path_or_empty(normal_tex)
+
+ _albedo_preview.material.shader = HT_ColorSliceShader
+ _bump_preview.material.shader = HT_AlphaSliceShader
+ _normal_preview.material.shader = \
+ HT_ColorSliceShader if normal_tex != null else HT_ColorShader
+ _roughness_preview.material.shader = \
+ HT_AlphaSliceShader if normal_tex != null else HT_AlphaShader
+
+ _albedo_preview.material.set_shader_parameter("u_texture_array", albedo_tex)
+ _bump_preview.material.set_shader_parameter("u_texture_array", albedo_tex)
+ _normal_preview.material.set_shader_parameter("u_texture_array", normal_tex)
+ _roughness_preview.material.set_shader_parameter("u_texture_array", normal_tex)
+
+ _albedo_preview.material.set_shader_parameter("u_index", slot_index)
+ _bump_preview.material.set_shader_parameter("u_index", slot_index)
+ _normal_preview.material.set_shader_parameter("u_index", slot_index)
+ _roughness_preview.material.set_shader_parameter("u_index", slot_index)
+
+ _slots_list.select(slot_index)
+
+
+static func _get_resource_path_or_empty(res: Resource) -> String:
+ if res != null:
+ return res.resource_path
+ return "<empty>"
+
+
+func _on_ImportButton_pressed():
+ import_selected.emit()
+
+
+func _on_CloseButton_pressed():
+ hide()
+
+
+func _get_undo_redo_for_texture_set() -> UndoRedo:
+ return _undo_redo_manager.get_history_undo_redo(
+ _undo_redo_manager.get_object_history_id(_texture_set))
+
+
+func _on_AddSlot_pressed():
+ assert(_texture_set.get_mode() == HTerrainTextureSet.MODE_TEXTURES)
+ var slot_index = _texture_set.get_slots_count()
+ var ur := _get_undo_redo_for_texture_set()
+ ur.create_action("HTerrainTextureSet: add slot")
+ ur.add_do_method(_texture_set.insert_slot.bind(-1))
+ ur.add_undo_method(_texture_set.remove_slot.bind(slot_index))
+ ur.commit_action()
+
+
+func _on_RemoveSlot_pressed():
+ assert(_texture_set.get_mode() == HTerrainTextureSet.MODE_TEXTURES)
+
+ var slot_index = _slots_list.get_selected_items()[0]
+ var textures = []
+ for type in HTerrainTextureSet.TYPE_COUNT:
+ textures.append(_texture_set.get_texture(slot_index, type))
+
+ var ur := _get_undo_redo_for_texture_set()
+
+ ur.create_action("HTerrainTextureSet: remove slot")
+
+ ur.add_do_method(_texture_set.remove_slot.bind(slot_index))
+
+ ur.add_undo_method(_texture_set.insert_slot.bind(slot_index))
+ for type in len(textures):
+ var texture = textures[type]
+ # TODO This branch only exists because of a flaw in UndoRedo
+ # See https://github.com/godotengine/godot/issues/36895
+ if texture == null:
+ ur.add_undo_method(_texture_set.set_texture_null.bind(slot_index, type))
+ else:
+ ur.add_undo_method(_texture_set.set_texture.bind(slot_index, type, texture))
+
+ ur.commit_action()
+
+
+func _on_SlotsList_item_selected(index: int):
+ _select_slot(index)
+
+
+func _open_load_texture_dialog(type: int):
+ _load_texture_type = type
+ if _texture_set.get_mode() == HTerrainTextureSet.MODE_TEXTURES:
+ _load_texture_dialog.popup_centered_ratio()
+ else:
+ _load_texture_array_dialog.popup_centered_ratio()
+
+
+func _on_LoadAlbedo_pressed():
+ _open_load_texture_dialog(HTerrainTextureSet.TYPE_ALBEDO_BUMP)
+
+
+func _on_LoadNormal_pressed():
+ _open_load_texture_dialog(HTerrainTextureSet.TYPE_NORMAL_ROUGHNESS)
+
+
+func _set_texture_action(slot_index: int, texture: Texture, type: int):
+ var prev_texture = _texture_set.get_texture(slot_index, type)
+
+ var ur := _get_undo_redo_for_texture_set()
+
+ ur.create_action("HTerrainTextureSet: load texture")
+
+ # TODO This branch only exists because of a flaw in UndoRedo
+ # See https://github.com/godotengine/godot/issues/36895
+ if texture == null:
+ ur.add_do_method(_texture_set.set_texture_null.bind(slot_index, type))
+ else:
+ ur.add_do_method(_texture_set.set_texture.bind(slot_index, type, texture))
+ ur.add_do_method(self._select_slot.bind(slot_index))
+
+ # TODO This branch only exists because of a flaw in UndoRedo
+ # See https://github.com/godotengine/godot/issues/36895
+ if prev_texture == null:
+ ur.add_undo_method(_texture_set.set_texture_null.bind(slot_index, type))
+ else:
+ ur.add_undo_method(_texture_set.set_texture.bind(slot_index, type, prev_texture))
+ ur.add_undo_method(self._select_slot.bind(slot_index))
+
+ ur.commit_action()
+
+
+func _set_texture_array_action(slot_index: int, texture_array: TextureLayered, type: int):
+ var prev_texture_array = _texture_set.get_texture_array(type)
+
+ var ur := _get_undo_redo_for_texture_set()
+
+ ur.create_action("HTerrainTextureSet: load texture array")
+
+ # TODO This branch only exists because of a flaw in UndoRedo
+ # See https://github.com/godotengine/godot/issues/36895
+ if texture_array == null:
+ ur.add_do_method(_texture_set.set_texture_array_null.bind(type))
+ # Can't select a slot after this because there won't be any after the array is removed
+ else:
+ ur.add_do_method(_texture_set.set_texture_array.bind(type, texture_array))
+ ur.add_do_method(self._select_slot.bind(slot_index))
+
+ # TODO This branch only exists because of a flaw in UndoRedo
+ # See https://github.com/godotengine/godot/issues/36895
+ if prev_texture_array == null:
+ ur.add_undo_method(_texture_set.set_texture_array_null.bind(type))
+ # Can't select a slot after this because there won't be any after the array is removed
+ else:
+ ur.add_undo_method(_texture_set.set_texture_array.bind(type, prev_texture_array))
+ ur.add_undo_method(self._select_slot.bind(slot_index))
+
+ ur.commit_action()
+
+
+func _on_LoadTextureDialog_file_selected(fpath: String):
+ assert(_texture_set.get_mode() == HTerrainTextureSet.MODE_TEXTURES)
+ var texture = load(fpath)
+ assert(texture != null)
+ var slot_index : int = _slots_list.get_selected_items()[0]
+ _set_texture_action(slot_index, texture, _load_texture_type)
+
+
+func _on_LoadTextureArrayDialog_file_selected(fpath: String):
+ assert(_texture_set.get_mode() == HTerrainTextureSet.MODE_TEXTURE_ARRAYS)
+ var texture_array = load(fpath)
+ assert(texture_array != null)
+ # It's possible no slot exists at the moment,
+ # because there could be no texture array already set.
+ # The number of slots in the new array might also be different.
+ # So in this case we'll default to selecting the first slot.
+ var slot_index := 0
+ _set_texture_array_action(slot_index, texture_array, _load_texture_type)
+
+
+func _on_ClearAlbedo_pressed():
+ var slot_index : int = _slots_list.get_selected_items()[0]
+ if _texture_set.get_mode() == HTerrainTextureSet.MODE_TEXTURES:
+ _set_texture_action(slot_index, null, HTerrainTextureSet.TYPE_ALBEDO_BUMP)
+ else:
+ _set_texture_array_action(slot_index, null, HTerrainTextureSet.TYPE_ALBEDO_BUMP)
+
+
+func _on_ClearNormal_pressed():
+ var slot_index : int = _slots_list.get_selected_items()[0]
+ if _texture_set.get_mode() == HTerrainTextureSet.MODE_TEXTURES:
+ _set_texture_action(slot_index, null, HTerrainTextureSet.TYPE_NORMAL_ROUGHNESS)
+ else:
+ _set_texture_array_action(slot_index, null, HTerrainTextureSet.TYPE_NORMAL_ROUGHNESS)
+
+
+func _on_ModeSelector_item_selected(index: int):
+ var id = _mode_selector.get_selected_id()
+ if id == _texture_set.get_mode():
+ return
+
+ # Early-cancel the change in OptionButton, so we won't need to rely on
+ # the (inexistent) cancel signal from ConfirmationDialog
+ _set_selected_id(_mode_selector, _texture_set.get_mode())
+
+ if not _texture_set.has_any_textures():
+ _switch_mode_action()
+
+ else:
+ if _texture_set.get_mode() == HTerrainTextureSet.MODE_TEXTURES:
+ _mode_confirmation_dialog.window_title = "Switch to TextureArrays"
+ _mode_confirmation_dialog.dialog_text = \
+ "This will unload all textures currently setup. Do you want to continue?"
+ _mode_confirmation_dialog.popup_centered()
+
+ else:
+ _mode_confirmation_dialog.window_title = "Switch to Textures"
+ _mode_confirmation_dialog.dialog_text = \
+ "This will unload all textures currently setup. Do you want to continue?"
+ _mode_confirmation_dialog.popup_centered()
+
+
+func _on_ModeConfirmationDialog_confirmed():
+ _switch_mode_action()
+
+
+func _switch_mode_action():
+ var mode := _texture_set.get_mode()
+ var ur := _get_undo_redo_for_texture_set()
+
+ if mode == HTerrainTextureSet.MODE_TEXTURES:
+ ur.create_action("HTerrainTextureSet: switch to TextureArrays")
+ ur.add_do_method(_texture_set.set_mode.bind(HTerrainTextureSet.MODE_TEXTURE_ARRAYS))
+ backup_for_undo(_texture_set, ur)
+
+ else:
+ ur.create_action("HTerrainTextureSet: switch to Textures")
+ ur.add_do_method(_texture_set.set_mode.bind(HTerrainTextureSet.MODE_TEXTURES))
+ backup_for_undo(_texture_set, ur)
+
+ ur.commit_action()
+
+
+static func backup_for_undo(texture_set: HTerrainTextureSet, ur: UndoRedo):
+ var mode := texture_set.get_mode()
+
+ ur.add_undo_method(texture_set.clear)
+ ur.add_undo_method(texture_set.set_mode.bind(mode))
+
+ if mode == HTerrainTextureSet.MODE_TEXTURES:
+ # Backup slots
+ var slot_count := texture_set.get_slots_count()
+ var type_textures := []
+ for type in HTerrainTextureSet.TYPE_COUNT:
+ var textures := []
+ for slot_index in slot_count:
+ textures.append(texture_set.get_texture(slot_index, type))
+ type_textures.append(textures)
+
+ for type in len(type_textures):
+ var textures = type_textures[type]
+ for slot_index in len(textures):
+ ur.add_undo_method(texture_set.insert_slot.bind(slot_index))
+ var texture = textures[slot_index]
+ # TODO This branch only exists because of a flaw in UndoRedo
+ # See https://github.com/godotengine/godot/issues/36895
+ if texture == null:
+ ur.add_undo_method(texture_set.set_texture_null.bind(slot_index, type))
+ else:
+ ur.add_undo_method(texture_set.set_texture.bind(slot_index, type, texture))
+
+ else:
+ # Backup slots
+ var type_textures := []
+ for type in HTerrainTextureSet.TYPE_COUNT:
+ type_textures.append(texture_set.get_texture_array(type))
+
+ for type in len(type_textures):
+ var texture_array = type_textures[type]
+ # TODO This branch only exists because of a flaw in UndoRedo
+ # See https://github.com/godotengine/godot/issues/36895
+ if texture_array == null:
+ ur.add_undo_method(texture_set.set_texture_array_null.bind(type))
+ else:
+ ur.add_undo_method(texture_set.set_texture_array.bind(type, texture_array))
+
+
+#func _on_ModeConfirmationDialog_cancelled():
+# print("Cancelled")
+# _set_selected_id(_mode_selector, _texture_set.get_mode())
+
diff --git a/game/addons/zylann.hterrain/tools/texture_editor/set_editor/texture_set_editor.tscn b/game/addons/zylann.hterrain/tools/texture_editor/set_editor/texture_set_editor.tscn
new file mode 100644
index 0000000..f69c89a
--- /dev/null
+++ b/game/addons/zylann.hterrain/tools/texture_editor/set_editor/texture_set_editor.tscn
@@ -0,0 +1,194 @@
+[gd_scene load_steps=9 format=3 uid="uid://c0e7ifnoygvr6"]
+
+[ext_resource type="Script" path="res://addons/zylann.hterrain/tools/texture_editor/set_editor/texture_set_editor.gd" id="1"]
+[ext_resource type="Shader" path="res://addons/zylann.hterrain/tools/texture_editor/display_alpha.gdshader" id="2"]
+[ext_resource type="Shader" path="res://addons/zylann.hterrain/tools/texture_editor/display_color.gdshader" id="3"]
+[ext_resource type="PackedScene" path="res://addons/zylann.hterrain/tools/util/dialog_fitter.tscn" id="5"]
+
+[sub_resource type="ShaderMaterial" id="1"]
+shader = ExtResource("3")
+
+[sub_resource type="ShaderMaterial" id="2"]
+shader = ExtResource("2")
+
+[sub_resource type="ShaderMaterial" id="3"]
+shader = ExtResource("3")
+
+[sub_resource type="ShaderMaterial" id="4"]
+shader = ExtResource("2")
+
+[node name="TextureSetEditor" type="AcceptDialog"]
+title = "TextureSet Editor"
+size = Vector2i(666, 341)
+min_size = Vector2i(652, 320)
+script = ExtResource("1")
+
+[node name="VB" type="VBoxContainer" parent="."]
+anchors_preset = 15
+anchor_right = 1.0
+anchor_bottom = 1.0
+offset_left = 8.0
+offset_top = 8.0
+offset_right = -8.0
+offset_bottom = -18.0
+
+[node name="HS" type="HSplitContainer" parent="VB"]
+layout_mode = 2
+size_flags_vertical = 3
+
+[node name="VB" type="VBoxContainer" parent="VB/HS"]
+layout_mode = 2
+
+[node name="SlotsList" type="ItemList" parent="VB/HS/VB"]
+custom_minimum_size = Vector2(100, 0)
+layout_mode = 2
+size_flags_vertical = 3
+
+[node name="HB" type="HBoxContainer" parent="VB/HS/VB"]
+layout_mode = 2
+
+[node name="AddSlot" type="Button" parent="VB/HS/VB/HB"]
+layout_mode = 2
+text = "+"
+
+[node name="Control" type="Control" parent="VB/HS/VB/HB"]
+layout_mode = 2
+size_flags_horizontal = 3
+
+[node name="RemoveSlot" type="Button" parent="VB/HS/VB/HB"]
+layout_mode = 2
+text = "-"
+
+[node name="VB2" type="VBoxContainer" parent="VB/HS"]
+layout_mode = 2
+
+[node name="GC" type="GridContainer" parent="VB/HS/VB2"]
+layout_mode = 2
+columns = 4
+
+[node name="AlbedoLabel" type="Label" parent="VB/HS/VB2/GC"]
+layout_mode = 2
+text = "Albedo"
+
+[node name="AlbedoExtraLabel" type="Label" parent="VB/HS/VB2/GC"]
+layout_mode = 2
+text = "+ alpha bump"
+
+[node name="NormalLabel" type="Label" parent="VB/HS/VB2/GC"]
+layout_mode = 2
+text = "Normal"
+
+[node name="NormalExtraLabel" type="Label" parent="VB/HS/VB2/GC"]
+layout_mode = 2
+text = "+ alpha roughness"
+
+[node name="AlbedoPreview" type="TextureRect" parent="VB/HS/VB2/GC"]
+material = SubResource("1")
+custom_minimum_size = Vector2(128, 128)
+layout_mode = 2
+expand_mode = 1
+stretch_mode = 1
+
+[node name="BumpPreview" type="TextureRect" parent="VB/HS/VB2/GC"]
+material = SubResource("2")
+custom_minimum_size = Vector2(128, 128)
+layout_mode = 2
+expand_mode = 1
+stretch_mode = 1
+
+[node name="NormalPreview" type="TextureRect" parent="VB/HS/VB2/GC"]
+material = SubResource("3")
+custom_minimum_size = Vector2(128, 128)
+layout_mode = 2
+expand_mode = 1
+stretch_mode = 1
+
+[node name="RoughnessPreview" type="TextureRect" parent="VB/HS/VB2/GC"]
+material = SubResource("4")
+custom_minimum_size = Vector2(128, 128)
+layout_mode = 2
+expand_mode = 1
+stretch_mode = 1
+
+[node name="LoadAlbedo" type="Button" parent="VB/HS/VB2/GC"]
+layout_mode = 2
+text = "Load..."
+
+[node name="Spacer" type="Control" parent="VB/HS/VB2/GC"]
+layout_mode = 2
+
+[node name="LoadNormal" type="Button" parent="VB/HS/VB2/GC"]
+layout_mode = 2
+text = "Load..."
+
+[node name="Spacer2" type="Control" parent="VB/HS/VB2/GC"]
+layout_mode = 2
+
+[node name="ClearAlbedo" type="Button" parent="VB/HS/VB2/GC"]
+layout_mode = 2
+text = "Clear"
+
+[node name="Spacer3" type="Control" parent="VB/HS/VB2/GC"]
+layout_mode = 2
+
+[node name="ClearNormal" type="Button" parent="VB/HS/VB2/GC"]
+layout_mode = 2
+text = "Clear"
+
+[node name="HSeparator" type="Control" parent="VB/HS/VB2"]
+custom_minimum_size = Vector2(0, 4)
+layout_mode = 2
+
+[node name="GC2" type="HBoxContainer" parent="VB/HS/VB2"]
+layout_mode = 2
+
+[node name="Label" type="Label" parent="VB/HS/VB2/GC2"]
+layout_mode = 2
+text = "Mode"
+
+[node name="ModeSelector" type="OptionButton" parent="VB/HS/VB2/GC2"]
+layout_mode = 2
+size_flags_horizontal = 3
+
+[node name="Spacer" type="Control" parent="VB/HS/VB2/GC2"]
+layout_mode = 2
+size_flags_horizontal = 3
+
+[node name="Spacer" type="Control" parent="VB"]
+custom_minimum_size = Vector2(0, 4)
+layout_mode = 2
+
+[node name="HB" type="HBoxContainer" parent="VB"]
+layout_mode = 2
+alignment = 1
+
+[node name="ImportButton" type="Button" parent="VB/HB"]
+layout_mode = 2
+text = "Import..."
+
+[node name="CloseButton" type="Button" parent="VB/HB"]
+layout_mode = 2
+text = "Close"
+
+[node name="Spacer2" type="Control" parent="VB"]
+custom_minimum_size = Vector2(0, 2)
+layout_mode = 2
+
+[node name="DialogFitter" parent="." instance=ExtResource("5")]
+layout_mode = 3
+anchors_preset = 0
+offset_left = 8.0
+offset_top = 8.0
+offset_right = 658.0
+offset_bottom = 323.0
+
+[connection signal="item_selected" from="VB/HS/VB/SlotsList" to="." method="_on_SlotsList_item_selected"]
+[connection signal="pressed" from="VB/HS/VB/HB/AddSlot" to="." method="_on_AddSlot_pressed"]
+[connection signal="pressed" from="VB/HS/VB/HB/RemoveSlot" to="." method="_on_RemoveSlot_pressed"]
+[connection signal="pressed" from="VB/HS/VB2/GC/LoadAlbedo" to="." method="_on_LoadAlbedo_pressed"]
+[connection signal="pressed" from="VB/HS/VB2/GC/LoadNormal" to="." method="_on_LoadNormal_pressed"]
+[connection signal="pressed" from="VB/HS/VB2/GC/ClearAlbedo" to="." method="_on_ClearAlbedo_pressed"]
+[connection signal="pressed" from="VB/HS/VB2/GC/ClearNormal" to="." method="_on_ClearNormal_pressed"]
+[connection signal="item_selected" from="VB/HS/VB2/GC2/ModeSelector" to="." method="_on_ModeSelector_item_selected"]
+[connection signal="pressed" from="VB/HB/ImportButton" to="." method="_on_ImportButton_pressed"]
+[connection signal="pressed" from="VB/HB/CloseButton" to="." method="_on_CloseButton_pressed"]
diff --git a/game/addons/zylann.hterrain/tools/texture_editor/set_editor/texture_set_import_editor.gd b/game/addons/zylann.hterrain/tools/texture_editor/set_editor/texture_set_import_editor.gd
new file mode 100644
index 0000000..f865f63
--- /dev/null
+++ b/game/addons/zylann.hterrain/tools/texture_editor/set_editor/texture_set_import_editor.gd
@@ -0,0 +1,920 @@
+@tool
+extends AcceptDialog
+
+const HTerrainTextureSet = preload("../../../hterrain_texture_set.gd")
+const HT_Logger = preload("../../../util/logger.gd")
+const HT_EditorUtil = preload("../../util/editor_util.gd")
+const HT_Errors = preload("../../../util/errors.gd")
+const HT_TextureSetEditor = preload("./texture_set_editor.gd")
+const HT_Result = preload("../../util/result.gd")
+const HT_Util = preload("../../../util/util.gd")
+const HT_PackedTextureUtil = preload("../../packed_textures/packed_texture_util.gd")
+const ResourceImporterTexture_Unexposed = preload("../../util/resource_importer_texture.gd")
+const ResourceImporterTextureLayered_Unexposed = preload(
+ "../../util/resource_importer_texture_layered.gd")
+
+const HT_NormalMapPreviewShader = preload("../display_normal.gdshader")
+
+const COMPRESS_RAW = 0
+const COMPRESS_LOSSLESS = 1
+const COMPRESS_LOSSY = 1
+const COMPRESS_VRAM = 2
+const COMPRESS_COUNT = 3
+
+const _compress_names = ["Raw", "Lossless", "Lossy", "VRAM"]
+
+# Indexed by HTerrainTextureSet.SRC_TYPE_* constants
+const _smart_pick_file_keywords = [
+ ["albedo", "color", "col", "diffuse"],
+ ["bump", "height", "depth", "displacement", "disp"],
+ ["normal", "norm", "nrm"],
+ ["roughness", "rough", "rgh"]
+]
+
+signal import_finished
+
+@onready var _texture_editors = [
+ $Import/HS/VB2/HB/Albedo,
+ $Import/HS/VB2/HB/Bump,
+ $Import/HS/VB2/HB/Normal,
+ $Import/HS/VB2/HB/Roughness
+]
+
+@onready var _slots_list : ItemList = $Import/HS/VB/SlotsList
+
+# TODO Some shortcuts to import options were disabled in the GUI because of Godot issues.
+# If users want to customize that, they need to do it on the files directly.
+#
+# There is no script API in Godot to choose the import settings of a generated file.
+# They always start with the defaults, and the only implemented case is for the import dock.
+# It appeared possible to reverse-engineer and write a .import file as done in HTerrainData,
+# however when I tried this with custom importers, Godot stopped importing after scan(),
+# and the resources could not load. However, selecting them each and clicking "Reimport"
+# did import them fine. Unfortunately, this short-circuits the workflow.
+# Since I have no idea what's going on with this reverse-engineering, I had to drop those options.
+# Godot needs an API to import specific files and choose settings before the first import.
+#
+# Godot 4: now we'll really need it, let's enable and we'll see if it works
+# when we can test the workflow...
+const _WRITE_IMPORT_FILES = true
+
+@onready var _import_mode_selector : OptionButton = $Import/GC/ImportModeSelector
+@onready var _compression_selector : OptionButton = $Import/GC/CompressionSelector
+@onready var _resolution_spinbox : SpinBox = $Import/GC/ResolutionSpinBox
+@onready var _mipmaps_checkbox : CheckBox = $Import/GC/MipmapsCheckbox
+@onready var _add_slot_button : Button = $Import/HS/VB/HB/AddSlotButton
+@onready var _remove_slot_button : Button = $Import/HS/VB/HB/RemoveSlotButton
+@onready var _import_directory_line_edit : LineEdit = $Import/HB2/ImportDirectoryLineEdit
+@onready var _normalmap_flip_checkbox : CheckBox = $Import/HS/VB2/HB/Normal/NormalMapFlipY
+
+var _texture_set : HTerrainTextureSet
+var _undo_redo_manager : EditorUndoRedoManager
+var _logger = HT_Logger.get_for(self)
+
+# This is normally an `EditorFileDialog`. I can't type-hint this one properly,
+# because when I test this UI in isolation, I can't use `EditorFileDialog`.
+var _load_texture_dialog : ConfirmationDialog
+var _load_texture_type : int = -1
+var _error_popup : AcceptDialog
+var _info_popup : AcceptDialog
+var _delete_confirmation_popup : ConfirmationDialog
+var _open_dir_dialog : ConfirmationDialog
+var _editor_file_system : EditorFileSystem
+var _normalmap_material : ShaderMaterial
+
+var _import_mode := HTerrainTextureSet.MODE_TEXTURES
+
+class HT_TextureSetImportEditorSlot:
+ # Array of strings.
+ # Can be either path to images, hexadecimal colors starting with #, or empty string for "null".
+ var texture_paths := []
+ var flip_normalmap_y := false
+
+ func _init():
+ for i in HTerrainTextureSet.SRC_TYPE_COUNT:
+ texture_paths.append("")
+
+# Array of HT_TextureSetImportEditorSlot
+var _slots_data := []
+
+var _import_settings := {
+ "mipmaps": true,
+ "compression": COMPRESS_VRAM,
+ "resolution": 512
+}
+
+
+func _init():
+ get_ok_button().hide()
+
+ # Default data
+ _slots_data.clear()
+ for i in 4:
+ _slots_data.append(HT_TextureSetImportEditorSlot.new())
+
+
+func _ready():
+ if HT_Util.is_in_edited_scene(self):
+ return
+
+ for src_type in len(_texture_editors):
+ var ed = _texture_editors[src_type]
+ var typename = HTerrainTextureSet.get_source_texture_type_name(src_type)
+ ed.set_label(typename.capitalize())
+ ed.load_pressed.connect(_on_texture_load_pressed.bind(src_type))
+ ed.clear_pressed.connect(_on_texture_clear_pressed.bind(src_type))
+
+ for import_mode in HTerrainTextureSet.MODE_COUNT:
+ var n = HTerrainTextureSet.get_import_mode_name(import_mode)
+ _import_mode_selector.add_item(n, import_mode)
+
+ for compress_mode in COMPRESS_COUNT:
+ var n = _compress_names[compress_mode]
+ _compression_selector.add_item(n, compress_mode)
+
+ _normalmap_material = ShaderMaterial.new()
+ _normalmap_material.shader = HT_NormalMapPreviewShader
+ _texture_editors[HTerrainTextureSet.SRC_TYPE_NORMAL].set_material(_normalmap_material)
+
+
+func setup_dialogs(parent: Node):
+ var d = HT_EditorUtil.create_open_image_dialog()
+ d.file_selected.connect(_on_LoadTextureDialog_file_selected)
+ _load_texture_dialog = d
+ add_child(d)
+
+ d = AcceptDialog.new()
+ d.title = "Import error"
+ _error_popup = d
+ add_child(_error_popup)
+
+ d = AcceptDialog.new()
+ d.title = "Info"
+ _info_popup = d
+ add_child(_info_popup)
+
+ d = ConfirmationDialog.new()
+ d.confirmed.connect(_on_delete_confirmation_popup_confirmed)
+ _delete_confirmation_popup = d
+ add_child(_delete_confirmation_popup)
+
+ d = HT_EditorUtil.create_open_dir_dialog()
+ d.title = "Choose import directory"
+ d.dir_selected.connect(_on_OpenDirDialog_dir_selected)
+ _open_dir_dialog = d
+ add_child(_open_dir_dialog)
+
+ _update_ui_from_data()
+
+
+func _notification(what: int):
+ if what == NOTIFICATION_EXIT_TREE:
+ # Have to check for null in all of them,
+ # because otherwise it breaks in the scene editor...
+ if _load_texture_dialog != null:
+ _load_texture_dialog.queue_free()
+ if _error_popup != null:
+ _error_popup.queue_free()
+ if _delete_confirmation_popup != null:
+ _delete_confirmation_popup.queue_free()
+ if _open_dir_dialog != null:
+ _open_dir_dialog.queue_free()
+ if _info_popup != null:
+ _info_popup.queue_free()
+
+
+# TODO Is it still necessary for an import tab?
+func set_undo_redo(ur: EditorUndoRedoManager):
+ _undo_redo_manager = ur
+
+
+func set_editor_file_system(efs: EditorFileSystem):
+ _editor_file_system = efs
+
+
+func set_texture_set(texture_set: HTerrainTextureSet):
+ if _texture_set == texture_set:
+ # TODO What if the set was actually modified since?
+ return
+ _texture_set = texture_set
+
+ _slots_data.clear()
+
+ if _texture_set.get_mode() == HTerrainTextureSet.MODE_TEXTURES:
+ var slots_count = _texture_set.get_slots_count()
+
+ for slot_index in slots_count:
+ var slot := HT_TextureSetImportEditorSlot.new()
+
+ for type in HTerrainTextureSet.TYPE_COUNT:
+ var texture = _texture_set.get_texture(slot_index, type)
+
+ if texture == null or texture.resource_path == "":
+ continue
+
+ if not texture.resource_path.ends_with(".packed_tex"):
+ continue
+
+ var import_data := _parse_json_file(texture.resource_path)
+ if import_data.is_empty() or not import_data.has("src"):
+ continue
+
+ var src_types = HTerrainTextureSet.get_src_types_from_type(type)
+
+ var src_data = import_data["src"]
+ if src_data.has("rgb"):
+ slot.texture_paths[src_types[0]] = src_data["rgb"]
+ if src_data.has("a"):
+ slot.texture_paths[src_types[1]] = src_data["a"]
+
+ _slots_data.append(slot)
+
+ else:
+ var slots_count := _texture_set.get_slots_count()
+
+ for type in HTerrainTextureSet.TYPE_COUNT:
+ var texture_array := _texture_set.get_texture_array(type)
+
+ if texture_array == null or texture_array.resource_path == "":
+ continue
+
+ if not texture_array.resource_path.ends_with(".packed_texarr"):
+ continue
+
+ var import_data := _parse_json_file(texture_array.resource_path)
+ if import_data.is_empty() or not import_data.has("layers"):
+ continue
+
+ var layers_data = import_data["layers"]
+
+ for slot_index in len(layers_data):
+ var src_data = layers_data[slot_index]
+
+ var src_types = HTerrainTextureSet.get_src_types_from_type(type)
+
+ while slot_index >= len(_slots_data):
+ var slot = HT_TextureSetImportEditorSlot.new()
+ _slots_data.append(slot)
+
+ var slot : HT_TextureSetImportEditorSlot = _slots_data[slot_index]
+
+ if src_data.has("rgb"):
+ slot.texture_paths[src_types[0]] = src_data["rgb"]
+ if src_data.has("a"):
+ slot.texture_paths[src_types[1]] = src_data["a"]
+
+ # TODO If the set doesn't have a file, use terrain path by default?
+ if texture_set.resource_path != "":
+ var dir = texture_set.resource_path.get_base_dir()
+ _import_directory_line_edit.text = dir
+
+ _update_ui_from_data()
+
+
+func _parse_json_file(fpath: String) -> Dictionary:
+ var f := FileAccess.open(fpath, FileAccess.READ)
+ if f == null:
+ var err := FileAccess.get_open_error()
+ _logger.error("Could not load {0}: {1}".format([fpath, HT_Errors.get_message(err)]))
+ return {}
+
+ var json_text := f.get_as_text()
+ var json := JSON.new()
+ var json_err := json.parse(json_text)
+ if json_err != OK:
+ _logger.error("Failed to parse {0}: {1}".format([fpath, json.get_error_message()]))
+ return {}
+
+ return json.data
+
+
+func _update_ui_from_data():
+ var prev_selected_items := _slots_list.get_selected_items()
+
+ _slots_list.clear()
+
+ for slot_index in len(_slots_data):
+ _slots_list.add_item("Texture {0}".format([slot_index]))
+
+ _resolution_spinbox.value = _import_settings.resolution
+ _mipmaps_checkbox.button_pressed = _import_settings.mipmaps
+ _set_selected_id(_compression_selector, _import_settings.compression)
+ _set_selected_id(_import_mode_selector, _import_mode)
+
+ var has_slots : bool = _slots_list.get_item_count() > 0
+
+ for ed in _texture_editors:
+ ed.set_enabled(has_slots)
+ _normalmap_flip_checkbox.disabled = not has_slots
+
+ if has_slots:
+ if len(prev_selected_items) > 0:
+ var i : int = prev_selected_items[0]
+ if i >= _slots_list.get_item_count():
+ i = _slots_list.get_item_count() - 1
+ _select_slot(i)
+ else:
+ _select_slot(0)
+ else:
+ for type in HTerrainTextureSet.SRC_TYPE_COUNT:
+ _set_ui_slot_texture_from_path("", type)
+
+ var max_slots := HTerrainTextureSet.get_max_slots_for_mode(_import_mode)
+ _add_slot_button.disabled = (len(_slots_data) >= max_slots)
+ _remove_slot_button.disabled = (len(_slots_data) == 0)
+
+
+static func _set_selected_id(ob: OptionButton, id: int):
+ for i in ob.get_item_count():
+ if ob.get_item_id(i) == id:
+ ob.selected = i
+ break
+
+
+func _select_slot(slot_index: int):
+ assert(slot_index >= 0)
+ assert(slot_index < len(_slots_data))
+ var slot = _slots_data[slot_index]
+
+ for type in HTerrainTextureSet.SRC_TYPE_COUNT:
+ var im_path : String = slot.texture_paths[type]
+ _set_ui_slot_texture_from_path(im_path, type)
+
+ _slots_list.select(slot_index)
+
+ _normalmap_flip_checkbox.button_pressed = slot.flip_normalmap_y
+ _normalmap_material.set_shader_parameter("u_flip_y", slot.flip_normalmap_y)
+
+
+func _set_ui_slot_texture_from_path(im_path: String, type: int):
+ var ed = _texture_editors[type]
+
+ if im_path == "":
+ ed.set_texture(null)
+ ed.set_texture_tooltip("<empty>")
+ return
+
+ var im : Image
+
+ if im_path.begins_with("#") and im_path.find(".") == -1:
+ # The path is actually a preset for a uniform color.
+ # This is a feature of packed texture descriptor files.
+ # Make a small placeholder image.
+ var color := Color(im_path)
+ im = Image.create(4, 4, false, Image.FORMAT_RGBA8)
+ im.fill(color)
+
+ else:
+ # Regular path
+ im = Image.new()
+ var err := im.load(im_path)
+ if err != OK:
+ _logger.error(str("Unable to load image from ", im_path))
+ # TODO Different icon for images that can't load?
+ ed.set_texture(null)
+ ed.set_texture_tooltip("<empty>")
+ return
+
+ var tex := ImageTexture.create_from_image(im)
+ ed.set_texture(tex)
+ ed.set_texture_tooltip(im_path)
+
+
+func _set_source_image(fpath: String, type: int):
+ _set_ui_slot_texture_from_path(fpath, type)
+
+ var slot_index : int = _slots_list.get_selected_items()[0]
+ #var prev_path = _texture_set.get_source_image_path(slot_index, type)
+
+ var slot : HT_TextureSetImportEditorSlot = _slots_data[slot_index]
+ slot.texture_paths[type] = fpath
+
+
+func _set_import_property(key: String, value):
+ var prev_value = _import_settings[key]
+ # This is needed, notably because CheckBox emits a signal too when we set it from code...
+ if prev_value == value:
+ return
+
+ _import_settings[key] = value
+
+
+func _on_texture_load_pressed(type: int):
+ _load_texture_type = type
+ _load_texture_dialog.popup_centered_ratio()
+
+
+func _on_LoadTextureDialog_file_selected(fpath: String):
+ _set_source_image(fpath, _load_texture_type)
+
+ if _load_texture_type == HTerrainTextureSet.SRC_TYPE_ALBEDO:
+ _smart_pick_files(fpath)
+
+
+# Attempts to load source images of other types by looking at how the albedo file was named
+func _smart_pick_files(albedo_fpath: String):
+ var albedo_words = _smart_pick_file_keywords[HTerrainTextureSet.SRC_TYPE_ALBEDO]
+
+ var albedo_fname := albedo_fpath.get_file()
+ var albedo_fname_lower = albedo_fname.to_lower()
+ var fname_pattern = ""
+
+ for albedo_word in albedo_words:
+ var i = albedo_fname_lower.find(albedo_word, 0)
+ if i != -1:
+ fname_pattern = \
+ albedo_fname.substr(0, i) + "{0}" + albedo_fname.substr(i + len(albedo_word))
+ break
+
+ if fname_pattern == "":
+ return
+
+ var dirpath := albedo_fpath.get_base_dir()
+ var fnames := _get_files_in_directory(dirpath, _logger)
+
+ var types := [
+ HTerrainTextureSet.SRC_TYPE_BUMP,
+ HTerrainTextureSet.SRC_TYPE_NORMAL,
+ HTerrainTextureSet.SRC_TYPE_ROUGHNESS
+ ]
+
+ var slot_index : int = _slots_list.get_selected_items()[0]
+
+ for type in types:
+ var slot = _slots_data[slot_index]
+ if slot.texture_paths[type] != "":
+ # Already set, don't overwrite unwantedly
+ continue
+
+ var keywords = _smart_pick_file_keywords[type]
+
+ for key in keywords:
+ var expected_fname = fname_pattern.format([key])
+
+ var found := false
+
+ for i in len(fnames):
+ var fname : String = fnames[i]
+
+ # TODO We should probably ignore extensions?
+ if fname.to_lower() == expected_fname.to_lower():
+ var fpath = dirpath.path_join(fname)
+ _set_source_image(fpath, type)
+ found = true
+ break
+
+ if found:
+ break
+
+
+static func _get_files_in_directory(dirpath: String, logger) -> Array:
+ var dir := DirAccess.open(dirpath)
+ var err := DirAccess.get_open_error()
+ if err != OK:
+ logger.error("Could not open directory {0}: {1}" \
+ .format([dirpath, HT_Errors.get_message(err)]))
+ return []
+
+ dir.include_hidden = false
+ dir.include_navigational = false
+
+ err = dir.list_dir_begin()
+ if err != OK:
+ logger.error("Could not probe directory {0}: {1}" \
+ .format([dirpath, HT_Errors.get_message(err)]))
+ return []
+
+ var files := []
+ var fname := dir.get_next()
+ while fname != "":
+ if not dir.current_is_dir():
+ files.append(fname)
+ fname = dir.get_next()
+
+ return files
+
+
+func _on_texture_clear_pressed(type: int):
+ _set_source_image("", type)
+
+
+func _on_SlotsList_item_selected(index: int):
+ _select_slot(index)
+
+
+func _on_ImportModeSelector_item_selected(index: int):
+ var mode : int = _import_mode_selector.get_item_id(index)
+ if mode != _import_mode:
+ #_set_import_property("mode", mode)
+ _import_mode = mode
+ _update_ui_from_data()
+
+
+func _on_CompressionSelector_item_selected(index: int):
+ var compression : int = _compression_selector.get_item_id(index)
+ _set_import_property("compression", compression)
+
+
+func _on_MipmapsCheckbox_toggled(button_pressed: bool):
+ _set_import_property("mipmaps", button_pressed)
+
+
+func _on_ResolutionSpinBox_value_changed(value):
+ _set_import_property("resolution", int(value))
+
+
+func _on_TextureArrayPrefixLineEdit_text_changed(new_text: String):
+ _set_import_property("output_prefix", new_text)
+
+
+func _on_AddSlotButton_pressed():
+ var i := len(_slots_data)
+ _slots_data.append(HT_TextureSetImportEditorSlot.new())
+ _update_ui_from_data()
+ _select_slot(i)
+
+
+func _on_RemoveSlotButton_pressed():
+ if _slots_list.get_item_count() == 0:
+ return
+ var selected_item = _slots_list.get_selected_items()[0]
+ _delete_confirmation_popup.title = "Delete slot {0}".format([selected_item])
+ _delete_confirmation_popup.dialog_text = "Delete import slot {0}?".format([selected_item])
+ _delete_confirmation_popup.popup_centered()
+
+
+func _on_delete_confirmation_popup_confirmed():
+ var selected_item : int = _slots_list.get_selected_items()[0]
+ _slots_data.remove_at(selected_item)
+ _update_ui_from_data()
+
+
+func _on_CancelButton_pressed():
+ hide()
+
+
+func _on_BrowseImportDirectory_pressed():
+ _open_dir_dialog.popup_centered_ratio()
+
+
+func _on_ImportDirectoryLineEdit_text_changed(new_text: String):
+ pass
+
+
+func _on_OpenDirDialog_dir_selected(dir_path: String):
+ _import_directory_line_edit.text = dir_path
+
+
+func _show_error(message: String):
+ _error_popup.dialog_text = message
+ _error_popup.popup_centered()
+
+
+func _on_NormalMapFlipY_toggled(button_pressed: bool):
+ var slot_index : int = _slots_list.get_selected_items()[0]
+ var slot : HT_TextureSetImportEditorSlot = _slots_data[slot_index]
+ slot.flip_normalmap_y = button_pressed
+ _normalmap_material.set_shader_parameter("u_flip_y", slot.flip_normalmap_y)
+
+
+# class ButtonDisabler:
+# var _button : Button
+
+# func _init(b: Button):
+# _button = b
+# _button.disabled = true
+
+# func _notification(what: int):
+# if what == NOTIFICATION_PREDELETE:
+# _button.disabled = false
+
+
+func _get_undo_redo_for_texture_set() -> UndoRedo:
+ return _undo_redo_manager.get_history_undo_redo(
+ _undo_redo_manager.get_object_history_id(_texture_set))
+
+
+func _on_ImportButton_pressed():
+ if _texture_set == null:
+ _show_error("No HTerrainTextureSet selected.")
+ return
+
+ var import_dir := _import_directory_line_edit.text.strip_edges()
+
+ var prefix := ""
+ if _texture_set.resource_path != "":
+ prefix = _texture_set.resource_path.get_file().get_basename() + "_"
+
+ var files_data_result : HT_Result
+ if _import_mode == HTerrainTextureSet.MODE_TEXTURES:
+ files_data_result = _generate_packed_images(import_dir, prefix)
+ else:
+ files_data_result = _generate_packed_texarray_images(import_dir, prefix)
+
+ if not files_data_result.success:
+ _show_error(files_data_result.get_message())
+ return
+
+ var files_data : Array = files_data_result.value
+
+ if len(files_data) == 0:
+ _show_error("There are no files to save.\nYou must setup at least one slot of textures.")
+ return
+
+ for fd in files_data:
+ var dir_path : String = fd.path.get_base_dir()
+ if not DirAccess.dir_exists_absolute(dir_path):
+ _show_error("The directory {0} could not be found.".format([dir_path]))
+ return
+
+ if _WRITE_IMPORT_FILES:
+ for fd in files_data:
+ var import_fpath = fd.path + ".import"
+ if not HT_Util.write_import_file(fd.import_file_data, import_fpath, _logger):
+ _show_error("Failed to write file {0}: {1}".format([import_fpath]))
+ return
+
+ if _editor_file_system == null:
+ _show_error("EditorFileSystem is not setup, can't trigger import system.")
+ return
+
+ # ______
+ # .-" "-.
+ # / \
+ # _ | | _
+ # ( \ |, .-. .-. ,| / )
+ # > "=._ | )(__/ \__)( | _.=" <
+ # (_/"=._"=._ |/ /\ \| _.="_.="\_)
+ # "=._ (_ ^^ _)"_.="
+ # "=\__|IIIIII|__/="
+ # _.="| \IIIIII/ |"=._
+ # _ _.="_.="\ /"=._"=._ _
+ # ( \_.="_.=" `--------` "=._"=._/ )
+ # > _.=" "=._ <
+ # (_/ \_)
+ #
+ # TODO What I need here is a way to trigger the import of specific files!
+ # It exists, but is not exposed, so I have to rely on a VERY fragile and hacky use of scan()...
+ # I'm not even sure it works tbh. It's terrible.
+ # See https://github.com/godotengine/godot-proposals/issues/1615
+ _editor_file_system.scan()
+ while _editor_file_system.is_scanning():
+ _logger.debug("Waiting for scan to complete...")
+ await get_tree().process_frame
+ if not is_inside_tree():
+ # oops?
+ return
+ _logger.debug("Scanning complete")
+ # Looks like import takes place AFTER scanning, so let's yield some more...
+ for fd in len(files_data) * 2:
+ _logger.debug("Yielding some more")
+ await get_tree().process_frame
+
+ var failed_resource_paths := []
+
+ # Using UndoRedo is mandatory for Godot to consider the resource as modified...
+ # ...yet if files get deleted, that won't be undoable anyways, but whatever :shrug:
+ var ur := _get_undo_redo_for_texture_set()
+
+ # Check imported textures
+ if _import_mode == HTerrainTextureSet.MODE_TEXTURES:
+ for fd in files_data:
+ var texture : Texture2D = load(fd.path)
+ if texture == null:
+ failed_resource_paths.append(fd.path)
+ continue
+ fd.texture = texture
+
+ else:
+ for fd in files_data:
+ var texture_array : TextureLayered = load(fd.path)
+ if texture_array == null:
+ failed_resource_paths.append(fd.path)
+ continue
+ fd.texture_array = texture_array
+
+ if len(failed_resource_paths) > 0:
+ var failed_list := "\n".join(PackedStringArray(failed_resource_paths))
+ _show_error("Some resources failed to load:\n" + failed_list)
+ return
+
+ # All is OK, commit action to modify the texture set with imported textures
+
+ if _import_mode == HTerrainTextureSet.MODE_TEXTURES:
+ ur.create_action("HTerrainTextureSet: import textures")
+
+ HT_TextureSetEditor.backup_for_undo(_texture_set, ur)
+
+ ur.add_do_method(_texture_set.clear)
+ ur.add_do_method(_texture_set.set_mode.bind(_import_mode))
+
+ for i in len(_slots_data):
+ ur.add_do_method(_texture_set.insert_slot.bind(-1))
+ for fd in files_data:
+ ur.add_do_method(_texture_set.set_texture.bind(fd.slot_index, fd.type, fd.texture))
+
+ else:
+ ur.create_action("HTerrainTextureSet: import texture arrays")
+
+ HT_TextureSetEditor.backup_for_undo(_texture_set, ur)
+
+ ur.add_do_method(_texture_set.clear)
+ ur.add_do_method(_texture_set.set_mode.bind(_import_mode))
+
+ for fd in files_data:
+ ur.add_do_method(_texture_set.set_texture_array.bind(fd.type, fd.texture_array))
+
+ ur.commit_action()
+
+ _logger.debug("Done importing")
+
+ _info_popup.dialog_text = "Importing complete!"
+ _info_popup.popup_centered()
+
+ import_finished.emit()
+
+
+class HT_PackedImageInfo:
+ var path := "" # Where the packed image is saved
+ var slot_index : int # Slot in texture set, when using individual textures
+ var type : int # 0:Albedo+Bump, 1:Normal+Roughness
+ var import_file_data := {} # Data to write into the .import file (when enabled...)
+ var image : Image
+ var is_default := false
+ var texture : Texture2D
+ var texture_array : TextureLayered
+
+
+# Shared code between the two import modes
+func _generate_packed_images2() -> HT_Result:
+ var resolution : int = _import_settings.resolution
+ var images_infos := []
+
+ for type in HTerrainTextureSet.TYPE_COUNT:
+ var src_types := HTerrainTextureSet.get_src_types_from_type(type)
+
+ for slot_index in len(_slots_data):
+ var slot : HT_TextureSetImportEditorSlot = _slots_data[slot_index]
+
+ # Albedo or Normal
+ var src0 : String = slot.texture_paths[src_types[0]]
+ # Bump or Roughness
+ var src1 : String = slot.texture_paths[src_types[1]]
+
+ if src0 == "":
+ if src_types[0] == HTerrainTextureSet.SRC_TYPE_ALBEDO:
+ return HT_Result.new(false,
+ "Albedo texture is missing in slot {0}".format([slot_index]))
+
+ var is_default := (src0 == "" and src1 == "")
+
+ if src0 == "":
+ src0 = HTerrainTextureSet.get_source_texture_default_color_code(src_types[0])
+ if src1 == "":
+ src1 = HTerrainTextureSet.get_source_texture_default_color_code(src_types[1])
+
+ var pack_sources := {
+ "rgb": src0,
+ "a": src1
+ }
+
+ if HTerrainTextureSet.SRC_TYPE_NORMAL in src_types and slot.flip_normalmap_y:
+ pack_sources["normalmap_flip_y"] = true
+
+ var packed_image_result := HT_PackedTextureUtil.generate_image(
+ pack_sources, resolution, _logger)
+ if not packed_image_result.success:
+ return packed_image_result
+ var packed_image : Image = packed_image_result.value
+
+ var fd := HT_PackedImageInfo.new()
+ fd.slot_index = slot_index
+ fd.type = type
+ fd.image = packed_image
+ fd.is_default = is_default
+
+ images_infos.append(fd)
+
+ return HT_Result.new(true).with_value(images_infos)
+
+
+func _generate_packed_images(import_dir: String, prefix: String) -> HT_Result:
+ var images_infos_result := _generate_packed_images2()
+ if not images_infos_result.success:
+ return images_infos_result
+ var images_infos : Array = images_infos_result.value
+
+ for info_index in len(images_infos):
+ var info : HT_PackedImageInfo = images_infos[info_index]
+
+ var type_name := HTerrainTextureSet.get_texture_type_name(info.type)
+ var fpath := import_dir.path_join(
+ str(prefix, "slot", info.slot_index, "_", type_name, ".png"))
+
+ var err := info.image.save_png(fpath)
+ if err != OK:
+ return HT_Result.new(false,
+ "Could not save image {0}, {1}".format([fpath, HT_Errors.get_message(err)]))
+
+ info.path = fpath
+ info.import_file_data = {
+ "remap": {
+ "importer": "texture",
+ "type": "CompressedTexture2D"
+ },
+ "deps": {
+ "source_file": fpath
+ },
+ "params": {
+ "compress/mode": ResourceImporterTexture_Unexposed.COMPRESS_VRAM_COMPRESSED,
+ "compress/high_quality": false,
+ "compress/lossy_quality": 0.7,
+ "mipmaps/generate": true,
+ "mipmaps/limit": -1,
+ "roughness/mode": ResourceImporterTexture_Unexposed.ROUGHNESS_DISABLED,
+ "process/fix_alpha_border": false
+ }
+ }
+
+ return HT_Result.new(true).with_value(images_infos)
+
+
+static func _assemble_texarray_images(images: Array[Image], resolution: Vector2i) -> Image:
+ # Godot expects some kind of grid. Let's be lazy and do a grid with only one row.
+ var atlas := Image.create(resolution.x * len(images), resolution.y, false, Image.FORMAT_RGBA8)
+ for index in len(images):
+ var image : Image = images[index]
+ if image.get_size() != resolution:
+ image.resize(resolution.x, resolution.y, Image.INTERPOLATE_BILINEAR)
+ atlas.blit_rect(image,
+ Rect2i(0, 0, image.get_width(), image.get_height()),
+ Vector2i(index * resolution.x, 0))
+ return atlas
+
+
+func _generate_packed_texarray_images(import_dir: String, prefix: String) -> HT_Result:
+ var images_infos_result := _generate_packed_images2()
+ if not images_infos_result.success:
+ return images_infos_result
+ var individual_images_infos : Array = images_infos_result.value
+
+ var resolution : int = _import_settings.resolution
+
+ var texarray_images_infos := []
+ var slot_count := len(_slots_data)
+
+ for type in HTerrainTextureSet.TYPE_COUNT:
+ var texarray_images : Array[Image] = []
+ texarray_images.resize(slot_count)
+
+ var fully_defaulted_slots := 0
+
+ for i in slot_count:
+ var info : HT_PackedImageInfo = individual_images_infos[type * slot_count + i]
+ if info.type == type:
+ texarray_images[i] = info.image
+ if info.is_default:
+ fully_defaulted_slots += 1
+
+ if fully_defaulted_slots == len(texarray_images):
+ # No need to generate this file at all
+ continue
+
+ var texarray_image := _assemble_texarray_images(texarray_images,
+ Vector2i(resolution, resolution))
+
+ var type_name := HTerrainTextureSet.get_texture_type_name(type)
+ var fpath := import_dir.path_join(str(prefix, type_name, "_array.png"))
+
+ var err := texarray_image.save_png(fpath)
+ if err != OK:
+ return HT_Result.new(false,
+ "Could not save image {0}, {1}".format([fpath, HT_Errors.get_message(err)]))
+
+ var texarray_image_info := HT_PackedImageInfo.new()
+ texarray_image_info.type = type
+ texarray_image_info.path = fpath
+ texarray_image_info.import_file_data = {
+ "remap": {
+ "importer": "2d_array_texture",
+ "type": "CompressedTexture2DArray"
+ },
+ "deps": {
+ "source_file": fpath
+ },
+ "params": {
+ "compress/mode": ResourceImporterTextureLayered_Unexposed.COMPRESS_VRAM_COMPRESSED,
+ "compress/high_quality": false,
+ "compress/lossy_quality": 0.7,
+ "mipmaps/generate": true,
+ "mipmaps/limit": -1,
+ "process/fix_alpha_border": false,
+ "slices/horizontal": len(texarray_images),
+ "slices/vertical": 1
+ }
+ }
+
+ texarray_images_infos.append(texarray_image_info)
+
+ return HT_Result.new(true).with_value(texarray_images_infos)
+
diff --git a/game/addons/zylann.hterrain/tools/texture_editor/set_editor/texture_set_import_editor.tscn b/game/addons/zylann.hterrain/tools/texture_editor/set_editor/texture_set_import_editor.tscn
new file mode 100644
index 0000000..549f7a1
--- /dev/null
+++ b/game/addons/zylann.hterrain/tools/texture_editor/set_editor/texture_set_import_editor.tscn
@@ -0,0 +1,218 @@
+[gd_scene load_steps=4 format=3 uid="uid://3indvrto5vd5"]
+
+[ext_resource type="PackedScene" path="res://addons/zylann.hterrain/tools/util/dialog_fitter.tscn" id="1"]
+[ext_resource type="PackedScene" uid="uid://dqgaomu3tr1ym" path="res://addons/zylann.hterrain/tools/texture_editor/set_editor/source_file_item_editor.tscn" id="3"]
+[ext_resource type="Script" path="res://addons/zylann.hterrain/tools/texture_editor/set_editor/texture_set_import_editor.gd" id="4"]
+
+[node name="TextureSetImportEditor" type="AcceptDialog"]
+title = "Texture Set Import Tool"
+size = Vector2i(652, 623)
+min_size = Vector2i(652, 480)
+script = ExtResource("4")
+
+[node name="Import" type="VBoxContainer" parent="."]
+anchors_preset = 15
+anchor_right = 1.0
+anchor_bottom = 1.0
+offset_left = 8.0
+offset_top = 8.0
+offset_right = -8.0
+offset_bottom = -18.0
+
+[node name="HS" type="HSplitContainer" parent="Import"]
+layout_mode = 2
+size_flags_vertical = 3
+
+[node name="VB" type="VBoxContainer" parent="Import/HS"]
+layout_mode = 2
+size_flags_vertical = 3
+
+[node name="Label" type="Label" parent="Import/HS/VB"]
+visible = false
+layout_mode = 2
+text = "Slots"
+
+[node name="SlotsList" type="ItemList" parent="Import/HS/VB"]
+custom_minimum_size = Vector2(100, 0)
+layout_mode = 2
+size_flags_vertical = 3
+item_count = 7
+item_0/text = "Item 0"
+item_1/text = "Item 1"
+item_2/text = "Item 2"
+item_3/text = "Item 3"
+item_4/text = "Item 4"
+item_5/text = "Item 5"
+item_6/text = "Item 6"
+
+[node name="HB" type="HBoxContainer" parent="Import/HS/VB"]
+layout_mode = 2
+
+[node name="AddSlotButton" type="Button" parent="Import/HS/VB/HB"]
+layout_mode = 2
+text = "+"
+
+[node name="Control" type="Control" parent="Import/HS/VB/HB"]
+layout_mode = 2
+size_flags_horizontal = 3
+
+[node name="RemoveSlotButton" type="Button" parent="Import/HS/VB/HB"]
+layout_mode = 2
+text = "-"
+
+[node name="VB2" type="VBoxContainer" parent="Import/HS"]
+layout_mode = 2
+
+[node name="Label" type="Label" parent="Import/HS/VB2"]
+visible = false
+layout_mode = 2
+
+[node name="HB" type="HBoxContainer" parent="Import/HS/VB2"]
+layout_mode = 2
+
+[node name="Albedo" parent="Import/HS/VB2/HB" instance=ExtResource("3")]
+layout_mode = 2
+
+[node name="Bump" parent="Import/HS/VB2/HB" instance=ExtResource("3")]
+layout_mode = 2
+
+[node name="Normal" parent="Import/HS/VB2/HB" instance=ExtResource("3")]
+layout_mode = 2
+
+[node name="NormalMapFlipY" type="CheckBox" parent="Import/HS/VB2/HB/Normal"]
+layout_mode = 2
+text = "Flip Y"
+
+[node name="Roughness" parent="Import/HS/VB2/HB" instance=ExtResource("3")]
+layout_mode = 2
+
+[node name="Control" type="Control" parent="Import/HS/VB2"]
+custom_minimum_size = Vector2(0, 4)
+layout_mode = 2
+
+[node name="Control2" type="Control" parent="Import/HS/VB2"]
+custom_minimum_size = Vector2(0, 4)
+layout_mode = 2
+size_flags_vertical = 3
+
+[node name="Label3" type="Label" parent="Import/HS/VB2"]
+modulate = Color(0.564706, 0.564706, 0.564706, 1)
+layout_mode = 2
+text = "These images should remain accessible for import to work.
+Tip: you can place them in a folder with a `.gdignore` file, so they won't take space in your exported game."
+autowrap_mode = 2
+
+[node name="Spacer3" type="Control" parent="Import"]
+custom_minimum_size = Vector2(0, 8)
+layout_mode = 2
+
+[node name="HSeparator" type="HSeparator" parent="Import"]
+layout_mode = 2
+
+[node name="GC" type="GridContainer" parent="Import"]
+layout_mode = 2
+columns = 4
+
+[node name="Label2" type="Label" parent="Import/GC"]
+layout_mode = 2
+text = "Import mode: "
+
+[node name="ImportModeSelector" type="OptionButton" parent="Import/GC"]
+layout_mode = 2
+size_flags_horizontal = 3
+
+[node name="MipmapsCheckbox" type="CheckBox" parent="Import/GC"]
+visible = false
+layout_mode = 2
+text = "Mipmaps"
+
+[node name="Spacer2" type="Control" parent="Import/GC"]
+layout_mode = 2
+size_flags_horizontal = 3
+
+[node name="Label" type="Label" parent="Import/GC"]
+visible = false
+layout_mode = 2
+text = "Compression:"
+
+[node name="CompressionSelector" type="OptionButton" parent="Import/GC"]
+visible = false
+layout_mode = 2
+size_flags_horizontal = 3
+
+[node name="FilterCheckBox" type="CheckBox" parent="Import/GC"]
+visible = false
+layout_mode = 2
+text = "Filter"
+
+[node name="Spacer" type="Control" parent="Import/GC"]
+layout_mode = 2
+
+[node name="Label3" type="Label" parent="Import/GC"]
+layout_mode = 2
+text = "Resolution:"
+
+[node name="ResolutionSpinBox" type="SpinBox" parent="Import/GC"]
+layout_mode = 2
+min_value = 1.0
+max_value = 4096.0
+value = 1.0
+
+[node name="HB2" type="HBoxContainer" parent="Import"]
+layout_mode = 2
+
+[node name="Label2" type="Label" parent="Import/HB2"]
+layout_mode = 2
+text = "Import directory"
+
+[node name="ImportDirectoryLineEdit" type="LineEdit" parent="Import/HB2"]
+layout_mode = 2
+size_flags_horizontal = 3
+
+[node name="BrowseImportDirectory" type="Button" parent="Import/HB2"]
+layout_mode = 2
+text = "..."
+
+[node name="Spacer" type="Control" parent="Import"]
+custom_minimum_size = Vector2(0, 8)
+layout_mode = 2
+
+[node name="HB" type="HBoxContainer" parent="Import"]
+layout_mode = 2
+alignment = 1
+
+[node name="ImportButton" type="Button" parent="Import/HB"]
+layout_mode = 2
+text = "Import to TextureSet"
+
+[node name="CancelButton" type="Button" parent="Import/HB"]
+layout_mode = 2
+text = "Close"
+
+[node name="Spacer2" type="Control" parent="Import"]
+custom_minimum_size = Vector2(0, 8)
+layout_mode = 2
+
+[node name="DialogFitter" parent="." instance=ExtResource("1")]
+layout_mode = 3
+anchors_preset = 0
+offset_left = 8.0
+offset_top = 8.0
+offset_right = 644.0
+offset_bottom = 605.0
+
+[connection signal="item_selected" from="Import/HS/VB/SlotsList" to="." method="_on_SlotsList_item_selected"]
+[connection signal="pressed" from="Import/HS/VB/HB/AddSlotButton" to="." method="_on_AddSlotButton_pressed"]
+[connection signal="pressed" from="Import/HS/VB/HB/RemoveSlotButton" to="." method="_on_RemoveSlotButton_pressed"]
+[connection signal="toggled" from="Import/HS/VB2/HB/Normal/NormalMapFlipY" to="." method="_on_NormalMapFlipY_toggled"]
+[connection signal="item_selected" from="Import/GC/ImportModeSelector" to="." method="_on_ImportModeSelector_item_selected"]
+[connection signal="toggled" from="Import/GC/MipmapsCheckbox" to="." method="_on_MipmapsCheckbox_toggled"]
+[connection signal="item_selected" from="Import/GC/CompressionSelector" to="." method="_on_CompressionSelector_item_selected"]
+[connection signal="toggled" from="Import/GC/FilterCheckBox" to="." method="_on_FilterCheckBox_toggled"]
+[connection signal="value_changed" from="Import/GC/ResolutionSpinBox" to="." method="_on_ResolutionSpinBox_value_changed"]
+[connection signal="text_changed" from="Import/HB2/ImportDirectoryLineEdit" to="." method="_on_ImportDirectoryLineEdit_text_changed"]
+[connection signal="pressed" from="Import/HB2/BrowseImportDirectory" to="." method="_on_BrowseImportDirectory_pressed"]
+[connection signal="pressed" from="Import/HB/ImportButton" to="." method="_on_ImportButton_pressed"]
+[connection signal="pressed" from="Import/HB/CancelButton" to="." method="_on_CancelButton_pressed"]
+
+[editable path="Import/HS/VB2/HB/Normal"]
diff --git a/game/addons/zylann.hterrain/tools/texture_editor/texture_editor.gd b/game/addons/zylann.hterrain/tools/texture_editor/texture_editor.gd
new file mode 100644
index 0000000..e3e337f
--- /dev/null
+++ b/game/addons/zylann.hterrain/tools/texture_editor/texture_editor.gd
@@ -0,0 +1,134 @@
+@tool
+extends Control
+
+const HTerrain = preload("../../hterrain.gd")
+const HTerrainTextureSet = preload("../../hterrain_texture_set.gd")
+const HT_TextureList = preload("./texture_list.gd")
+const HT_Logger = preload("../../util/logger.gd")
+# TODO Can't preload because it causes the plugin to fail loading if assets aren't imported
+const EMPTY_ICON_TEXTURE_PATH = "res://addons/zylann.hterrain/tools/icons/empty.png"
+
+signal texture_selected(index)
+signal edit_pressed(index)
+signal import_pressed
+
+@onready var _textures_list: HT_TextureList = $TextureList
+@onready var _buttons_container : HBoxContainer = $HBoxContainer
+
+var _terrain : HTerrain = null
+var _texture_set : HTerrainTextureSet = null
+
+var _texture_list_need_update := false
+var _empty_icon : Texture2D
+
+var _logger = HT_Logger.get_for(self)
+
+
+func _ready():
+ _empty_icon = load(EMPTY_ICON_TEXTURE_PATH)
+ if _empty_icon == null:
+ _logger.error(str("Failed to load empty icon ", EMPTY_ICON_TEXTURE_PATH))
+
+ # Default amount, will be updated when a terrain is assigned
+ _textures_list.clear()
+ for i in range(4):
+ _textures_list.add_item(str(i), _empty_icon)
+
+
+func set_terrain(terrain: HTerrain):
+ _terrain = terrain
+
+
+static func _get_slot_count(terrain: HTerrain) -> int:
+ var texture_set = terrain.get_texture_set()
+ if texture_set == null:
+ return 0
+ return texture_set.get_slots_count()
+
+
+func _process(delta: float):
+ var texture_set = null
+ if _terrain != null:
+ texture_set = _terrain.get_texture_set()
+
+ if _texture_set != texture_set:
+ if _texture_set != null:
+ _texture_set.changed.disconnect(_on_texture_set_changed)
+
+ _texture_set = texture_set
+
+ if _texture_set != null:
+ _texture_set.changed.connect(_on_texture_set_changed)
+
+ _update_texture_list()
+
+ if _texture_list_need_update:
+ _update_texture_list()
+ _texture_list_need_update = false
+
+
+func _on_texture_set_changed():
+ _texture_list_need_update = true
+
+
+func _update_texture_list():
+ _textures_list.clear()
+
+ if _terrain == null:
+ _set_buttons_active(false)
+ return
+ var texture_set := _terrain.get_texture_set()
+ if texture_set == null:
+ _set_buttons_active(false)
+ return
+ _set_buttons_active(true)
+
+ var slots_count := texture_set.get_slots_count()
+
+ match texture_set.get_mode():
+ HTerrainTextureSet.MODE_TEXTURES:
+ for slot_index in slots_count:
+ var texture := texture_set.get_texture(
+ slot_index, HTerrainTextureSet.TYPE_ALBEDO_BUMP)
+ var hint := _get_slot_hint_name(slot_index, _terrain.get_shader_type())
+ if texture == null:
+ texture = _empty_icon
+ _textures_list.add_item(hint, texture)
+
+ HTerrainTextureSet.MODE_TEXTURE_ARRAYS:
+ var texture_array = texture_set.get_texture_array(HTerrainTextureSet.TYPE_ALBEDO_BUMP)
+ for slot_index in slots_count:
+ var hint := _get_slot_hint_name(slot_index, _terrain.get_shader_type())
+ _textures_list.add_item(hint, texture_array, slot_index)
+
+
+func _set_buttons_active(active: bool):
+ for i in _buttons_container.get_child_count():
+ var child = _buttons_container.get_child(i)
+ if child is Button:
+ child.disabled = not active
+
+
+static func _get_slot_hint_name(i: int, stype: String) -> String:
+ if i == 3 and (stype == HTerrain.SHADER_CLASSIC4 or stype == HTerrain.SHADER_CLASSIC4_LITE):
+ return "cliff"
+ return str(i)
+
+
+func _on_TextureList_item_selected(index: int):
+ texture_selected.emit(index)
+
+
+func _on_TextureList_item_activated(index: int):
+ edit_pressed.emit(index)
+
+
+func _on_EditButton_pressed():
+ var selected_slot := _textures_list.get_selected_item()
+ if selected_slot == -1:
+ selected_slot = 0
+ edit_pressed.emit(selected_slot)
+
+
+func _on_ImportButton_pressed():
+ import_pressed.emit()
diff --git a/game/addons/zylann.hterrain/tools/texture_editor/texture_editor.tscn b/game/addons/zylann.hterrain/tools/texture_editor/texture_editor.tscn
new file mode 100644
index 0000000..885f344
--- /dev/null
+++ b/game/addons/zylann.hterrain/tools/texture_editor/texture_editor.tscn
@@ -0,0 +1,48 @@
+[gd_scene load_steps=3 format=2]
+
+[ext_resource path="res://addons/zylann.hterrain/tools/texture_editor/texture_editor.gd" type="Script" id=1]
+[ext_resource path="res://addons/zylann.hterrain/tools/texture_editor/texture_list.tscn" type="PackedScene" id=2]
+
+[node name="TextureEditor" type="Control"]
+offset_right = 352.0
+offset_bottom = 104.0
+custom_minimum_size = Vector2( 100, 0 )
+size_flags_horizontal = 3
+script = ExtResource( 1 )
+__meta__ = {
+"_edit_use_anchors_": false
+}
+
+[node name="TextureList" parent="." instance=ExtResource( 2 )]
+offset_bottom = -26.0
+
+[node name="HBoxContainer" type="HBoxContainer" parent="."]
+anchor_top = 1.0
+anchor_right = 1.0
+anchor_bottom = 1.0
+offset_top = -24.0
+__meta__ = {
+"_edit_use_anchors_": false
+}
+
+[node name="EditButton" type="Button" parent="HBoxContainer"]
+offset_right = 48.0
+offset_bottom = 24.0
+text = "Edit..."
+
+[node name="ImportButton" type="Button" parent="HBoxContainer"]
+offset_left = 52.0
+offset_right = 120.0
+offset_bottom = 24.0
+text = "Import..."
+
+[node name="Label" type="Label" parent="HBoxContainer"]
+offset_left = 124.0
+offset_top = 5.0
+offset_right = 179.0
+offset_bottom = 19.0
+text = "Textures"
+[connection signal="item_activated" from="TextureList" to="." method="_on_TextureList_item_activated"]
+[connection signal="item_selected" from="TextureList" to="." method="_on_TextureList_item_selected"]
+[connection signal="pressed" from="HBoxContainer/EditButton" to="." method="_on_EditButton_pressed"]
+[connection signal="pressed" from="HBoxContainer/ImportButton" to="." method="_on_ImportButton_pressed"]
diff --git a/game/addons/zylann.hterrain/tools/texture_editor/texture_list.gd b/game/addons/zylann.hterrain/tools/texture_editor/texture_list.gd
new file mode 100644
index 0000000..7391f85
--- /dev/null
+++ b/game/addons/zylann.hterrain/tools/texture_editor/texture_list.gd
@@ -0,0 +1,79 @@
+
+# I needed a custom container for this because textures edited by this plugin are often
+# unfit to display in a GUI, they need to go through a shader (either discarding alpha,
+# or picking layers of a TextureArray). Unfortunately, ItemList does not have custom item drawing,
+# and items cannot have individual shaders.
+# I could create new textures just for that but it would be expensive.
+
+@tool
+extends ScrollContainer
+
+const HT_TextureListItemScene = preload("./texture_list_item.tscn")
+const HT_TextureListItem = preload("./texture_list_item.gd")
+
+signal item_selected(index)
+signal item_activated(index)
+
+@onready var _container : Container = $Container
+
+
+var _selected_item := -1
+
+
+# TEST
+#func _ready():
+# add_item("First", load("res://addons/zylann.hterrain_demo/textures/ground/bricks_albedo_bump.png"), 0)
+# add_item("Second", load("res://addons/zylann.hterrain_demo/textures/ground/grass_albedo_bump.png"), 0)
+# add_item("Third", load("res://addons/zylann.hterrain_demo/textures/ground/leaves_albedo_bump.png"), 0)
+# add_item("Fourth", load("res://addons/zylann.hterrain_demo/textures/ground/sand_albedo_bump.png"), 0)
+# var texture_array = load("res://tests/texarray/textures/array_albedo_atlas.png")
+# add_item("Ninth", texture_array, 2)
+# add_item("Sixth", texture_array, 3)
+
+
+# Note: the texture can be a TextureArray, which does not inherit Texture
+func add_item(text: String, texture: Texture, texture_layer: int = 0):
+ var item : HT_TextureListItem = HT_TextureListItemScene.instantiate()
+ _container.add_child(item)
+ item.set_text(text)
+ item.set_texture(texture, texture_layer)
+
+
+func get_item_count() -> int:
+ return _container.get_child_count()
+
+
+func set_item_texture(index: int, tex: Texture, layer: int = 0):
+ var child : HT_TextureListItem = _container.get_child(index)
+ child.set_texture(tex, layer)
+
+
+func get_selected_item() -> int:
+ return _selected_item
+
+
+func clear():
+ for i in _container.get_child_count():
+ var child = _container.get_child(i)
+ if child is Control:
+ child.queue_free()
+ _selected_item = -1
+
+
+func _on_item_selected(item: HT_TextureListItem):
+ _selected_item = item.get_index()
+ for i in _container.get_child_count():
+ var child = _container.get_child(i)
+ if child is HT_TextureListItem and child != item:
+ child.set_selected(false, false)
+ item_selected.emit(_selected_item)
+
+
+func _on_item_activated(item: HT_TextureListItem):
+ item_activated.emit(item.get_index())
+
+
+func _draw():
+ # TODO Draw same background as Panel
+ # Draw a background
+ draw_rect(get_rect(), Color(0,0,0,0.3))
diff --git a/game/addons/zylann.hterrain/tools/texture_editor/texture_list.tscn b/game/addons/zylann.hterrain/tools/texture_editor/texture_list.tscn
new file mode 100644
index 0000000..df5e1e2
--- /dev/null
+++ b/game/addons/zylann.hterrain/tools/texture_editor/texture_list.tscn
@@ -0,0 +1,20 @@
+[gd_scene load_steps=3 format=2]
+
+[ext_resource path="res://addons/zylann.hterrain/tools/texture_editor/texture_list.gd" type="Script" id=1]
+[ext_resource path="res://addons/zylann.hterrain/tools/texture_editor/flow_container.gd" type="Script" id=2]
+
+[node name="TextureList" type="ScrollContainer"]
+anchor_right = 1.0
+anchor_bottom = 1.0
+scroll_horizontal_enabled = false
+script = ExtResource( 1 )
+__meta__ = {
+"_edit_use_anchors_": false
+}
+
+[node name="Container" type="Container" parent="."]
+offset_right = 800.0
+offset_bottom = 82.0
+custom_minimum_size = Vector2( 0, 82 )
+size_flags_horizontal = 3
+script = ExtResource( 2 )
diff --git a/game/addons/zylann.hterrain/tools/texture_editor/texture_list_item.gd b/game/addons/zylann.hterrain/tools/texture_editor/texture_list_item.gd
new file mode 100644
index 0000000..098f349
--- /dev/null
+++ b/game/addons/zylann.hterrain/tools/texture_editor/texture_list_item.gd
@@ -0,0 +1,72 @@
+@tool
+extends PanelContainer
+# Had to use PanelContainer, because due to variable font sizes in the editor,
+# the contents of the VBoxContainer can vary in size, and so in height.
+# Which means the entire item can have variable size, not just because of DPI.
+# In such cases, the hierarchy must be made of containers that grow based on their children.
+
+const HT_ColorMaterial = preload("./display_color_material.tres")
+const HT_ColorSliceShader = preload("./display_color_slice.gdshader")
+# TODO Can't preload because it causes the plugin to fail loading if assets aren't imported
+#const HT_DummyTexture = preload("../icons/empty.png")
+const DUMMY_TEXTURE_PATH = "res://addons/zylann.hterrain/tools/icons/empty.png"
+
+@onready var _texture_rect : TextureRect = $VB/TextureRect
+@onready var _label : Label = $VB/Label
+
+
+var _selected := false
+
+
+func set_text(text: String):
+ _label.text = text
+
+
+func set_texture(texture: Texture, texture_layer: int):
+ if texture is TextureLayered:
+ var mat = _texture_rect.material
+ if mat == null or not (mat is ShaderMaterial):
+ mat = ShaderMaterial.new()
+ mat.shader = HT_ColorSliceShader
+ _texture_rect.material = mat
+ mat.set_shader_parameter("u_texture_array", texture)
+ mat.set_shader_parameter("u_index", texture_layer)
+ _texture_rect.texture = load(DUMMY_TEXTURE_PATH)
+ else:
+ _texture_rect.texture = texture
+ _texture_rect.material = HT_ColorMaterial
+
+
+func _gui_input(event: InputEvent):
+ if event is InputEventMouseButton:
+ if event.pressed:
+ if event.button_index == MOUSE_BUTTON_LEFT:
+ grab_focus()
+ set_selected(true, true)
+ if event.double_click:
+ # Don't do this at home.
+ # I do it here because this script is very related to its container anyways.
+ get_parent().get_parent()._on_item_activated(self)
+
+
+func set_selected(selected: bool, notify: bool):
+ if selected == _selected:
+ return
+ _selected = selected
+ queue_redraw()
+ if _selected:
+ _label.modulate = Color(0,0,0)
+ else:
+ _label.modulate = Color(1,1,1)
+ if notify:
+ get_parent().get_parent()._on_item_selected(self)
+
+
+func _draw():
+ var color : Color
+ if _selected:
+ color = get_theme_color("accent_color", "Editor")
+ else:
+ color = Color(0.0, 0.0, 0.0, 0.5)
+ # Draw background
+ draw_rect(Rect2(Vector2(), size), color)
diff --git a/game/addons/zylann.hterrain/tools/texture_editor/texture_list_item.tscn b/game/addons/zylann.hterrain/tools/texture_editor/texture_list_item.tscn
new file mode 100644
index 0000000..4340f55
--- /dev/null
+++ b/game/addons/zylann.hterrain/tools/texture_editor/texture_list_item.tscn
@@ -0,0 +1,25 @@
+[gd_scene load_steps=2 format=3 uid="uid://daugk4kdnx6vy"]
+
+[ext_resource type="Script" path="res://addons/zylann.hterrain/tools/texture_editor/texture_list_item.gd" id="2"]
+
+[node name="TextureListItem" type="PanelContainer"]
+custom_minimum_size = Vector2(64, 80)
+offset_right = 64.0
+offset_bottom = 80.0
+focus_mode = 1
+script = ExtResource("2")
+
+[node name="VB" type="VBoxContainer" parent="."]
+layout_mode = 2
+mouse_filter = 2
+
+[node name="TextureRect" type="TextureRect" parent="VB"]
+custom_minimum_size = Vector2(60, 60)
+layout_mode = 2
+size_flags_vertical = 3
+mouse_filter = 2
+expand_mode = 1
+
+[node name="Label" type="Label" parent="VB"]
+layout_mode = 2
+text = "Texture"
diff --git a/game/addons/zylann.hterrain/tools/util/dialog_fitter.gd b/game/addons/zylann.hterrain/tools/util/dialog_fitter.gd
new file mode 100644
index 0000000..59bb194
--- /dev/null
+++ b/game/addons/zylann.hterrain/tools/util/dialog_fitter.gd
@@ -0,0 +1,53 @@
+
+# If you make a container-based UI inside a WindowDialog, there is a chance it will overflow
+# because WindowDialogs don't adjust by themselves. This happens when the user has a different
+# font size than yours, and can cause controls to be unusable (like buttons at the bottom).
+# This script adjusts the size of the parent WindowDialog based on the first Container it finds
+# when the node becomes visible.
+
+@tool
+# Needs to be a Control, otherwise we don't receive the notification...
+extends Control
+
+const HT_Util = preload("../../util/util.gd")
+
+
+func _notification(what: int):
+ if HT_Util.is_in_edited_scene(self):
+ return
+ if is_inside_tree() and what == Control.NOTIFICATION_VISIBILITY_CHANGED:
+ #print("Visible ", is_visible_in_tree(), ", ", visible)
+ call_deferred("_fit_to_contents")
+
+
+func _fit_to_contents():
+ var dialog : Window = get_parent()
+ for child in dialog.get_children():
+ if child is Container:
+ var child_rect : Rect2 = child.get_global_rect()
+ var dialog_rect := Rect2(Vector2(), dialog.size)
+ #print("Dialog: ", dialog_rect, ", contents: ", child_rect, " ", child.get_path())
+ if not dialog_rect.encloses(child_rect):
+ var margin : Vector2 = child.get_rect().position
+ #print("Fitting ", dialog.get_path(), " from ", dialog.rect_size,
+ # " to ", child_rect.size + margin * 2.0)
+ dialog.min_size = child_rect.size + margin * 2.0
+
+
+#func _process(delta):
+# update()
+
+# DEBUG
+#func _draw():
+# var self_global_pos = get_global_rect().position
+#
+# var dialog : Control = get_parent()
+# var dialog_rect := dialog.get_global_rect()
+# dialog_rect.position -= self_global_pos
+# draw_rect(dialog_rect, Color(1,1,0), false)
+#
+# for child in dialog.get_children():
+# if child is Container:
+# var child_rect : Rect2 = child.get_global_rect()
+# child_rect.position -= self_global_pos
+# draw_rect(child_rect, Color(1,1,0,0.1))
diff --git a/game/addons/zylann.hterrain/tools/util/dialog_fitter.tscn b/game/addons/zylann.hterrain/tools/util/dialog_fitter.tscn
new file mode 100644
index 0000000..2e3b00c
--- /dev/null
+++ b/game/addons/zylann.hterrain/tools/util/dialog_fitter.tscn
@@ -0,0 +1,10 @@
+[gd_scene load_steps=2 format=2]
+
+[ext_resource path="res://addons/zylann.hterrain/tools/util/dialog_fitter.gd" type="Script" id=1]
+
+[node name="DialogFitter" type="Control"]
+mouse_filter = 2
+script = ExtResource( 1 )
+__meta__ = {
+"_edit_use_anchors_": false
+}
diff --git a/game/addons/zylann.hterrain/tools/util/editor_util.gd b/game/addons/zylann.hterrain/tools/util/editor_util.gd
new file mode 100644
index 0000000..a6d9eff
--- /dev/null
+++ b/game/addons/zylann.hterrain/tools/util/editor_util.gd
@@ -0,0 +1,104 @@
+
+# Editor-specific utilities.
+# This script cannot be loaded in an exported game.
+
+@tool
+
+
+# This is normally an `EditorFileDialog`. I can't type-hint this one properly,
+# because when I test UI in isolation, I can't use `EditorFileDialog`.
+static func create_open_file_dialog() -> ConfirmationDialog:
+ var d
+ if Engine.is_editor_hint():
+ # TODO Workaround bug when editor-only classes are created in source code, even if not run
+ # https://github.com/godotengine/godot/issues/73525
+# d = EditorFileDialog.new()
+ d = ClassDB.instantiate(&"EditorFileDialog")
+ d.file_mode = EditorFileDialog.FILE_MODE_OPEN_FILE
+ d.access = EditorFileDialog.ACCESS_RESOURCES
+ else:
+ d = FileDialog.new()
+ d.file_mode = FileDialog.FILE_MODE_OPEN_FILE
+ d.access = FileDialog.ACCESS_RESOURCES
+ d.unresizable = false
+ return d
+
+
+static func create_open_dir_dialog() -> ConfirmationDialog:
+ var d
+ if Engine.is_editor_hint():
+ # TODO Workaround bug when editor-only classes are created in source code, even if not run
+ # https://github.com/godotengine/godot/issues/73525
+# d = EditorFileDialog.new()
+ d = ClassDB.instantiate(&"EditorFileDialog")
+ d.file_mode = EditorFileDialog.FILE_MODE_OPEN_DIR
+ d.access = EditorFileDialog.ACCESS_RESOURCES
+ else:
+ d = FileDialog.new()
+ d.file_mode = FileDialog.FILE_MODE_OPEN_DIR
+ d.access = FileDialog.ACCESS_RESOURCES
+ d.unresizable = false
+ return d
+
+
+# If you want to open using Image.load()
+static func create_open_image_dialog() -> ConfirmationDialog:
+ var d = create_open_file_dialog()
+ _add_image_filters(d)
+ return d
+
+
+# If you want to open using load(),
+# although it might still fail if the file is imported as Image...
+static func create_open_texture_dialog() -> ConfirmationDialog:
+ var d = create_open_file_dialog()
+ _add_texture_filters(d)
+ return d
+
+
+static func create_open_texture_array_dialog() -> ConfirmationDialog:
+ var d = create_open_file_dialog()
+ _add_texture_array_filters(d)
+ return d
+
+# TODO Post a proposal, we need a file dialog filtering on resource types, not on file extensions!
+
+static func _add_image_filters(file_dialog):
+ file_dialog.add_filter("*.png ; PNG files")
+ file_dialog.add_filter("*.jpg ; JPG files")
+ #file_dialog.add_filter("*.exr ; EXR files")
+
+
+static func _add_texture_filters(file_dialog):
+ _add_image_filters(file_dialog)
+ # Godot
+ file_dialog.add_filter("*.ctex ; CompressedTexture files")
+ # Packed textures
+ file_dialog.add_filter("*.packed_tex ; HTerrainPackedTexture files")
+
+
+static func _add_texture_array_filters(file_dialog):
+ _add_image_filters(file_dialog)
+ # Godot
+ file_dialog.add_filter("*.ctexarray ; TextureArray files")
+ # Packed textures
+ file_dialog.add_filter("*.packed_texarr ; HTerrainPackedTextureArray files")
+
+
+# Tries to load a texture with the ResourceLoader, and if it fails, attempts
+# to load it manually as an ImageTexture
+static func load_texture(path: String, logger) -> Texture:
+ var tex : Texture = load(path)
+ if tex != null:
+ return tex
+ # This can unfortunately happen when the editor didn't import assets yet.
+ # See https://github.com/godotengine/godot/issues/17483
+ logger.error(str("Failed to load texture ", path, ", attempting to load manually"))
+ var im := Image.new()
+ var err = im.load(path)
+ if err != OK:
+ logger.error(str("Failed to load image ", path))
+ return null
+ var itex := ImageTexture.create_from_image(im)
+ return itex
+
diff --git a/game/addons/zylann.hterrain/tools/util/interval_slider.gd b/game/addons/zylann.hterrain/tools/util/interval_slider.gd
new file mode 100644
index 0000000..481d08b
--- /dev/null
+++ b/game/addons/zylann.hterrain/tools/util/interval_slider.gd
@@ -0,0 +1,197 @@
+
+# Slider with two handles representing an interval.
+
+@tool
+extends Control
+
+const VALUE_LOW = 0
+const VALUE_HIGH = 1
+const VALUE_COUNT = 2
+
+const FG_MARGIN = 1
+
+signal changed
+
+var _min_value := 0.0
+var _max_value := 1.0
+var _values = [0.2, 0.6]
+var _grabbing := false
+
+
+func _get_property_list():
+ return [
+ {
+ "name": "min_value",
+ "type": TYPE_FLOAT,
+ "usage": PROPERTY_USAGE_EDITOR
+ },
+ {
+ "name": "max_value",
+ "type": TYPE_FLOAT,
+ "usage": PROPERTY_USAGE_EDITOR
+ },
+ {
+ "name": "range",
+ "type": TYPE_VECTOR2,
+ "usage": PROPERTY_USAGE_STORAGE
+ }
+ ]
+
+
+func _get(key: StringName):
+ match key:
+ &"min_value":
+ return _min_value
+ &"max_value":
+ return _max_value
+ &"range":
+ return Vector2(_min_value, _max_value)
+
+
+func _set(key: StringName, value):
+ match key:
+ &"min_value":
+ _min_value = min(value, _max_value)
+ queue_redraw()
+ &"max_value":
+ _max_value = max(value, _min_value)
+ queue_redraw()
+ &"range":
+ _min_value = value.x
+ _max_value = value.y
+
+
+func set_values(low: float, high: float):
+ if low > high:
+ low = high
+ if high < low:
+ high = low
+ _values[VALUE_LOW] = low
+ _values[VALUE_HIGH] = high
+ queue_redraw()
+
+
+func set_value(i: int, v: float, notify_change: bool):
+ var min_value = _min_value
+ var max_value = _max_value
+
+ match i:
+ VALUE_LOW:
+ max_value = _values[VALUE_HIGH]
+ VALUE_HIGH:
+ min_value = _values[VALUE_LOW]
+ _:
+ assert(false)
+
+ v = clampf(v, min_value, max_value)
+ if v != _values[i]:
+ _values[i] = v
+ queue_redraw()
+ if notify_change:
+ changed.emit()
+
+
+func get_value(i: int) -> float:
+ return _values[i]
+
+
+func get_low_value() -> float:
+ return _values[VALUE_LOW]
+
+
+func get_high_value() -> float:
+ return _values[VALUE_HIGH]
+
+
+func get_ratio(i: int) -> float:
+ return _value_to_ratio(_values[i])
+
+
+func get_low_ratio() -> float:
+ return get_ratio(VALUE_LOW)
+
+
+func get_high_ratio() -> float:
+ return get_ratio(VALUE_HIGH)
+
+
+func _ratio_to_value(r: float) -> float:
+ return r * (_max_value - _min_value) + _min_value
+
+
+func _value_to_ratio(v: float) -> float:
+ if absf(_max_value - _min_value) < 0.001:
+ return 0.0
+ return (v - _min_value) / (_max_value - _min_value)
+
+
+func _get_closest_index(ratio: float) -> int:
+ var distance_low := absf(ratio - get_low_ratio())
+ var distance_high := absf(ratio - get_high_ratio())
+ if distance_low < distance_high:
+ return VALUE_LOW
+ return VALUE_HIGH
+
+
+func _set_from_pixel(px: float):
+ var r := (px - FG_MARGIN) / (size.x - FG_MARGIN * 2.0)
+ var i := _get_closest_index(r)
+ var v := _ratio_to_value(r)
+ set_value(i, v, true)
+
+
+func _gui_input(event: InputEvent):
+ if event is InputEventMouseButton:
+ if event.pressed:
+ if event.button_index == MOUSE_BUTTON_LEFT:
+ _grabbing = true
+ _set_from_pixel(event.position.x)
+ else:
+ if event.button_index == MOUSE_BUTTON_LEFT:
+ _grabbing = false
+
+ elif event is InputEventMouseMotion:
+ if _grabbing:
+ _set_from_pixel(event.position.x)
+
+
+func _draw():
+ var grabber_width := 3
+ var background_v_margin := 0
+ var foreground_margin := FG_MARGIN
+ var grabber_color := Color(0.8, 0.8, 0.8)
+ var interval_color := Color(0.4,0.4,0.4)
+ var background_color := Color(0.1, 0.1, 0.1)
+
+ var control_rect := Rect2(Vector2(), size)
+
+ var bg_rect := Rect2(
+ control_rect.position.x,
+ control_rect.position.y + background_v_margin,
+ control_rect.size.x,
+ control_rect.size.y - 2 * background_v_margin)
+ draw_rect(bg_rect, background_color)
+
+ var fg_rect := control_rect.grow(-foreground_margin)
+
+ var low_ratio := get_low_ratio()
+ var high_ratio := get_high_ratio()
+
+ var low_x := fg_rect.position.x + low_ratio * fg_rect.size.x
+ var high_x := fg_rect.position.x + high_ratio * fg_rect.size.x
+
+ var interval_rect := Rect2(
+ low_x, fg_rect.position.y, high_x - low_x, fg_rect.size.y)
+ draw_rect(interval_rect, interval_color)
+
+ low_x = fg_rect.position.x + low_ratio * (fg_rect.size.x - grabber_width)
+ high_x = fg_rect.position.x + high_ratio * (fg_rect.size.x - grabber_width)
+
+ for x in [low_x, high_x]:
+ var grabber_rect := Rect2(
+ x,
+ fg_rect.position.y,
+ grabber_width,
+ fg_rect.size.y)
+ draw_rect(grabber_rect, grabber_color)
+
diff --git a/game/addons/zylann.hterrain/tools/util/resource_importer_texture.gd b/game/addons/zylann.hterrain/tools/util/resource_importer_texture.gd
new file mode 100644
index 0000000..b24f0e2
--- /dev/null
+++ b/game/addons/zylann.hterrain/tools/util/resource_importer_texture.gd
@@ -0,0 +1,19 @@
+@tool
+
+# Stuff not exposed by Godot for making .import files
+
+const COMPRESS_LOSSLESS = 0
+const COMPRESS_LOSSY = 1
+const COMPRESS_VRAM_COMPRESSED = 2
+const COMPRESS_VRAM_UNCOMPRESSED = 3
+const COMPRESS_BASIS_UNIVERSAL = 4
+
+const ROUGHNESS_DETECT = 0
+const ROUGHNESS_DISABLED = 1
+# Godot internally subtracts 2 to magically obtain a `Image.RoughnessChannel` enum
+# (also not exposed)
+const ROUGHNESS_RED = 2
+const ROUGHNESS_GREEN = 3
+const ROUGHNESS_BLUE = 4
+const ROUGHNESS_ALPHA = 5
+const ROUGHNESS_GRAY = 6
diff --git a/game/addons/zylann.hterrain/tools/util/resource_importer_texture_layered.gd b/game/addons/zylann.hterrain/tools/util/resource_importer_texture_layered.gd
new file mode 100644
index 0000000..ff77b1a
--- /dev/null
+++ b/game/addons/zylann.hterrain/tools/util/resource_importer_texture_layered.gd
@@ -0,0 +1,9 @@
+@tool
+
+# Stuff not exposed by Godot for making .import files
+
+const COMPRESS_LOSSLESS = 0
+const COMPRESS_LOSSY = 1
+const COMPRESS_VRAM_COMPRESSED = 2
+const COMPRESS_VRAM_UNCOMPRESSED = 3
+const COMPRESS_BASIS_UNIVERSAL = 4
diff --git a/game/addons/zylann.hterrain/tools/util/result.gd b/game/addons/zylann.hterrain/tools/util/result.gd
new file mode 100644
index 0000000..5e897f1
--- /dev/null
+++ b/game/addons/zylann.hterrain/tools/util/result.gd
@@ -0,0 +1,36 @@
+# Data structure to hold the result of a function that can be expected to fail.
+# The use case is to report errors back to the GUI and act accordingly,
+# instead of forgetting them to the console or having the script break on an assertion.
+# This is a C-like way of things, where the result can bubble, and does not require globals.
+
+@tool
+
+# Replace `success` with `error : int`?
+var success := false
+var value = null
+var message := ""
+var inner_result = null
+
+
+func _init(p_success: bool, p_message := "", p_inner = null):
+ success = p_success
+ message = p_message
+ inner_result = p_inner
+
+
+# TODO Can't type-hint self return
+func with_value(v):
+ value = v
+ return self
+
+
+func get_message() -> String:
+ var msg := message
+ if inner_result != null:
+ msg += "\n"
+ msg += inner_result.get_message()
+ return msg
+
+
+func is_ok() -> bool:
+ return success
diff --git a/game/addons/zylann.hterrain/tools/util/rich_text_label_hyperlinks.gd b/game/addons/zylann.hterrain/tools/util/rich_text_label_hyperlinks.gd
new file mode 100644
index 0000000..242fe6e
--- /dev/null
+++ b/game/addons/zylann.hterrain/tools/util/rich_text_label_hyperlinks.gd
@@ -0,0 +1,11 @@
+@tool
+extends RichTextLabel
+
+
+func _init():
+ meta_clicked.connect(_on_meta_clicked)
+
+
+func _on_meta_clicked(meta):
+ OS.shell_open(meta)
+
diff --git a/game/addons/zylann.hterrain/tools/util/spin_slider.gd b/game/addons/zylann.hterrain/tools/util/spin_slider.gd
new file mode 100644
index 0000000..fa690d5
--- /dev/null
+++ b/game/addons/zylann.hterrain/tools/util/spin_slider.gd
@@ -0,0 +1,385 @@
+@tool
+extends Control
+
+const FG_MARGIN = 2
+const MAX_DECIMALS_VISUAL = 3
+
+signal value_changed(value)
+
+
+var _value := 0.0
+@export var value: float:
+ get:
+ return _value
+ set(v):
+ set_value_no_notify(v)
+
+
+var _min_value := 0.0
+@export var min_value: float:
+ get:
+ return _min_value
+ set(v):
+ set_min_value(v)
+
+
+var _max_value := 100.0
+@export var max_value: float:
+ get:
+ return _max_value
+ set(v):
+ set_max_value(v)
+
+
+var _prefix := ""
+@export var prefix: String:
+ get:
+ return _prefix
+ set(v):
+ set_prefix(v)
+
+
+var _suffix := ""
+@export var suffix: String:
+ get:
+ return _suffix
+ set(v):
+ set_suffix(v)
+
+
+var _rounded := false
+@export var rounded: bool:
+ get:
+ return _rounded
+ set(v):
+ set_rounded(v)
+
+
+var _centered := true
+@export var centered: bool:
+ get:
+ return _centered
+ set(v):
+ set_centered(v)
+
+
+var _allow_greater := false
+@export var allow_greater: bool:
+ get:
+ return _allow_greater
+ set(v):
+ set_allow_greater(v)
+
+
+# There is still a limit when typing a larger value, but this one is to prevent software
+# crashes or freezes. The regular min and max values are for slider UX. Exceeding it should be
+# a corner case.
+var _greater_max_value := 10000.0
+@export var greater_max_value: float:
+ get:
+ return _greater_max_value
+ set(v):
+ set_greater_max_value(v)
+
+
+var _label : Label
+var _label2 : Label
+var _line_edit : LineEdit
+var _ignore_line_edit := false
+var _pressing := false
+var _grabbing := false
+var _press_pos := Vector2()
+
+
+func _init():
+ custom_minimum_size = Vector2(32, 28)
+
+ _label = Label.new()
+ _label.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER
+ _label.vertical_alignment = VERTICAL_ALIGNMENT_CENTER
+ _label.clip_text = true
+ #_label.size_flags_horizontal = Control.SIZE_EXPAND_FILL
+ _label.anchor_top = 0
+ _label.anchor_left = 0
+ _label.anchor_right = 1
+ _label.anchor_bottom = 1
+ _label.mouse_filter = Control.MOUSE_FILTER_IGNORE
+ _label.add_theme_color_override("font_color_shadow", Color(0,0,0,0.5))
+ _label.add_theme_constant_override("shadow_offset_x", 1)
+ _label.add_theme_constant_override("shadow_offset_y", 1)
+ add_child(_label)
+
+ _label2 = Label.new()
+ _label2.horizontal_alignment = HORIZONTAL_ALIGNMENT_LEFT
+ _label2.vertical_alignment = VERTICAL_ALIGNMENT_CENTER
+ _label2.clip_text = true
+ #_label.size_flags_horizontal = Control.SIZE_EXPAND_FILL
+ _label2.anchor_top = 0
+ _label2.anchor_left = 0
+ _label2.anchor_right = 1
+ _label2.anchor_bottom = 1
+ _label2.offset_left = 8
+ _label2.mouse_filter = Control.MOUSE_FILTER_IGNORE
+ _label2.add_theme_color_override("font_color_shadow", Color(0,0,0,0.5))
+ _label2.add_theme_constant_override("shadow_offset_x", 1)
+ _label2.add_theme_constant_override("shadow_offset_y", 1)
+ _label2.hide()
+ add_child(_label2)
+
+ _line_edit = LineEdit.new()
+ _line_edit.alignment = HORIZONTAL_ALIGNMENT_CENTER
+ _line_edit.anchor_top = 0
+ _line_edit.anchor_left = 0
+ _line_edit.anchor_right = 1
+ _line_edit.anchor_bottom = 1
+ _line_edit.gui_input.connect(_on_LineEdit_gui_input)
+ _line_edit.focus_exited.connect(_on_LineEdit_focus_exited)
+ _line_edit.text_submitted.connect(_on_LineEdit_text_submitted)
+ _line_edit.hide()
+ add_child(_line_edit)
+
+ mouse_default_cursor_shape = Control.CURSOR_HSIZE
+
+
+func _ready():
+ pass # Replace with function body.
+
+
+func set_centered(p_centered: bool):
+ _centered = p_centered
+ if _centered:
+ _label.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER
+ _label.offset_right = 0
+ _label2.hide()
+ else:
+ _label.horizontal_alignment = HORIZONTAL_ALIGNMENT_RIGHT
+ _label.offset_right = -8
+ _label2.show()
+ queue_redraw()
+
+
+func is_centered() -> bool:
+ return _centered
+
+
+func set_value_no_notify(v: float):
+ set_value(v, false, false)
+
+
+func set_value(v: float, notify_change: bool, use_slider_maximum: bool = false):
+ if _allow_greater and not use_slider_maximum:
+ v = clampf(v, _min_value, _greater_max_value)
+ else:
+ v = clampf(v, _min_value, _max_value)
+
+ if v != _value:
+ _value = v
+
+ queue_redraw()
+
+ if notify_change:
+ value_changed.emit(get_value())
+
+
+func get_value():
+ if _rounded:
+ return int(roundf(_value))
+ return _value
+
+
+func set_min_value(minv: float):
+ _min_value = minv
+ #queue_redraw()
+
+
+func get_min_value() -> float:
+ return _min_value
+
+
+func set_max_value(maxv: float):
+ _max_value = maxv
+ #queue_redraw()
+
+
+func get_max_value() -> float:
+ return _max_value
+
+
+func set_greater_max_value(gmax: float):
+ _greater_max_value = gmax
+
+
+func get_greater_max_value() -> float:
+ return _greater_max_value
+
+
+func set_rounded(b: bool):
+ _rounded = b
+ queue_redraw()
+
+
+func is_rounded() -> bool:
+ return _rounded
+
+
+func set_prefix(p_prefix: String):
+ _prefix = p_prefix
+ queue_redraw()
+
+
+func get_prefix() -> String:
+ return _prefix
+
+
+func set_suffix(p_suffix: String):
+ _suffix = p_suffix
+ queue_redraw()
+
+
+func get_suffix() -> String:
+ return _suffix
+
+
+func set_allow_greater(allow: bool):
+ _allow_greater = allow
+
+
+func is_allowing_greater() -> bool:
+ return _allow_greater
+
+
+func _set_from_pixel(px: float):
+ var r := (px - FG_MARGIN) / (size.x - FG_MARGIN * 2.0)
+ var v := _ratio_to_value(r)
+ set_value(v, true, true)
+
+
+func get_ratio() -> float:
+ return _value_to_ratio(get_value())
+
+
+func _ratio_to_value(r: float) -> float:
+ return r * (_max_value - _min_value) + _min_value
+
+
+func _value_to_ratio(v: float) -> float:
+ if absf(_max_value - _min_value) < 0.001:
+ return 0.0
+ return (v - _min_value) / (_max_value - _min_value)
+
+
+func _on_LineEdit_gui_input(event: InputEvent):
+ if event is InputEventKey:
+ if event.pressed:
+ if event.keycode == KEY_ESCAPE:
+ _ignore_line_edit = true
+ _hide_line_edit()
+ grab_focus()
+ _ignore_line_edit = false
+
+
+func _on_LineEdit_focus_exited():
+ if _ignore_line_edit:
+ return
+ _enter_text()
+
+
+func _on_LineEdit_text_submitted(text: String):
+ _enter_text()
+
+
+func _enter_text():
+ var s = _line_edit.text.strip_edges()
+ if s.is_valid_float():
+ var v := s.to_float()
+ if not _allow_greater:
+ v = minf(v, _max_value)
+ set_value(v, true, false)
+ _hide_line_edit()
+
+
+func _hide_line_edit():
+ _line_edit.hide()
+ _label.show()
+ queue_redraw()
+
+
+func _show_line_edit():
+ _line_edit.show()
+ _line_edit.text = str(get_value())
+ _line_edit.select_all()
+ _line_edit.grab_focus()
+ _label.hide()
+ queue_redraw()
+
+
+func _gui_input(event: InputEvent):
+ if event is InputEventMouseButton:
+ if event.pressed:
+ if event.button_index == MOUSE_BUTTON_LEFT:
+ _press_pos = event.position
+ _pressing = true
+ else:
+ if event.button_index == MOUSE_BUTTON_LEFT:
+ _pressing = false
+ if _grabbing:
+ _grabbing = false
+ _set_from_pixel(event.position.x)
+ else:
+ _show_line_edit()
+
+ elif event is InputEventMouseMotion:
+ if _pressing and _press_pos.distance_to(event.position) > 2.0:
+ _grabbing = true
+ if _grabbing:
+ _set_from_pixel(event.position.x)
+
+
+func _draw():
+ if _line_edit.visible:
+ return
+
+ #var grabber_width := 3
+ var background_v_margin := 0
+ var foreground_margin := FG_MARGIN
+ #var grabber_color := Color(0.8, 0.8, 0.8)
+ var interval_color := Color(0.4,0.4,0.4)
+ var background_color := Color(0.1, 0.1, 0.1)
+
+ var control_rect := Rect2(Vector2(), size)
+
+ var bg_rect := Rect2(
+ control_rect.position.x,
+ control_rect.position.y + background_v_margin,
+ control_rect.size.x,
+ control_rect.size.y - 2 * background_v_margin)
+ draw_rect(bg_rect, background_color)
+
+ var fg_rect := control_rect.grow(-foreground_margin)
+ # Clamping the ratio because the value can be allowed to exceed the slider's boundaries
+ var ratio := clampf(get_ratio(), 0.0, 1.0)
+ fg_rect.size.x *= ratio
+ draw_rect(fg_rect, interval_color)
+
+ var value_text := str(get_value())
+
+ var dot_pos := value_text.find(".")
+ if dot_pos != -1:
+ var decimal_count := len(value_text) - dot_pos
+ if decimal_count > MAX_DECIMALS_VISUAL:
+ value_text = value_text.substr(0, dot_pos + MAX_DECIMALS_VISUAL + 1)
+
+ if _centered:
+ var text := value_text
+ if _prefix != "":
+ text = str(_prefix, " ", text)
+ if _suffix != "":
+ text = str(text, " ", _suffix)
+ _label.text = text
+
+ else:
+ _label2.text = _prefix
+ var text := value_text
+ if _suffix != "":
+ text = str(text, " ", _suffix)
+ _label.text = text
diff --git a/game/addons/zylann.hterrain/tools/util/spin_slider.tscn b/game/addons/zylann.hterrain/tools/util/spin_slider.tscn
new file mode 100644
index 0000000..70a0da8
--- /dev/null
+++ b/game/addons/zylann.hterrain/tools/util/spin_slider.tscn
@@ -0,0 +1,13 @@
+[gd_scene load_steps=2 format=2]
+
+[ext_resource path="res://addons/zylann.hterrain/tools/util/spin_slider.gd" type="Script" id=1]
+
+[node name="SpinSlider" type="Control"]
+anchor_right = 1.0
+anchor_bottom = 1.0
+rect_min_size = Vector2( 32, 28 )
+mouse_default_cursor_shape = 10
+script = ExtResource( 1 )
+__meta__ = {
+"_edit_use_anchors_": false
+}