From 520a62863b7780531ab1cc17ed3e320ab7b823ff Mon Sep 17 00:00:00 2001 From: Olivier Date: Tue, 6 Oct 2026 21:35:42 +0200 Subject: [PATCH] feat(tools): modifier phase and per-primitive material hook in the site baker The bake order is now primitive sum, modifiers (with the local gradient), floor clamp, slope material, primitive materials, material patches. A regression test checks the Banc du Vigilant still bakes byte for byte. Co-Authored-By: Claude Fable 5.1 --- tests/unit/run_site_baker_tests.gd | 61 +++++++++++++++++++++++++++++- tools/site_terrain_baker.gd | 60 ++++++++++++++++++++++++++++- 2 files changed, 118 insertions(+), 3 deletions(-) diff --git a/tests/unit/run_site_baker_tests.gd b/tests/unit/run_site_baker_tests.gd index 3713530..d3714da 100644 --- a/tests/unit/run_site_baker_tests.gd +++ b/tests/unit/run_site_baker_tests.gd @@ -3,8 +3,12 @@ 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 Zone1Recipe = preload("res://tools/sites/zone1_vigilant_recipe.gd") +const SiteTerrainData = preload("res://game/domain/sites/site_terrain_data.gd") -const TEST_COUNT := 10 +const ZONE1_TERRAIN_PATH := "res://content/sites/zone1_vigilant_terrain.res" + +const TEST_COUNT := 16 var _failures: Array[String] = [] @@ -12,6 +16,8 @@ var _failures: Array[String] = [] func _init() -> void: _test_plane_and_blob() _test_materials_and_determinism() + _test_modifiers_and_primitive_materials() + _test_zone1_artefact_is_stable() if _failures.is_empty(): print("Site baker unit tests: %d passed, 0 failed." % TEST_COUNT) @@ -66,6 +72,59 @@ func _test_materials_and_determinism() -> void: _expect(calibration != null and calibration.validation_error().is_empty(), "calibration recipe bakes valid data") +## Test doubles for the extension points: a modifier that lifts the field by one metre +## and a primitive that forces rock on a flat plane. +class Lift extends SiteTerrainBaker.Modifier: + func modify(_x_m: float, _z_m: float, elevation_m: float, _gradient: Vector2) -> float: + return elevation_m + 1.0 + + +class GradientProbe extends SiteTerrainBaker.Modifier: + var gradient_at_centre := Vector2.ZERO + + func modify(x_m: float, z_m: float, elevation_m: float, gradient: Vector2) -> float: + if is_zero_approx(x_m) and is_zero_approx(z_m): + gradient_at_centre = gradient + return elevation_m + + +class RockDisc extends SiteTerrainBaker.Primitive: + func material_at(x_m: float, z_m: float) -> int: + if Vector2(x_m, z_m).length() <= 5.0: + return SonarMaterial.Value.ROCK + return SiteTerrainBaker.NO_MATERIAL + + +func _test_modifiers_and_primitive_materials() -> void: + var recipe := _small_recipe() + recipe.primitives.append(SiteTerrainBaker.SlopedPlane.new(30.0, 0.2, 0.0, 0.0, 0.0)) + var probe := GradientProbe.new() + recipe.modifiers.append(probe) + recipe.modifiers.append(Lift.new()) + var data := SiteTerrainBaker.bake(recipe) + _expect(is_equal_approx(data.elevations_m[data.index_of(10, 10)], -29.0), "modifier runs after the primitive sum") + _expect(probe.gradient_at_centre.is_equal_approx(Vector2(0.2, 0.0)), "modifier receives the local gradient (%s)" % probe.gradient_at_centre) + + var flat := _small_recipe() + flat.primitives.append(SiteTerrainBaker.SlopedPlane.new(30.0, 0.0, 0.0, 0.0, 0.0)) + flat.primitives.append(RockDisc.new()) + var flat_data := SiteTerrainBaker.bake(flat) + _expect(flat_data.materials[flat_data.index_of(10, 10)] == SonarMaterial.Value.ROCK, "primitive material wins over the slope rule") + _expect(flat_data.materials[flat_data.index_of(0, 0)] == SonarMaterial.Value.SAND, "primitive material is local") + + +func _test_zone1_artefact_is_stable() -> void: + var versioned := load(ZONE1_TERRAIN_PATH) as SiteTerrainData + _expect(versioned != null, "versioned zone 1 terrain loads") + if versioned == null: + return + var baked := SiteTerrainBaker.bake(Zone1Recipe.recipe()) + _expect( + baked.elevations_m == versioned.elevations_m and baked.materials == versioned.materials, + "zone 1 recipe still bakes the versioned artefact byte for byte" + ) + + func _expect(condition: bool, label: String) -> void: if not condition: _failures.append(label) diff --git a/tools/site_terrain_baker.gd b/tools/site_terrain_baker.gd index c4ae378..9f011f0 100644 --- a/tools/site_terrain_baker.gd +++ b/tools/site_terrain_baker.gd @@ -5,10 +5,19 @@ const SiteTerrainData = preload("res://game/domain/sites/site_terrain_data.gd") const TerrainHeightField = preload("res://game/domain/sites/terrain_height_field.gd") +## A primitive returning this from material_at leaves the slope rule in charge. +const NO_MATERIAL := -1 + + class Primitive extends RefCounted: func elevation_delta(_x_m: float, _z_m: float) -> float: return 0.0 + ## Material imposed by this primitive at (x, z), or NO_MATERIAL. Applied after the + ## slope rule, in recipe order, before the material patches. + func material_at(_x_m: float, _z_m: float) -> int: + return NO_MATERIAL + static func distance_to_segment(point: Vector2, start: Vector2, end: Vector2) -> float: var segment := end - start var length_squared := segment.length_squared() @@ -171,6 +180,13 @@ class Roughness extends Primitive: return _noise.get_noise_2d(x_m, z_m) * amplitude_m +## Reshapes the summed elevation once every primitive has been added. gradient is the +## local slope Vector2(dE/dx, dE/dz) of the field being modified, by central differences. +class Modifier extends RefCounted: + func modify(_x_m: float, _z_m: float, elevation_m: float, _gradient: Vector2) -> float: + return elevation_m + + class MaterialPatch extends RefCounted: var centre: Vector2 var radius_m: float @@ -193,9 +209,12 @@ class Recipe extends RefCounted: var rock_reflectivity: float = 0.72 var sand_reflectivity: float = 0.45 var primitives: Array[Primitive] = [] + var modifiers: Array[Modifier] = [] var material_patches: Array[MaterialPatch] = [] +## Bake order: primitive sum, modifiers in order, floor clamp, slope material, +## primitive materials in order, material patches. static func bake(recipe: Recipe) -> SiteTerrainData: var data := SiteTerrainData.new() data.columns = recipe.columns @@ -218,8 +237,12 @@ static func bake(recipe: Recipe) -> SiteTerrainData: 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 + elevations[data.index_of(column, row)] = elevation + for modifier: Modifier in recipe.modifiers: + elevations = _apply_modifier(recipe, modifier, elevations) + for index in cell_count: + elevations[index] = maxf(elevations[index], recipe.floor_elevation_m) + materials[index] = SonarMaterial.Value.SAND data.elevations_m = elevations data.materials = materials var field := TerrainHeightField.from_data(data) @@ -230,6 +253,14 @@ static func bake(recipe: Recipe) -> SiteTerrainData: 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 primitive: Primitive in recipe.primitives: + 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 material := primitive.material_at(x_m, z_m) + if material != NO_MATERIAL: + materials[data.index_of(column, row)] = material 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 @@ -241,6 +272,31 @@ static func bake(recipe: Recipe) -> SiteTerrainData: return data +## Returns the modified copy of elevations; the gradient of each cell is read from the +## unmodified input so the modifier sees one consistent field. +static func _apply_modifier(recipe: Recipe, modifier: Modifier, source: PackedFloat32Array) -> PackedFloat32Array: + var columns := recipe.columns + var rows := recipe.rows + var cell_size := recipe.cell_size_m + var result := PackedFloat32Array() + result.resize(source.size()) + for row in rows: + var z_m := recipe.origin_z_m + float(row) * cell_size + var north := maxi(row - 1, 0) + var south := mini(row + 1, rows - 1) + for column in columns: + var x_m := recipe.origin_x_m + float(column) * cell_size + var west := maxi(column - 1, 0) + var east := mini(column + 1, columns - 1) + var gradient := Vector2( + (source[row * columns + east] - source[row * columns + west]) / (float(east - west) * cell_size), + (source[south * columns + column] - source[north * columns + column]) / (float(south - north) * cell_size) + ) + var index := row * columns + column + result[index] = modifier.modify(x_m, z_m, source[index], gradient) + return result + + static func write_preview_png(data: SiteTerrainData, path: String) -> Error: var field := TerrainHeightField.from_data(data) if field == null: