The site terrain integration now also configures zone 1 (3 s budget, seabed under the hull, trench at the groove entrance, reference scan from the spawn). The actor test uses an 81 x 65 grid with an off-diagonal bump, checks tile vertices against their field cells and samples the collision between vertices. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
235 lines
10 KiB
GDScript
235 lines
10 KiB
GDScript
extends SceneTree
|
|
|
|
const SonarMaterial = preload("res://game/domain/sonar/sonar_material.gd")
|
|
const TerrainHeightField = preload("res://game/domain/sites/terrain_height_field.gd")
|
|
const SiteTerrainBaker = preload("res://tools/site_terrain_baker.gd")
|
|
const BathymetricTerrainActor = preload(
|
|
"res://game/world/sites/bathymetric_terrain_actor.gd"
|
|
)
|
|
const DiveSiteDefinition = preload("res://game/domain/sites/dive_site_definition.gd")
|
|
const SitePlacement = preload("res://game/domain/sites/site_placement.gd")
|
|
const DiveSite = preload("res://game/world/sites/dive_site.gd")
|
|
const SonarCaptureRequest = preload("res://game/application/sonar/sonar_capture_request.gd")
|
|
const ReferenceBathymetryAcquisition = preload(
|
|
"res://game/world/sonar/reference_bathymetry_acquisition.gd"
|
|
)
|
|
const DIVE_SITE_SCENE = preload("res://game/world/sites/dive_site.tscn")
|
|
|
|
const ZONE1_SITE_PATH := "res://content/sites/zone1_vigilant.tres"
|
|
const GROOVE_PROBE_OFFSET_M := 25.0
|
|
const TEST_COUNT := 31
|
|
|
|
var _failures: Array[String] = []
|
|
|
|
|
|
func _init() -> void:
|
|
_run.call_deferred()
|
|
|
|
|
|
func _run() -> void:
|
|
await _test_actor_matches_field()
|
|
await _test_dive_site_scene()
|
|
await _test_zone1_site()
|
|
|
|
var exit_code := 0
|
|
if _failures.is_empty():
|
|
print("Site terrain integration: %d passed, 0 failed." % TEST_COUNT)
|
|
else:
|
|
for failure: String in _failures:
|
|
push_error(failure)
|
|
print("Site terrain integration: %d failed." % _failures.size())
|
|
exit_code = 1
|
|
await process_frame
|
|
quit(exit_code)
|
|
|
|
|
|
func _asymmetric_field() -> TerrainHeightField:
|
|
var recipe := SiteTerrainBaker.Recipe.new()
|
|
recipe.columns = 81
|
|
recipe.rows = 65
|
|
recipe.cell_size_m = 2.0
|
|
recipe.origin_x_m = -80.0
|
|
recipe.origin_z_m = -80.0
|
|
recipe.floor_elevation_m = -90.0
|
|
recipe.primitives = [
|
|
SiteTerrainBaker.SlopedPlane.new(40.0, 0.0, 0.0, 0.0, 0.0),
|
|
# Tall bump off the diagonal on a non-square grid: any front/back flip or
|
|
# row/column transposition shows up.
|
|
SiteTerrainBaker.Blob.new(Vector2(-60.0, 20.0), 10.0, 10.0, 18.0),
|
|
]
|
|
return TerrainHeightField.from_data(SiteTerrainBaker.bake(recipe))
|
|
|
|
|
|
func _test_actor_matches_field() -> void:
|
|
var field := _asymmetric_field()
|
|
_expect(field != null, "asymmetric test field bakes")
|
|
if field == null:
|
|
return
|
|
var actor := BathymetricTerrainActor.new()
|
|
root.add_child(actor)
|
|
_expect(actor.configure(field).is_empty(), "actor accepts a valid field")
|
|
await physics_frame
|
|
await physics_frame
|
|
|
|
_expect(actor.tile_count() == 2, "80 x 64 cells split into 2 x 1 tiles of at most 64 cells")
|
|
var first_tile := actor.get_node("Tiles").get_child(0) as MeshInstance3D
|
|
var arrays: Array = first_tile.mesh.surface_get_arrays(0)
|
|
var tile_vertices: PackedVector3Array = arrays[Mesh.ARRAY_VERTEX]
|
|
var tile_indices: PackedInt32Array = arrays[Mesh.ARRAY_INDEX]
|
|
var first_face := Plane(
|
|
tile_vertices[tile_indices[0]],
|
|
tile_vertices[tile_indices[1]],
|
|
tile_vertices[tile_indices[2]]
|
|
)
|
|
_expect(first_face.normal.y > 0.0, "tile triangles are wound clockwise from above (front face up)")
|
|
# The first tile spans cells 0..64 on both axes, so vertex index = row * 65 + column.
|
|
var worst_vertex_error := 0.0
|
|
for cell: Vector2i in [Vector2i(0, 0), Vector2i(10, 50), Vector2i(64, 64)]:
|
|
var vertex := first_tile.global_transform * tile_vertices[cell.y * 65 + cell.x]
|
|
worst_vertex_error = maxf(worst_vertex_error, vertex.distance_to(field.cell_world_position(cell.x, cell.y)))
|
|
_expect(worst_vertex_error < 0.01, "tile vertices sit on their field cells (worst %.4f m)" % worst_vertex_error)
|
|
var space := actor.get_world_3d().direct_space_state
|
|
var worst_error := 0.0
|
|
for cell: Vector2i in [
|
|
Vector2i(0, 0), Vector2i(80, 0), Vector2i(0, 64), Vector2i(80, 64),
|
|
Vector2i(10, 50), Vector2i(40, 40), Vector2i(64, 20),
|
|
]:
|
|
var expected := field.cell_world_position(cell.x, cell.y)
|
|
var query := PhysicsRayQueryParameters3D.create(
|
|
Vector3(expected.x, 50.0, expected.z),
|
|
Vector3(expected.x, -200.0, expected.z)
|
|
)
|
|
var hit := space.intersect_ray(query)
|
|
if hit.is_empty():
|
|
_failures.append("collision ray at cell %s hits nothing" % cell)
|
|
continue
|
|
worst_error = maxf(worst_error, absf(float(hit["position"].y) - expected.y))
|
|
_expect(worst_error < 0.05, "collision height equals the field height at vertices (worst %.3f m)" % worst_error)
|
|
|
|
var bump := _collision_height(space, -60.0, 20.0)
|
|
var north_east := _collision_height(space, 60.0, -60.0)
|
|
_expect(bump > -30.0, "south-west bump is where the field puts it (collision)")
|
|
_expect(north_east < -39.0, "north-east stays flat (collision)")
|
|
_expect(absf(bump - field.elevation_at(-60.0, 20.0)) < 0.05, "bump height matches the field")
|
|
var mid_cell := _collision_height(space, -59.0, 21.0)
|
|
_expect(absf(mid_cell - field.elevation_at(-59.0, 21.0)) < 0.3, "collision between vertices follows the field (%.2f m)" % absf(mid_cell - field.elevation_at(-59.0, 21.0)))
|
|
|
|
_expect(actor.sonar_material_at(Vector3(-52.0, -30.0, 20.0)) == SonarMaterial.Value.ROCK, "steep flank reports rock")
|
|
_expect(actor.sonar_material_at(Vector3(40.0, -40.0, 40.0)) == SonarMaterial.Value.SAND, "flat plain reports sand")
|
|
_expect(is_equal_approx(actor.sonar_reflectivity_at(Vector3(40.0, -40.0, 40.0)), 0.45), "sand reflectivity from the data")
|
|
|
|
_expect(actor.configure(field).is_empty() and actor.tile_count() == 2, "reconfiguring rebuilds without duplicating tiles")
|
|
_expect(not actor.configure(null).is_empty(), "null field is refused")
|
|
actor.queue_free()
|
|
|
|
|
|
func _collision_height(space: PhysicsDirectSpaceState3D, x: float, z: float) -> float:
|
|
var hit := space.intersect_ray(
|
|
PhysicsRayQueryParameters3D.create(Vector3(x, 50.0, z), Vector3(x, -200.0, z))
|
|
)
|
|
if hit.is_empty():
|
|
return INF
|
|
return float(hit["position"].y)
|
|
|
|
|
|
func _expect(condition: bool, label: String) -> void:
|
|
if not condition:
|
|
_failures.append(label)
|
|
|
|
|
|
func _test_dive_site_scene() -> void:
|
|
var definition := load("res://content/sites/vehicle_test.tres") as DiveSiteDefinition
|
|
_expect(definition != null, "calibration site definition loads")
|
|
if definition == null:
|
|
return
|
|
_expect(definition.validation_error().is_empty(), "calibration site definition is valid")
|
|
var started_usec := Time.get_ticks_usec()
|
|
var site := DIVE_SITE_SCENE.instantiate() as DiveSite
|
|
root.add_child(site)
|
|
_expect(site.configure(definition).is_empty(), "dive site scene accepts the definition")
|
|
var configure_ms := float(Time.get_ticks_usec() - started_usec) / 1000.0
|
|
_expect(configure_ms < 3000.0, "site configures under the 3 s load budget (%.0f ms)" % configure_ms)
|
|
await physics_frame
|
|
await physics_frame
|
|
_expect(site.terrain().tile_count() > 0, "site builds its terrain tiles")
|
|
_expect(site.placeholder_count() == 2, "calibration target and beacon are placed")
|
|
var target := site.get_node_or_null("%Placements/calibration_target") as StaticBody3D
|
|
_expect(target != null and int(target.get_meta("sonar_material")) == SonarMaterial.Value.METAL, "wreck placeholder is a metal sonar body")
|
|
_expect(site.spawn_transform().origin.is_equal_approx(Vector3(0.0, -12.0, 12.0)), "spawn comes from the definition")
|
|
|
|
var sensor := Transform3D(Basis(Vector3.UP, 0.0), Vector3(0.0, -12.0, 10.0))
|
|
sensor.basis = Basis(sensor.basis.x, sensor.basis.y, -sensor.basis.z)
|
|
var acquisition := ReferenceBathymetryAcquisition.new(site.get_world_3d())
|
|
var request := SonarCaptureRequest.new(
|
|
&"scan", &"session", sensor, 5.0, 95.0, deg_to_rad(76.0), deg_to_rad(64.0), 49, 31
|
|
)
|
|
var result = acquisition.capture(request)
|
|
_expect(result.accepted, "reference acquisition scans the baked terrain")
|
|
var materials: Dictionary = {}
|
|
for sample in result.samples:
|
|
materials[sample.material] = true
|
|
_expect(materials.has(SonarMaterial.Value.SAND) and materials.has(SonarMaterial.Value.ROCK), "acquisition reports per-cell sand and rock")
|
|
_expect(materials.has(SonarMaterial.Value.METAL), "acquisition still reports the metal placeholder")
|
|
site.queue_free()
|
|
|
|
|
|
func _test_zone1_site() -> void:
|
|
# Let the calibration site queued for deletion leave the physics space first.
|
|
await process_frame
|
|
var definition := load(ZONE1_SITE_PATH) as DiveSiteDefinition
|
|
if definition == null:
|
|
_failures.append("zone 1 site definition loads")
|
|
return
|
|
var started_usec := Time.get_ticks_usec()
|
|
var site := DIVE_SITE_SCENE.instantiate() as DiveSite
|
|
root.add_child(site)
|
|
_expect(site.configure(definition).is_empty(), "dive site scene accepts the Banc du Vigilant")
|
|
var configure_ms := float(Time.get_ticks_usec() - started_usec) / 1000.0
|
|
_expect(configure_ms < 3000.0, "zone 1 configures under the 3 s load budget (%.0f ms)" % configure_ms)
|
|
await physics_frame
|
|
await physics_frame
|
|
var space := site.get_world_3d().direct_space_state
|
|
var terrain := site.terrain()
|
|
|
|
var hull := definition.placements_of_kind(SitePlacement.Kind.WRECK_TARGET)[0]
|
|
var hull_body := site.get_node_or_null("%%Placements/%s" % hull.placement_id) as StaticBody3D
|
|
var excluded: Array[RID] = []
|
|
if hull_body != null:
|
|
excluded.append(hull_body.get_rid())
|
|
var under_hull := _terrain_height(space, terrain, hull.position_m.x, hull.position_m.z, excluded)
|
|
_expect(under_hull < hull.position_m.y, "a ray at the target hits the seabed under the hull centre (%.1f m)" % under_hull)
|
|
|
|
var entrance := definition.placement(&"saignee_entrance").position_m
|
|
var groove_floor := _terrain_height(space, terrain, entrance.x, entrance.z, [])
|
|
var beside_groove := _terrain_height(space, terrain, entrance.x + GROOVE_PROBE_OFFSET_M, entrance.z, [])
|
|
_expect(groove_floor < beside_groove, "la Saignee is a trench at its entrance (%.1f m vs %.1f m)" % [groove_floor, beside_groove])
|
|
|
|
var sensor := definition.spawn_transform()
|
|
sensor.basis = Basis(sensor.basis.x, sensor.basis.y, -sensor.basis.z)
|
|
var acquisition := ReferenceBathymetryAcquisition.new(site.get_world_3d())
|
|
var request := SonarCaptureRequest.new(
|
|
&"scan", &"session", sensor, 5.0, 95.0, deg_to_rad(76.0), deg_to_rad(64.0), 49, 31
|
|
)
|
|
var result = acquisition.capture(request)
|
|
var has_sand := false
|
|
for sample in result.samples:
|
|
has_sand = has_sand or sample.material == SonarMaterial.Value.SAND
|
|
_expect(result.accepted and has_sand, "a reference scan from the zone 1 spawn reads the sand plateau")
|
|
site.queue_free()
|
|
|
|
|
|
## Seabed height under (x, z), or NAN (every comparison fails) when the ray misses the terrain actor.
|
|
func _terrain_height(
|
|
space: PhysicsDirectSpaceState3D,
|
|
terrain: Node,
|
|
x: float,
|
|
z: float,
|
|
excluded: Array[RID]
|
|
) -> float:
|
|
var query := PhysicsRayQueryParameters3D.create(Vector3(x, 50.0, z), Vector3(x, -200.0, z))
|
|
query.exclude = excluded
|
|
var hit := space.intersect_ray(query)
|
|
if hit.is_empty() or hit["collider"] != terrain:
|
|
return NAN
|
|
return float(hit["position"].y)
|