diff --git a/game/domain/sites/site_terrain_data.gd b/game/domain/sites/site_terrain_data.gd new file mode 100644 index 0000000..f014c3d --- /dev/null +++ b/game/domain/sites/site_terrain_data.gd @@ -0,0 +1,44 @@ +class_name SiteTerrainData +extends Resource + +const SonarMaterial = preload("res://game/domain/sonar/sonar_material.gd") + +## Regular single-surface heightfield. Column grows with world +X (east), +## row grows with world +Z (south). Elevations are world Y in metres. +@export var columns: int = 0 +@export var rows: int = 0 +@export var cell_size_m: float = 1.0 +@export var origin_x_m: float = 0.0 +@export var origin_z_m: float = 0.0 +@export var elevations_m := PackedFloat32Array() +@export var materials := PackedByteArray() +@export var rock_reflectivity: float = 0.72 +@export var sand_reflectivity: float = 0.45 + + +func index_of(column: int, row: int) -> int: + return row * columns + column + + +func validation_error() -> String: + if columns < 2 or rows < 2: + return "terrain needs at least 2 columns and 2 rows" + if not is_finite(cell_size_m) or cell_size_m <= 0.0: + return "terrain cell size must be positive" + if not is_finite(origin_x_m) or not is_finite(origin_z_m): + return "terrain origin must be finite" + var cell_count := columns * rows + if elevations_m.size() != cell_count: + return "terrain elevation count does not match the grid" + if materials.size() != cell_count: + return "terrain material count does not match the grid" + for elevation: float in elevations_m: + if not is_finite(elevation): + return "terrain elevations must be finite" + for material: int in materials: + if not SonarMaterial.is_terrain(material): + return "terrain materials must be rock or sand" + for reflectivity: float in [rock_reflectivity, sand_reflectivity]: + if not is_finite(reflectivity) or reflectivity <= 0.0 or reflectivity > 1.0: + return "terrain reflectivity must be within ]0, 1]" + return "" diff --git a/game/domain/sites/terrain_height_field.gd b/game/domain/sites/terrain_height_field.gd new file mode 100644 index 0000000..7866b17 --- /dev/null +++ b/game/domain/sites/terrain_height_field.gd @@ -0,0 +1,133 @@ +class_name TerrainHeightField +extends RefCounted + +const SonarMaterial = preload("res://game/domain/sonar/sonar_material.gd") +const SiteTerrainData = preload("res://game/domain/sites/site_terrain_data.gd") + +var _data: SiteTerrainData +var _minimum_elevation_m := 0.0 +var _maximum_elevation_m := 0.0 + + +static func from_data(data: SiteTerrainData) -> TerrainHeightField: + if data == null or not data.validation_error().is_empty(): + return null + var field := TerrainHeightField.new() + field._data = data + field._minimum_elevation_m = data.elevations_m[0] + field._maximum_elevation_m = data.elevations_m[0] + for elevation: float in data.elevations_m: + field._minimum_elevation_m = minf(field._minimum_elevation_m, elevation) + field._maximum_elevation_m = maxf(field._maximum_elevation_m, elevation) + return field + + +func columns() -> int: + return _data.columns + + +func rows() -> int: + return _data.rows + + +func cell_size_m() -> float: + return _data.cell_size_m + + +func origin_m() -> Vector2: + return Vector2(_data.origin_x_m, _data.origin_z_m) + + +func bounds_m() -> Rect2: + return Rect2( + _data.origin_x_m, + _data.origin_z_m, + float(_data.columns - 1) * _data.cell_size_m, + float(_data.rows - 1) * _data.cell_size_m + ) + + +func contains(x_m: float, z_m: float) -> bool: + return bounds_m().has_point(Vector2(x_m, z_m)) + + +func minimum_elevation_m() -> float: + return _minimum_elevation_m + + +func maximum_elevation_m() -> float: + return _maximum_elevation_m + + +func elevation_at_cell(column: int, row: int) -> float: + return _data.elevations_m[_data.index_of(_clamp_column(column), _clamp_row(row))] + + +func material_at_cell(column: int, row: int) -> int: + return _data.materials[_data.index_of(_clamp_column(column), _clamp_row(row))] + + +func cell_world_position(column: int, row: int) -> Vector3: + return Vector3( + _data.origin_x_m + float(column) * _data.cell_size_m, + elevation_at_cell(column, row), + _data.origin_z_m + float(row) * _data.cell_size_m + ) + + +func normal_at_cell(column: int, row: int) -> Vector3: + var left := elevation_at_cell(column - 1, row) + var right := elevation_at_cell(column + 1, row) + var north := elevation_at_cell(column, row - 1) + var south := elevation_at_cell(column, row + 1) + var span_x := float(_clamp_column(column + 1) - _clamp_column(column - 1)) * _data.cell_size_m + var span_z := float(_clamp_row(row + 1) - _clamp_row(row - 1)) * _data.cell_size_m + var slope_x := (right - left) / span_x + var slope_z := (south - north) / span_z + return Vector3(-slope_x, 1.0, -slope_z).normalized() + + +func elevation_at(x_m: float, z_m: float) -> float: + var column_f := clampf( + (x_m - _data.origin_x_m) / _data.cell_size_m, + 0.0, + float(_data.columns - 1) + ) + var row_f := clampf( + (z_m - _data.origin_z_m) / _data.cell_size_m, + 0.0, + float(_data.rows - 1) + ) + var column := mini(int(floorf(column_f)), _data.columns - 2) + var row := mini(int(floorf(row_f)), _data.rows - 2) + var tx := column_f - float(column) + var tz := row_f - float(row) + var top := lerpf(elevation_at_cell(column, row), elevation_at_cell(column + 1, row), tx) + var bottom := lerpf( + elevation_at_cell(column, row + 1), + elevation_at_cell(column + 1, row + 1), + tx + ) + return lerpf(top, bottom, tz) + + +func material_at(x_m: float, z_m: float) -> int: + var column := int(roundf((x_m - _data.origin_x_m) / _data.cell_size_m)) + var row := int(roundf((z_m - _data.origin_z_m) / _data.cell_size_m)) + return material_at_cell(column, row) + + +func reflectivity_for(material: int) -> float: + match material: + SonarMaterial.Value.SAND: + return _data.sand_reflectivity + _: + return _data.rock_reflectivity + + +func _clamp_column(column: int) -> int: + return clampi(column, 0, _data.columns - 1) + + +func _clamp_row(row: int) -> int: + return clampi(row, 0, _data.rows - 1) diff --git a/scripts/quality_gate.sh b/scripts/quality_gate.sh index c14fb7e..3b2aa1d 100755 --- a/scripts/quality_gate.sh +++ b/scripts/quality_gate.sh @@ -103,6 +103,16 @@ check_godot_log \ "production sonar application unit tests" \ "$QUALITY_TEMP_DIRECTORY/sonar-application-unit-console.log" +printf 'Quality gate: production site domain unit tests\n' +"$GODOT_EXECUTABLE" --headless \ + --log-file "$QUALITY_TEMP_DIRECTORY/site-domain-unit.log" \ + --path . \ + --script tests/unit/run_site_domain_tests.gd \ + 2>&1 | tee "$QUALITY_TEMP_DIRECTORY/site-domain-unit-console.log" +check_godot_log \ + "production site domain unit tests" \ + "$QUALITY_TEMP_DIRECTORY/site-domain-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/unit/run_site_domain_tests.gd b/tests/unit/run_site_domain_tests.gd new file mode 100644 index 0000000..225d3e3 --- /dev/null +++ b/tests/unit/run_site_domain_tests.gd @@ -0,0 +1,100 @@ +extends SceneTree + +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") + +const TEST_COUNT := 20 + +var _failures: Array[String] = [] + + +func _init() -> void: + _test_terrain_data_validation() + _test_height_field_sampling() + + if _failures.is_empty(): + print("Site domain unit tests: %d passed, 0 failed." % TEST_COUNT) + quit(0) + else: + for failure: String in _failures: + push_error(failure) + print("Site domain unit tests: %d failed." % _failures.size()) + quit(1) + + +func _make_terrain(columns: int, rows: int) -> SiteTerrainData: + var data := SiteTerrainData.new() + data.columns = columns + data.rows = rows + data.cell_size_m = 2.0 + data.origin_x_m = -10.0 + data.origin_z_m = 100.0 + # Packed arrays are passed by value in GDScript: build locally, then assign. + var elevations := PackedFloat32Array() + var materials := PackedByteArray() + elevations.resize(columns * rows) + materials.resize(columns * rows) + for row in rows: + for column in columns: + var index := row * columns + column + # Plane sloping down toward +Z (south): -20 at row 0, -2 m per row. + elevations[index] = -20.0 - 2.0 * float(row) + materials[index] = ( + SonarMaterial.Value.ROCK if column == 0 else SonarMaterial.Value.SAND + ) + data.elevations_m = elevations + data.materials = materials + return data + + +func _test_terrain_data_validation() -> void: + var data := _make_terrain(3, 4) + _expect(data.validation_error().is_empty(), "regular terrain data is valid") + _expect(data.index_of(2, 3) == 11, "index is row-major with columns along X") + + var tiny := _make_terrain(1, 4) + _expect(not tiny.validation_error().is_empty(), "terrain needs at least two columns") + + var mismatched := _make_terrain(3, 4) + mismatched.elevations_m.resize(5) + _expect(not mismatched.validation_error().is_empty(), "elevation array must match the grid") + + var non_finite := _make_terrain(3, 4) + non_finite.elevations_m[0] = INF + _expect(not non_finite.validation_error().is_empty(), "elevations must be finite") + + var metal_terrain := _make_terrain(3, 4) + metal_terrain.materials[1] = SonarMaterial.Value.METAL + _expect(not metal_terrain.validation_error().is_empty(), "terrain cells are rock or sand only") + + var bad_cell := _make_terrain(3, 4) + bad_cell.cell_size_m = 0.0 + _expect(not bad_cell.validation_error().is_empty(), "cell size must be positive") + + var bad_reflectivity := _make_terrain(3, 4) + bad_reflectivity.sand_reflectivity = 1.5 + _expect(not bad_reflectivity.validation_error().is_empty(), "reflectivity stays within 0..1") + _expect(TerrainHeightField.from_data(bad_reflectivity) == null, "invalid data yields no field") + + +func _test_height_field_sampling() -> void: + var field := TerrainHeightField.from_data(_make_terrain(3, 4)) + _expect(field != null, "valid data yields a field") + if field == null: + return + _expect(field.bounds_m().is_equal_approx(Rect2(-10.0, 100.0, 4.0, 6.0)), "bounds cover (columns-1) by (rows-1) cells") + _expect(field.cell_world_position(2, 3).is_equal_approx(Vector3(-6.0, -26.0, 106.0)), "cell position follows origin and cell size") + _expect(is_equal_approx(field.elevation_at(-10.0, 100.0), -20.0), "elevation at a vertex is exact") + _expect(is_equal_approx(field.elevation_at(-9.0, 101.0), -21.0), "elevation is bilinear between vertices") + _expect(is_equal_approx(field.elevation_at(-50.0, 1000.0), -26.0), "sampling outside clamps to the nearest edge") + _expect(field.material_at(-10.4, 100.0) == SonarMaterial.Value.ROCK, "material is the nearest cell") + _expect(field.material_at(-6.0, 103.0) == SonarMaterial.Value.SAND, "material of an interior cell") + _expect(field.normal_at_cell(1, 1).is_equal_approx(Vector3(0.0, 1.0, 1.0).normalized()), "normal leans against the downhill direction") + _expect(not field.contains(-11.0, 100.0) and field.contains(-8.0, 103.0), "contains follows the bounds") + _expect(is_equal_approx(field.reflectivity_for(SonarMaterial.Value.SAND), 0.45), "sand reflectivity comes from the data") + + +func _expect(condition: bool, label: String) -> void: + if not condition: + _failures.append(label)