From 8ab1289d106f3c1b5b7df56bb5840c710c1dbfd0 Mon Sep 17 00:00:00 2001 From: Olivier Date: Tue, 6 Oct 2026 20:01:36 +0200 Subject: [PATCH] feat(tools): declarative site terrain baker and calibration recipe Co-Authored-By: Claude Sonnet 5.5 --- content/sites/vehicle_test_terrain.res | Bin 0 -> 21604 bytes .../captures/vehicle_test_terrain_preview.png | Bin 0 -> 2516 bytes .../vehicle_test_terrain_preview.png.import | 40 +++ scripts/quality_gate.sh | 8 + tests/run_load_checks.gd | 1 + tests/unit/run_site_baker_tests.gd | 71 +++++ tools/bake_site_terrain.gd | 58 ++++ tools/site_terrain_baker.gd | 258 ++++++++++++++++++ tools/sites/vehicle_test_terrain_recipe.gd | 25 ++ 9 files changed, 461 insertions(+) create mode 100644 content/sites/vehicle_test_terrain.res create mode 100644 docs/briefs/captures/vehicle_test_terrain_preview.png create mode 100644 docs/briefs/captures/vehicle_test_terrain_preview.png.import create mode 100644 tests/unit/run_site_baker_tests.gd create mode 100644 tools/bake_site_terrain.gd create mode 100644 tools/site_terrain_baker.gd create mode 100644 tools/sites/vehicle_test_terrain_recipe.gd diff --git a/content/sites/vehicle_test_terrain.res b/content/sites/vehicle_test_terrain.res new file mode 100644 index 0000000000000000000000000000000000000000..496f48d7c8a9230f59e87cf12905dc9e4bb4d3cb GIT binary patch literal 21604 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 B;E36b^U|?V@4sv&5Sa(k5B?ALz zpQnpsNJit_$;>GIuY({IMc%J9Dvw~;nDCc_*hEO_&e^YL8! z?6t}4-;5`f9!Wkkx50hq%%5W0|9-IxncA_(`2W+wy~hoE82gKbFHW^Sb-6dweABf^ z5$m!GUsDp34_s7t5xJOrBbrhFj&<^`&blSde;e-g+-U8;`|wt^VA0d}(Uu~*8;xgK z-dW_cY)dmQ$QQ`W_&D#~x2V^@qw4c6oRPVB{EE$!$rG!CKUJJfSrRsBM&%5tznhtM zsNb9Z={sAlao@%A(A3Gh&!7IgH1zw;_28ARpQ({9dUsrAa%{WS`E7R4Ddj-LQ!b}BC3s}}U-)mT@BZSP$~)fXvi%3EbRR4X zxvFWdsJaV`HcUK#mKHgjA8>3r#Bu#X6iSpm? zPwsu0X>F<|x#oJ+w3I7m2@+asBI`vo^g{AFw>~=dyzAr-h8<5b$H`n zm->B++=b;nSx2{=UTvrUSKjcBZbn3UdX3-p+iJzHf6P7M`8C|-eC+OLyceCeDAZ+a z?+>=+`Fg;5?@s^MJFE}S%#k~>{$)*5N0c_h9@Q!pBY&m?{9E--C+(|r_`}f9u~avz z?^kv39@%$=4;JV)hkRwfy5DT?{Tp-oJzV&e;-41POvv7Qt@?;a$=&O}t=_+R-<`Pk z>`v8&-cL`NuF8l0e>QdDRY$>}-shB@I?NR2t>j-N&u~ZU`^&wan-9y*zjn0F`HQZ^ zu{=eWv}sd{&S|PTg@i`)?Ap!3z~H5ML(JIvj>!4d22)m@czdL9Z)2j;mU&*9_N(f$ z&ExVolb%mmHmCU95u2jfPdYN#Za>`jEcLs#%pcpL-%B3X?pL*19cA)?>G}~r&8Ksg zU(TGRX7;X9&E>1nC6(i8A9wGx-n-a;(WNtg);)DwTN*hn>*g_^g$BZc-7`G>E|*x% zb#Vzz7u10DIp5`pqsmehC7q^YwRYEo&V4gT)K6Z0XG+k! z9WxzEBd1kHUb4|Y{b}7b{=5oXOc%_)hcr z<(DN^bA!B&#b(ca{Pf5F@QoYW&bd7C<>1u+!P zd_HHA$^Qb)GdgEHRXT6{Sd!T^C$Q8h=Go8NmnZ-Bb(v$W$LVgSWc5mzV`>Cb`8mnX zQ=1xI)!03sGdV{1+P4CoNkN;Ix6b)L_j7(|;MKa!^O|S4H%~p0w{<-HwC)Q9sSmit}>iC#G|B;^p#~=E6;Yv;0Jb3CP%+swUH@qRrB&&Ccc*c5B*V3 z^FGymkGySudj7@ajk)`$-}-WLsf6+7NLQ8RDwgTbdMx6% zQ=wdaB%7-NLsA#ANU4X}8{v%bP6yK8nBW zPTd3EwQWu^nZkCejy|u8bpl$qwKD(CU%PAeZ@zcUc8W^h3ctVBWl|E3yt@0v-5REE ztCoa)n7k$EfOvyT%Mb3)cRxSY2xW6T7)A`!3-yQefWxIZ1tJU_Y946h; z6_;ms8pxzZZi!mGExI6@2 z-#OuXT-KAmwbm^XJM~4+nu#fShK3ebw0@E_Yz;8HZmF}|{Ql&gw=3@5U3Yw^EE9vB z^1q9(6&M&|ydUm*m0i7DDqKyX@4)w+^Do|hn76a0W{JMb<`ZjXb%uPM_)}3;>?eD% z6yMylLVRy$zuvvG{F4!%UW@aSbHC2yaC*J%u-jVOY`@lhL&Lnj8-2&DXS!wB1hHGc zGYmVv_Tg4O!P>2>Tkw>Xr-``UCJ06CrRFK)k5pP9rvcrdJ*{iUCpC~izeJXZF2X2T*UXiO(%L^S6yP2l|S>Vk9EeO zu;iF`I~U(k+`i=Ht+KdBy!G!F{LWX}(wbGx(|2&I?@N(=E#(t_zuR-i@qL@>x948R zFGo5r3|_COEg{Uf|x#olduVUhcTxD82pFN!>3KZ|v?llK0M{%}{>c(|4~= zc7K&qeSf6vOCaBFTc^v{uFskOaNqU&r9YfjtlNL=X;uG3^Q{uy4;1Ggx&Nzl_MOrn zj|#3>+kNKyE^(RJO?TJA?g{PJHX0^fKbSGeB1zV~#qwdr8Q(qsnK()}{e3^-)e{B= O1_n=8KbLh*2~7Z7q!x?- literal 0 HcmV?d00001 diff --git a/docs/briefs/captures/vehicle_test_terrain_preview.png.import b/docs/briefs/captures/vehicle_test_terrain_preview.png.import new file mode 100644 index 0000000..c5bc213 --- /dev/null +++ b/docs/briefs/captures/vehicle_test_terrain_preview.png.import @@ -0,0 +1,40 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://crrgsnvuf3ttv" +path="res://.godot/imported/vehicle_test_terrain_preview.png-4e68e296dd4625902c8d2c03244ab133.ctex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://docs/briefs/captures/vehicle_test_terrain_preview.png" +dest_files=["res://.godot/imported/vehicle_test_terrain_preview.png-4e68e296dd4625902c8d2c03244ab133.ctex"] + +[params] + +compress/mode=0 +compress/high_quality=false +compress/lossy_quality=0.7 +compress/uastc_level=0 +compress/rdo_quality_loss=0.0 +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/channel_remap/red=0 +process/channel_remap/green=1 +process/channel_remap/blue=2 +process/channel_remap/alpha=3 +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/scripts/quality_gate.sh b/scripts/quality_gate.sh index 2ffbf89..cf8a391 100755 --- a/scripts/quality_gate.sh +++ b/scripts/quality_gate.sh @@ -123,6 +123,14 @@ check_godot_log \ "production site application unit tests" \ "$QUALITY_TEMP_DIRECTORY/site-application-unit-console.log" +printf 'Quality gate: site baker unit tests\n' +"$GODOT_EXECUTABLE" --headless \ + --log-file "$QUALITY_TEMP_DIRECTORY/site-baker-unit.log" \ + --path . \ + --script tests/unit/run_site_baker_tests.gd \ + 2>&1 | tee "$QUALITY_TEMP_DIRECTORY/site-baker-unit-console.log" +check_godot_log "site baker unit tests" "$QUALITY_TEMP_DIRECTORY/site-baker-unit-console.log" + printf 'Quality gate: production foundation integration\n' "$GODOT_EXECUTABLE" --headless \ --log-file "$QUALITY_TEMP_DIRECTORY/foundation-integration.log" \ diff --git a/tests/run_load_checks.gd b/tests/run_load_checks.gd index 26f868c..672bd65 100644 --- a/tests/run_load_checks.gd +++ b/tests/run_load_checks.gd @@ -4,6 +4,7 @@ const SEARCH_ROOTS := [ "res://src", "res://game", "res://content", + "res://tools", "res://scenes", "res://tests", ] diff --git a/tests/unit/run_site_baker_tests.gd b/tests/unit/run_site_baker_tests.gd new file mode 100644 index 0000000..3713530 --- /dev/null +++ b/tests/unit/run_site_baker_tests.gd @@ -0,0 +1,71 @@ +extends SceneTree + +const SonarMaterial = preload("res://game/domain/sonar/sonar_material.gd") +const SiteTerrainBaker = preload("res://tools/site_terrain_baker.gd") +const VehicleTestRecipe = preload("res://tools/sites/vehicle_test_terrain_recipe.gd") + +const TEST_COUNT := 10 + +var _failures: Array[String] = [] + + +func _init() -> void: + _test_plane_and_blob() + _test_materials_and_determinism() + + if _failures.is_empty(): + print("Site baker unit tests: %d passed, 0 failed." % TEST_COUNT) + quit(0) + else: + for failure: String in _failures: + push_error(failure) + print("Site baker unit tests: %d failed." % _failures.size()) + quit(1) + + +func _small_recipe() -> SiteTerrainBaker.Recipe: + var recipe := SiteTerrainBaker.Recipe.new() + recipe.columns = 21 + recipe.rows = 21 + recipe.cell_size_m = 2.0 + recipe.origin_x_m = -20.0 + recipe.origin_z_m = -20.0 + recipe.floor_elevation_m = -80.0 + recipe.rock_slope_degrees = 20.0 + return recipe + + +func _test_plane_and_blob() -> void: + var recipe := _small_recipe() + recipe.primitives.append(SiteTerrainBaker.SlopedPlane.new(30.0, 0.0, 0.1, 0.0, 0.0)) + var data := SiteTerrainBaker.bake(recipe) + _expect(data != null and data.validation_error().is_empty(), "plane recipe bakes valid data") + _expect(is_equal_approx(data.elevations_m[data.index_of(10, 10)], -30.0), "plane depth at the reference point") + _expect(is_equal_approx(data.elevations_m[data.index_of(10, 20)], -28.0), "plane slopes along +Z") + _expect(data.materials[data.index_of(10, 10)] == SonarMaterial.Value.SAND, "gentle plane is sand") + + recipe.primitives.append(SiteTerrainBaker.Blob.new(Vector2(0.0, 0.0), 4.0, 4.0, 10.0)) + data = SiteTerrainBaker.bake(recipe) + _expect(is_equal_approx(data.elevations_m[data.index_of(10, 10)], -20.0), "blob adds its amplitude at the centre") + _expect(data.materials[data.index_of(12, 10)] == SonarMaterial.Value.ROCK, "steep blob flank is rock") + + +func _test_materials_and_determinism() -> void: + var recipe := _small_recipe() + recipe.primitives.append(SiteTerrainBaker.SlopedPlane.new(30.0, 0.0, 0.0, 0.0, 0.0)) + recipe.primitives.append(SiteTerrainBaker.Roughness.new(1933, 0.05, 0.5)) + recipe.material_patches.append( + SiteTerrainBaker.MaterialPatch.new(Vector2(10.0, 10.0), 3.0, SonarMaterial.Value.ROCK) + ) + var first := SiteTerrainBaker.bake(recipe) + var second := SiteTerrainBaker.bake(recipe) + _expect(first.elevations_m == second.elevations_m, "same recipe bakes identical elevations") + _expect(first.materials == second.materials, "same recipe bakes identical materials") + _expect(first.materials[first.index_of(15, 15)] == SonarMaterial.Value.ROCK, "material patch overrides the slope rule") + var calibration := SiteTerrainBaker.bake(VehicleTestRecipe.recipe()) + _expect(calibration != null and calibration.validation_error().is_empty(), "calibration recipe bakes valid data") + + +func _expect(condition: bool, label: String) -> void: + if not condition: + _failures.append(label) diff --git a/tools/bake_site_terrain.gd b/tools/bake_site_terrain.gd new file mode 100644 index 0000000..6a69b3f --- /dev/null +++ b/tools/bake_site_terrain.gd @@ -0,0 +1,58 @@ +extends SceneTree + +const SiteTerrainBaker = preload("res://tools/site_terrain_baker.gd") + +const RECIPES := { + "vehicle_test": "res://tools/sites/vehicle_test_terrain_recipe.gd", + "zone1_vigilant": "res://tools/sites/zone1_vigilant_recipe.gd", +} +const OUTPUT_DIRECTORY := "res://content/sites" +const PREVIEW_DIRECTORY := "res://docs/briefs/captures" + + +func _init() -> void: + var arguments := OS.get_cmdline_user_args() + if arguments.is_empty() or not RECIPES.has(arguments[0]): + push_error("usage: -- <%s>" % " | ".join(RECIPES.keys())) + quit(1) + return + var name: String = arguments[0] + var recipe_script := load(RECIPES[name]) as GDScript + if recipe_script == null: + push_error("recipe script %s is missing" % RECIPES[name]) + quit(1) + return + var recipe: SiteTerrainBaker.Recipe = recipe_script.recipe() + var data := SiteTerrainBaker.bake(recipe) + if data == null: + push_error("recipe %s did not bake valid terrain" % name) + quit(1) + return + DirAccess.make_dir_recursive_absolute(OUTPUT_DIRECTORY) + DirAccess.make_dir_recursive_absolute(PREVIEW_DIRECTORY) + var output_path := "%s/%s_terrain.res" % [OUTPUT_DIRECTORY, name] + var save_error := ResourceSaver.save(data, output_path) + if save_error != OK: + push_error("cannot save %s (%d)" % [output_path, save_error]) + quit(1) + return + var preview_path := "%s/%s_terrain_preview.png" % [PREVIEW_DIRECTORY, name] + var preview_error := SiteTerrainBaker.write_preview_png(data, preview_path) + if preview_error != OK: + push_error("cannot write %s (%d)" % [preview_path, preview_error]) + quit(1) + return + var rock_cells := 0 + for material: int in data.materials: + if material == SiteTerrainBaker.SonarMaterial.Value.ROCK: + rock_cells += 1 + var minimum := data.elevations_m[0] + var maximum := data.elevations_m[0] + for elevation: float in data.elevations_m: + minimum = minf(minimum, elevation) + maximum = maxf(maximum, elevation) + print("Baked %s: %d x %d cells, elevation %.1f .. %.1f m, rock %.0f %%" % [ + name, data.columns, data.rows, minimum, maximum, + 100.0 * float(rock_cells) / float(data.materials.size()) + ]) + quit(0) diff --git a/tools/site_terrain_baker.gd b/tools/site_terrain_baker.gd new file mode 100644 index 0000000..4de46a9 --- /dev/null +++ b/tools/site_terrain_baker.gd @@ -0,0 +1,258 @@ +class_name SiteTerrainBaker +extends RefCounted + +const SonarMaterial = preload("res://game/domain/sonar/sonar_material.gd") +const SiteTerrainData = preload("res://game/domain/sites/site_terrain_data.gd") +const TerrainHeightField = preload("res://game/domain/sites/terrain_height_field.gd") + + +class Primitive extends RefCounted: + func elevation_delta(_x_m: float, _z_m: float) -> float: + return 0.0 + + static func distance_to_segment(point: Vector2, start: Vector2, end: Vector2) -> float: + var segment := end - start + var length_squared := segment.length_squared() + if length_squared == 0.0: + return point.distance_to(start) + var t := clampf((point - start).dot(segment) / length_squared, 0.0, 1.0) + return point.distance_to(start + segment * t) + + static func end_taper(point: Vector2, start: Vector2, end: Vector2, taper_m: float) -> float: + if taper_m <= 0.0: + return 1.0 + var segment := end - start + var length := segment.length() + if length == 0.0: + return 1.0 + var along := clampf((point - start).dot(segment) / length, 0.0, length) + return smoothstep(0.0, taper_m, along) * smoothstep(0.0, taper_m, length - along) + + +class SlopedPlane extends Primitive: + var depth_m: float + var slope_x: float + var slope_z: float + var reference_x_m: float + var reference_z_m: float + + func _init(p_depth_m: float, p_slope_x: float, p_slope_z: float, p_reference_x_m: float, p_reference_z_m: float) -> void: + depth_m = p_depth_m + slope_x = p_slope_x + slope_z = p_slope_z + reference_x_m = p_reference_x_m + reference_z_m = p_reference_z_m + + func elevation_delta(x_m: float, z_m: float) -> float: + return -depth_m + slope_x * (x_m - reference_x_m) + slope_z * (z_m - reference_z_m) + + +class Blob extends Primitive: + var centre: Vector2 + var sigma_x_m: float + var sigma_z_m: float + var amplitude_m: float + + func _init(p_centre: Vector2, p_sigma_x_m: float, p_sigma_z_m: float, p_amplitude_m: float) -> void: + centre = p_centre + sigma_x_m = p_sigma_x_m + sigma_z_m = p_sigma_z_m + amplitude_m = p_amplitude_m + + func elevation_delta(x_m: float, z_m: float) -> float: + var dx := (x_m - centre.x) / sigma_x_m + var dz := (z_m - centre.y) / sigma_z_m + return amplitude_m * exp(-(dx * dx + dz * dz)) + + +class Ridge extends Primitive: + var start: Vector2 + var end: Vector2 + var sigma_m: float + var amplitude_m: float + var taper_m: float + + func _init(p_start: Vector2, p_end: Vector2, p_sigma_m: float, p_amplitude_m: float, p_taper_m: float) -> void: + start = p_start + end = p_end + sigma_m = p_sigma_m + amplitude_m = p_amplitude_m + taper_m = p_taper_m + + func elevation_delta(x_m: float, z_m: float) -> float: + var point := Vector2(x_m, z_m) + var distance := distance_to_segment(point, start, end) + var profile := exp(-pow(distance / sigma_m, 2.0)) + return amplitude_m * profile * end_taper(point, start, end, taper_m) + + +class Groove extends Primitive: + var start: Vector2 + var end: Vector2 + var half_width_m: float + var depth_m: float + var wall_softness_m: float + + func _init(p_start: Vector2, p_end: Vector2, p_half_width_m: float, p_depth_m: float, p_wall_softness_m: float) -> void: + start = p_start + end = p_end + half_width_m = p_half_width_m + depth_m = p_depth_m + wall_softness_m = p_wall_softness_m + + func elevation_delta(x_m: float, z_m: float) -> float: + var point := Vector2(x_m, z_m) + var distance := distance_to_segment(point, start, end) + var inside := 1.0 - smoothstep( + half_width_m - wall_softness_m, + half_width_m + wall_softness_m, + distance + ) + return -depth_m * inside * end_taper(point, start, end, half_width_m) + + +class Step extends Primitive: + var point: Vector2 + var normal: Vector2 + var drop_m: float + var softness_m: float + var slope_beyond: float + + func _init(p_point: Vector2, p_normal: Vector2, p_drop_m: float, p_softness_m: float, p_slope_beyond: float) -> void: + point = p_point + normal = p_normal.normalized() + drop_m = p_drop_m + softness_m = p_softness_m + slope_beyond = p_slope_beyond + + func elevation_delta(x_m: float, z_m: float) -> float: + var signed_distance := (Vector2(x_m, z_m) - point).dot(normal) + var step := drop_m * smoothstep(-softness_m, softness_m, signed_distance) + var slope := maxf(signed_distance - softness_m, 0.0) * slope_beyond + return -(step + slope) + + +class Ripples extends Primitive: + var direction: Vector2 + var wavelength_m: float + var amplitude_m: float + var centre: Vector2 + var radius_m: float + + func _init(p_direction: Vector2, p_wavelength_m: float, p_amplitude_m: float, p_centre: Vector2, p_radius_m: float) -> void: + direction = p_direction.normalized() + wavelength_m = p_wavelength_m + amplitude_m = p_amplitude_m + centre = p_centre + radius_m = p_radius_m + + func elevation_delta(x_m: float, z_m: float) -> float: + var point := Vector2(x_m, z_m) + var phase := point.dot(direction) * TAU / wavelength_m + var falloff := 1.0 + if radius_m > 0.0: + falloff = 1.0 - smoothstep(radius_m * 0.6, radius_m, point.distance_to(centre)) + return amplitude_m * sin(phase) * falloff + + +class Roughness extends Primitive: + var amplitude_m: float + var _noise: FastNoiseLite + + func _init(p_seed: int, p_frequency: float, p_amplitude_m: float) -> void: + amplitude_m = p_amplitude_m + _noise = FastNoiseLite.new() + _noise.noise_type = FastNoiseLite.TYPE_SIMPLEX_SMOOTH + _noise.seed = p_seed + _noise.frequency = p_frequency + _noise.fractal_type = FastNoiseLite.FRACTAL_FBM + _noise.fractal_octaves = 3 + + func elevation_delta(x_m: float, z_m: float) -> float: + return _noise.get_noise_2d(x_m, z_m) * amplitude_m + + +class MaterialPatch extends RefCounted: + var centre: Vector2 + var radius_m: float + var material: int + + func _init(p_centre: Vector2, p_radius_m: float, p_material: int) -> void: + centre = p_centre + radius_m = p_radius_m + material = p_material + + +class Recipe extends RefCounted: + var columns: int = 2 + var rows: int = 2 + var cell_size_m: float = 1.0 + var origin_x_m: float = 0.0 + var origin_z_m: float = 0.0 + var floor_elevation_m: float = -1000.0 + var rock_slope_degrees: float = 20.0 + var rock_reflectivity: float = 0.72 + var sand_reflectivity: float = 0.45 + var primitives: Array[Primitive] = [] + var material_patches: Array[MaterialPatch] = [] + + +static func bake(recipe: Recipe) -> SiteTerrainData: + var data := SiteTerrainData.new() + data.columns = recipe.columns + data.rows = recipe.rows + data.cell_size_m = recipe.cell_size_m + data.origin_x_m = recipe.origin_x_m + data.origin_z_m = recipe.origin_z_m + data.rock_reflectivity = recipe.rock_reflectivity + data.sand_reflectivity = recipe.sand_reflectivity + var cell_count := recipe.columns * recipe.rows + # Packed arrays are passed by value in GDScript: fill locals, assign once. + var elevations := PackedFloat32Array() + var materials := PackedByteArray() + elevations.resize(cell_count) + materials.resize(cell_count) + for row in recipe.rows: + var z_m := recipe.origin_z_m + float(row) * recipe.cell_size_m + for column in recipe.columns: + var x_m := recipe.origin_x_m + float(column) * recipe.cell_size_m + var elevation := 0.0 + for primitive: Primitive in recipe.primitives: + elevation += primitive.elevation_delta(x_m, z_m) + elevations[data.index_of(column, row)] = maxf(elevation, recipe.floor_elevation_m) + materials[data.index_of(column, row)] = SonarMaterial.Value.SAND + data.elevations_m = elevations + data.materials = materials + var field := TerrainHeightField.from_data(data) + if field == null: + return null + var rock_cosine := cos(deg_to_rad(recipe.rock_slope_degrees)) + for row in recipe.rows: + for column in recipe.columns: + if field.normal_at_cell(column, row).y < rock_cosine: + materials[data.index_of(column, row)] = SonarMaterial.Value.ROCK + for patch: MaterialPatch in recipe.material_patches: + for row in recipe.rows: + var z_m := recipe.origin_z_m + float(row) * recipe.cell_size_m + for column in recipe.columns: + var x_m := recipe.origin_x_m + float(column) * recipe.cell_size_m + if Vector2(x_m, z_m).distance_to(patch.centre) <= patch.radius_m: + materials[data.index_of(column, row)] = patch.material + data.materials = materials + return data + + +static func write_preview_png(data: SiteTerrainData, path: String) -> Error: + var field := TerrainHeightField.from_data(data) + if field == null: + return ERR_INVALID_DATA + var image := Image.create(data.columns, data.rows, false, Image.FORMAT_RGB8) + var span := maxf(field.maximum_elevation_m() - field.minimum_elevation_m(), 0.001) + for row in data.rows: + for column in data.columns: + var ratio := (field.elevation_at_cell(column, row) - field.minimum_elevation_m()) / span + var color := Color(ratio, ratio, ratio) + if field.material_at_cell(column, row) == SonarMaterial.Value.ROCK: + color.r = minf(color.r + 0.16, 1.0) + image.set_pixel(column, row, color) + return image.save_png(path) diff --git a/tools/sites/vehicle_test_terrain_recipe.gd b/tools/sites/vehicle_test_terrain_recipe.gd new file mode 100644 index 0000000..b5baf29 --- /dev/null +++ b/tools/sites/vehicle_test_terrain_recipe.gd @@ -0,0 +1,25 @@ +class_name VehicleTestTerrainRecipe +extends RefCounted + +const SiteTerrainBaker = preload("res://tools/site_terrain_baker.gd") + + +static func recipe() -> SiteTerrainBaker.Recipe: + var result := SiteTerrainBaker.Recipe.new() + result.columns = 61 + result.rows = 69 + result.cell_size_m = 3.0 + result.origin_x_m = -90.0 + result.origin_z_m = -176.0 + result.floor_elevation_m = -95.0 + result.rock_slope_degrees = 20.0 + result.primitives = [ + SiteTerrainBaker.SlopedPlane.new(40.0, 0.0, 0.19, 0.0, 28.0), + SiteTerrainBaker.Blob.new(Vector2(-24.0, -48.0), sqrt(210.0), sqrt(980.0), 13.0), + SiteTerrainBaker.Blob.new(Vector2(25.0, -58.0), sqrt(250.0), sqrt(1150.0), 15.0), + SiteTerrainBaker.Blob.new(Vector2(0.0, -66.0), sqrt(120.0), sqrt(2500.0), -8.5), + SiteTerrainBaker.Blob.new(Vector2(-5.0, -125.0), sqrt(900.0), sqrt(620.0), 8.0), + SiteTerrainBaker.Ripples.new(Vector2(1.0, 0.0), TAU / 0.075, 0.9, Vector2.ZERO, 0.0), + SiteTerrainBaker.Ripples.new(Vector2(0.0, 1.0), TAU / 0.048, 0.9, Vector2.ZERO, 0.0), + ] + return result