From 30cc2f635fd00a69ae4d1a906d6c9fce7b13b5be Mon Sep 17 00:00:00 2001 From: Olivier Date: Tue, 6 Oct 2026 20:55:35 +0200 Subject: [PATCH] test(sites): load the Banc du Vigilant and check tiles on a non-square grid 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 --- tests/README.md | 6 +- .../run_site_terrain_integration.gd | 101 +++++++++++++++--- 2 files changed, 91 insertions(+), 16 deletions(-) diff --git a/tests/README.md b/tests/README.md index 9ea8755..acefa24 100644 --- a/tests/README.md +++ b/tests/README.md @@ -27,5 +27,7 @@ Suites de production actuelles : - `unit/run_site_baker_tests.gd` : primitives et déterminisme de la cuisson ; - `contract/run_site_content_tests.gd` : règles de contenu du Banc du Vigilant (profondeurs, routes, amers) ; -- `integration/run_site_terrain_integration.gd` : collision égale au champ, - matériaux sonar par cellule, scène de site et budget de chargement. +- `integration/run_site_terrain_integration.gd` : collision et maillage égaux au + champ, matériaux sonar par cellule, puis scène de site chargée deux fois — le + site de calibration et le Banc du Vigilant (budget de chargement de 3 s, + sillon, coque, scan de référence depuis le départ). diff --git a/tests/integration/run_site_terrain_integration.gd b/tests/integration/run_site_terrain_integration.gd index 3552133..67b45bc 100644 --- a/tests/integration/run_site_terrain_integration.gd +++ b/tests/integration/run_site_terrain_integration.gd @@ -15,7 +15,9 @@ const ReferenceBathymetryAcquisition = preload( ) const DIVE_SITE_SCENE = preload("res://game/world/sites/dive_site.tscn") -const TEST_COUNT := 24 +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] = [] @@ -27,6 +29,7 @@ func _init() -> void: 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(): @@ -43,15 +46,16 @@ func _run() -> void: func _asymmetric_field() -> TerrainHeightField: var recipe := SiteTerrainBaker.Recipe.new() recipe.columns = 81 - recipe.rows = 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 in the north-west corner only: any front/back flip shows up. - SiteTerrainBaker.Blob.new(Vector2(-60.0, -60.0), 10.0, 10.0, 18.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)) @@ -67,7 +71,7 @@ func _test_actor_matches_field() -> void: await physics_frame await physics_frame - _expect(actor.tile_count() == 4, "80 cells per side split into 2 x 2 tiles of 64") + _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] @@ -78,11 +82,17 @@ func _test_actor_matches_field() -> void: 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, 80), Vector2i(80, 80), - Vector2i(10, 10), Vector2i(40, 40), Vector2i(64, 20), + 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( @@ -96,17 +106,19 @@ func _test_actor_matches_field() -> void: 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 north_west := _collision_height(space, -60.0, -60.0) - var south_east := _collision_height(space, 60.0, 60.0) - _expect(north_west > -30.0, "north-west bump is where the field puts it (collision)") - _expect(south_east < -39.0, "south-east stays flat (collision)") - _expect(absf(north_west - field.elevation_at(-60.0, -60.0)) < 0.05, "bump height matches the field") + 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, -60.0)) == SonarMaterial.Value.ROCK, "steep flank reports rock") + _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() == 4, "reconfiguring rebuilds without duplicating tiles") + _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() @@ -159,3 +171,64 @@ func _test_dive_site_scene() -> void: _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)