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 ## Row-major elevations (index = row * columns + column), the HeightMapShape3D layout. func elevations_m() -> PackedFloat32Array: return _data.elevations_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: return _normal_at(_data.elevations_m, _clamp_column(column), _clamp_row(row)) ## World positions of the cells [start_column..end_column] x [start_row..end_row] ## (bounds included, clamped to the grid), row-major. Same values as cell_world_position. func cell_block_positions(start_column: int, end_column: int, start_row: int, end_row: int) -> PackedVector3Array: var first_column := _clamp_column(start_column) var last_column := _clamp_column(end_column) var first_row := _clamp_row(start_row) var last_row := _clamp_row(end_row) var elevations := _data.elevations_m var columns := _data.columns var cell_size := _data.cell_size_m var positions := PackedVector3Array() positions.resize((last_column - first_column + 1) * (last_row - first_row + 1)) var index := 0 for row in range(first_row, last_row + 1): var z := _data.origin_z_m + float(row) * cell_size var row_start := row * columns for column in range(first_column, last_column + 1): positions[index] = Vector3( _data.origin_x_m + float(column) * cell_size, elevations[row_start + column], z ) index += 1 return positions ## Normals of the same cell block, row-major. Same values as normal_at_cell. func cell_block_normals(start_column: int, end_column: int, start_row: int, end_row: int) -> PackedVector3Array: var first_column := _clamp_column(start_column) var last_column := _clamp_column(end_column) var first_row := _clamp_row(start_row) var last_row := _clamp_row(end_row) var elevations := _data.elevations_m var normals := PackedVector3Array() normals.resize((last_column - first_column + 1) * (last_row - first_row + 1)) var index := 0 for row in range(first_row, last_row + 1): for column in range(first_column, last_column + 1): normals[index] = _normal_at(elevations, column, row) index += 1 return normals 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 ## Central differences, one-sided on the grid edges. column and row must be in range. func _normal_at(elevations: PackedFloat32Array, column: int, row: int) -> Vector3: var columns := _data.columns var left := maxi(column - 1, 0) var right := mini(column + 1, columns - 1) var north := maxi(row - 1, 0) var south := mini(row + 1, _data.rows - 1) var slope_x := ( (elevations[row * columns + right] - elevations[row * columns + left]) / (float(right - left) * _data.cell_size_m) ) var slope_z := ( (elevations[south * columns + column] - elevations[north * columns + column]) / (float(south - north) * _data.cell_size_m) ) return Vector3(-slope_x, 1.0, -slope_z).normalized() 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)