Files
Ransom-s-wake/tests/unit/run_site_domain_tests.gd
T
OlivierandClaude Fable 5.1 122f3f5800 feat(world): render steep tiles on a smooth bicubic surface, sampled in parallel
Tiles holding a slope over 35 degrees are meshed at two vertices per cell
on a clamped Catmull-Rom surface that passes through every grid node, so
cliffs and ledges lose the facets of the heightfield grid while the
collision stays on the grid. The domain field exposes the smooth
elevation and a separable, per-phase-weighted block sampler with linear
edge extrapolation and a one-cell margin for continuous normals; the actor
samples tiles on the worker thread pool and builds meshes afterwards.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-07 00:09:42 +02:00

248 lines
11 KiB
GDScript

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 SitePlacement = preload("res://game/domain/sites/site_placement.gd")
const SiteRoute = preload("res://game/domain/sites/site_route.gd")
const DiveSiteDefinition = preload("res://game/domain/sites/dive_site_definition.gd")
const TEST_COUNT := 46
var _failures: Array[String] = []
func _init() -> void:
_test_terrain_data_validation()
_test_height_field_sampling()
_test_site_definition_validation()
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 data := _make_terrain(3, 4)
var field := TerrainHeightField.from_data(data)
_expect(field != null, "valid data yields a field")
if field == null:
return
_expect(field.elevations_m() == data.elevations_m, "elevations are exposed in row-major data order")
_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(is_equal_approx(field.smooth_elevation_at(-10.0, 100.0), field.elevation_at(-10.0, 100.0)), "smooth elevation passes through the grid nodes")
var plane_data := SiteTerrainData.new()
plane_data.columns = 7
plane_data.rows = 6
plane_data.cell_size_m = 2.0
plane_data.origin_x_m = 0.0
plane_data.origin_z_m = 0.0
var plane_elevations := PackedFloat32Array()
var plane_materials := PackedByteArray()
for row in 6:
for column in 7:
plane_elevations.append(-20.0 + 0.5 * float(column) * 2.0 - 0.25 * float(row) * 2.0)
plane_materials.append(SonarMaterial.Value.SAND)
plane_data.elevations_m = plane_elevations
plane_data.materials = plane_materials
var plane := TerrainHeightField.from_data(plane_data)
_expect(
is_equal_approx(plane.smooth_elevation_at(5.0, 3.0), -20.0 + 2.5 - 0.75)
and is_equal_approx(plane.smooth_elevation_at(0.5, 0.5), -20.0 + 0.25 - 0.125),
"smooth elevation reproduces a plane between nodes, edges included"
)
var fine := field.cell_block_positions_subdivided(1, 2, 0, 3, 2)
var fine_normals := field.cell_block_normals_subdivided(1, 2, 0, 3, 2)
_expect(fine.size() == 3 * 7 and fine_normals.size() == 3 * 7, "a subdivided block has cells * 2 + 1 points per axis")
var nodes_match := fine.size() == 21
for row in 4:
for column in range(1, 3):
var index := (row * 2) * 3 + (column - 1) * 2
if nodes_match and not fine[index].is_equal_approx(field.cell_world_position(column, row)):
nodes_match = false
_expect(nodes_match, "subdivided block points on the grid nodes equal the cell positions")
_expect(fine_normals[0].is_normalized() and fine_normals[10].y > 0.0, "subdivided normals are unit length and point up")
var block_positions := field.cell_block_positions(1, 2, 0, 3)
var block_normals := field.cell_block_normals(1, 2, 0, 3)
var block_matches := block_positions.size() == 8 and block_normals.size() == 8
var normals_match := block_matches
for row in 4:
for column in range(1, 3):
var index := row * 2 + column - 1
if block_matches and not block_positions[index].is_equal_approx(field.cell_world_position(column, row)):
block_matches = false
if normals_match and not block_normals[index].is_equal_approx(field.normal_at_cell(column, row)):
normals_match = false
_expect(block_matches, "cell block positions match cell_world_position, row-major")
_expect(normals_match, "cell block normals match normal_at_cell, edges included")
_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 _make_placement(id: String, kind: int, position: Vector3) -> SitePlacement:
var placement := SitePlacement.new()
placement.placement_id = StringName(id)
placement.kind = kind
placement.position_m = position
placement.footprint_m = Vector3(4.0, 2.0, 3.0)
return placement
func _make_route(id: String, waypoints: PackedVector3Array, maximum_depth: float) -> SiteRoute:
var route := SiteRoute.new()
route.route_id = StringName(id)
route.waypoints_m = waypoints
route.maximum_depth_m = maximum_depth
return route
func _make_definition() -> DiveSiteDefinition:
var definition := DiveSiteDefinition.new()
definition.site_id = &"test_site"
definition.display_name = "Site de test"
definition.terrain = _make_terrain(3, 4)
definition.playable_bounds_m = Rect2(-10.0, 100.0, 4.0, 6.0)
definition.spawn_position_m = Vector3(-8.0, -10.0, 102.0)
definition.spawn_yaw_radians = PI * 0.5
var placements_list: Array[SitePlacement] = [
_make_placement("target", SitePlacement.Kind.WRECK_TARGET, Vector3(-8.0, -23.0, 103.0)),
_make_placement("anchor", SitePlacement.Kind.VESSEL_ANCHOR, Vector3(-8.0, -20.0, 100.5)),
]
definition.placements = placements_list
var routes_list: Array[SiteRoute] = [
_make_route(
"safe",
PackedVector3Array([Vector3(-8.0, -12.0, 101.0), Vector3(-8.0, -14.0, 103.0)]),
20.0
),
]
definition.routes = routes_list
return definition
func _test_site_definition_validation() -> void:
var definition := _make_definition()
_expect(definition.validation_error().is_empty(), "complete definition is valid")
_expect(definition.height_field() != null, "definition exposes its height field")
_expect(definition.placement(&"target").kind == SitePlacement.Kind.WRECK_TARGET, "placement lookup by id")
_expect(definition.placement(&"missing") == null, "unknown placement is null")
_expect(definition.placements_of_kind(SitePlacement.Kind.VESSEL_ANCHOR).size() == 1, "placements filter by kind")
_expect(definition.route(&"safe").waypoints_m.size() == 2, "route lookup by id")
_expect(definition.spawn_transform().origin.is_equal_approx(Vector3(-8.0, -10.0, 102.0)), "spawn transform origin")
_expect(
(definition.spawn_transform().basis * Vector3.FORWARD).is_equal_approx(Vector3.LEFT),
"spawn yaw rotates the forward axis about Y"
)
_expect(definition.placement(&"target").is_sonar_metal(), "wrecks read as metal on sonar")
_expect(not definition.placement(&"anchor").is_sonar_metal(), "anchor is not a sonar body")
var no_id := _make_definition()
no_id.site_id = &""
_expect(not no_id.validation_error().is_empty(), "site id is required")
var no_terrain := _make_definition()
no_terrain.terrain = null
_expect(not no_terrain.validation_error().is_empty(), "terrain is required")
var bounds_outside := _make_definition()
bounds_outside.playable_bounds_m = Rect2(-20.0, 100.0, 4.0, 6.0)
_expect(not bounds_outside.validation_error().is_empty(), "playable bounds must lie inside the terrain")
var spawn_outside := _make_definition()
spawn_outside.spawn_position_m = Vector3(50.0, -10.0, 102.0)
_expect(not spawn_outside.validation_error().is_empty(), "spawn must lie inside the bounds")
var duplicate_ids := _make_definition()
duplicate_ids.placements.append(
_make_placement("target", SitePlacement.Kind.WRECK_DECOY, Vector3(-7.0, -23.0, 104.0))
)
_expect(not duplicate_ids.validation_error().is_empty(), "placement ids are unique")
var placement_outside := _make_definition()
placement_outside.placements.append(
_make_placement("far", SitePlacement.Kind.DEBRIS, Vector3(80.0, -23.0, 104.0))
)
_expect(not placement_outside.validation_error().is_empty(), "placements must lie inside the bounds")
var empty_route := _make_definition()
empty_route.routes.append(_make_route("empty", PackedVector3Array(), 20.0))
_expect(not empty_route.validation_error().is_empty(), "routes need at least two waypoints")
var deep_route := _make_definition()
deep_route.routes.append(
_make_route("deep", PackedVector3Array([Vector3(-8.0, -12.0, 101.0), Vector3(-8.0, -30.0, 103.0)]), 20.0)
)
_expect(not deep_route.validation_error().is_empty(), "route waypoints respect the route maximum depth")
func _expect(condition: bool, label: String) -> void:
if not condition:
_failures.append(label)