From 26f29ce14f8959705d7287f8af9d1d1fd2035583 Mon Sep 17 00:00:00 2001 From: Olivier Date: Tue, 6 Oct 2026 21:39:30 +0200 Subject: [PATCH] feat(tools): Strata modifier quantises steep walls into ledges Co-Authored-By: Claude Fable 5.1 --- tests/unit/run_site_baker_tests.gd | 40 +++++++++++++++++++++++++++++- tools/site_terrain_baker.gd | 39 +++++++++++++++++++++++++++++ 2 files changed, 78 insertions(+), 1 deletion(-) diff --git a/tests/unit/run_site_baker_tests.gd b/tests/unit/run_site_baker_tests.gd index f81e68f..f8081e5 100644 --- a/tests/unit/run_site_baker_tests.gd +++ b/tests/unit/run_site_baker_tests.gd @@ -8,7 +8,7 @@ const SiteTerrainData = preload("res://game/domain/sites/site_terrain_data.gd") const ZONE1_TERRAIN_PATH := "res://content/sites/zone1_vigilant_terrain.res" -const TEST_COUNT := 36 +const TEST_COUNT := 40 var _failures: Array[String] = [] @@ -22,6 +22,7 @@ func _init() -> void: _test_escarpment() _test_outcrop() _test_boulder_field() + _test_strata() if _failures.is_empty(): print("Site baker unit tests: %d passed, 0 failed." % TEST_COUNT) @@ -174,6 +175,43 @@ func _test_boulder_field() -> void: _expect(stacked.elevation_delta(0.0, 0.0) <= 4.0 + 0.001, "overlapping boulders combine by maximum, not by sum") +func _test_strata() -> void: + # A 45 degree plane sampled every 0.5 m: four cells per 2 m layer. + var recipe := _small_recipe() + recipe.columns = 41 + recipe.rows = 41 + recipe.cell_size_m = 0.5 + recipe.origin_x_m = -10.0 + recipe.origin_z_m = -10.0 + recipe.primitives.append(SiteTerrainBaker.SlopedPlane.new(30.0, 1.0, 0.0, 0.0, 0.0)) + var plain := SiteTerrainBaker.bake(recipe) + recipe.modifiers.append(SiteTerrainBaker.Strata.new(2.0, 0.9, 20.0, 35.0, Vector2.ZERO, 0.0)) + var terraced := SiteTerrainBaker.bake(recipe) + var flat_cells := 0 + var riser_cells := 0 + for column in range(5, 35): + var rise := terraced.elevations_m[terraced.index_of(column + 1, 20)] - terraced.elevations_m[terraced.index_of(column, 20)] + if rise < 0.1: + flat_cells += 1 + elif rise > 0.9: + riser_cells += 1 + _expect(flat_cells >= 8 and riser_cells >= 4, "strata turn a steep slope into ledges and risers (%d flat, %d risers)" % [flat_cells, riser_cells]) + _expect(is_equal_approx(terraced.elevations_m[terraced.index_of(20, 20)], plain.elevations_m[plain.index_of(20, 20)]), "strata keep the layer boundaries where they were") + + var flat := _small_recipe() + flat.primitives.append(SiteTerrainBaker.SlopedPlane.new(30.3, 0.0, 0.0, 0.0, 0.0)) + flat.modifiers.append(SiteTerrainBaker.Strata.new(2.0, 0.9, 20.0, 35.0, Vector2.ZERO, 0.0)) + var flat_data := SiteTerrainBaker.bake(flat) + _expect(is_equal_approx(flat_data.elevations_m[flat_data.index_of(10, 10)], -30.3), "strata leave flat ground alone") + + var identity := _small_recipe() + identity.primitives.append(SiteTerrainBaker.SlopedPlane.new(30.0, 1.0, 0.0, 0.0, 0.0)) + var before := SiteTerrainBaker.bake(identity) + identity.modifiers.append(SiteTerrainBaker.Strata.new(2.0, 0.0, 20.0, 35.0, Vector2.ZERO, 0.0)) + var after := SiteTerrainBaker.bake(identity) + _expect(before.elevations_m == after.elevations_m, "sharpness 0 is the identity") + + func _test_zone1_artefact_is_stable() -> void: var versioned := load(ZONE1_TERRAIN_PATH) as SiteTerrainData _expect(versioned != null, "versioned zone 1 terrain loads") diff --git a/tools/site_terrain_baker.gd b/tools/site_terrain_baker.gd index 2ec7ce4..4cc2220 100644 --- a/tools/site_terrain_baker.gd +++ b/tools/site_terrain_baker.gd @@ -415,6 +415,45 @@ class Modifier extends RefCounted: return elevation_m +## Quantises steep ground into horizontal ledges layer_height_m apart: the stratified +## walls of a canyon or cliff. Flat floors and summits are below minimum_slope_degrees and +## stay untouched; the effect is full from full_slope_degrees. sharpness 0 leaves the +## field unchanged, 1 cuts hard steps. radius_m 0 applies everywhere. +class Strata extends Modifier: + var layer_height_m: float + var sharpness: float + var centre: Vector2 + var radius_m: float + var _minimum_tangent: float + var _full_tangent: float + + func _init( + p_layer_height_m: float, + p_sharpness: float, + p_minimum_slope_degrees: float, + p_full_slope_degrees: float, + p_centre: Vector2, + p_radius_m: float + ) -> void: + layer_height_m = p_layer_height_m + sharpness = clampf(p_sharpness, 0.0, 0.999) + centre = p_centre + radius_m = p_radius_m + _minimum_tangent = tan(deg_to_rad(p_minimum_slope_degrees)) + _full_tangent = tan(deg_to_rad(p_full_slope_degrees)) + + func modify(x_m: float, z_m: float, elevation_m: float, gradient: Vector2) -> float: + var weight := smoothstep(_minimum_tangent, _full_tangent, gradient.length()) + if radius_m > 0.0: + weight *= 1.0 - smoothstep(radius_m * 0.8, radius_m, Vector2(x_m, z_m).distance_to(centre)) + if weight <= 0.0: + return elevation_m + var layer := floorf(elevation_m / layer_height_m) + var fraction := elevation_m / layer_height_m - layer + var sharpened := clampf((fraction - 0.5) / (1.0 - sharpness) + 0.5, 0.0, 1.0) + return lerpf(elevation_m, (layer + sharpened) * layer_height_m, weight) + + class MaterialPatch extends RefCounted: var centre: Vector2 var radius_m: float