diff --git a/content/sites/zone1_vigilant.tres b/content/sites/zone1_vigilant.tres new file mode 100644 index 0000000..890f4c4 --- /dev/null +++ b/content/sites/zone1_vigilant.tres @@ -0,0 +1,121 @@ +[gd_resource type="Resource" script_class="DiveSiteDefinition" format=3] + +[ext_resource type="Script" path="res://game/domain/sites/site_placement.gd" id="1_n3xpu"] +[ext_resource type="Script" path="res://game/domain/sites/site_route.gd" id="2_36hd6"] +[ext_resource type="Script" path="res://game/domain/sites/dive_site_definition.gd" id="3_vix3p"] +[ext_resource type="Resource" path="res://content/sites/zone1_vigilant_terrain.res" id="4_i0v8k"] + +[sub_resource type="Resource" id="Resource_rqt47"] +script = ExtResource("1_n3xpu") +placement_id = &"prospection_vessel" +kind = 0 +position_m = Vector3(-12, -37.10001, 108) +yaw_radians = 0.35 +footprint_m = Vector3(28, 8, 9) + +[sub_resource type="Resource" id="Resource_xcojm"] +script = ExtResource("1_n3xpu") +placement_id = &"decoy_yacht" +kind = 1 +position_m = Vector3(-40, -24.655516, -200) +yaw_radians = 1.1 +footprint_m = Vector3(12, 3, 4) + +[sub_resource type="Resource" id="Resource_yljal"] +script = ExtResource("1_n3xpu") +placement_id = &"decoy_coaster" +kind = 1 +position_m = Vector3(140, -35.265244, 40) +yaw_radians = -0.6 +footprint_m = Vector3(26, 6, 8) + +[sub_resource type="Resource" id="Resource_aqgwj"] +script = ExtResource("1_n3xpu") +placement_id = &"secondary_item" +kind = 2 +position_m = Vector3(-6, -61.61916, 157) +footprint_m = Vector3(1, 1, 1) + +[sub_resource type="Resource" id="Resource_aervf"] +script = ExtResource("1_n3xpu") +placement_id = &"debris_1" +kind = 3 +position_m = Vector3(-12, -45.756382, 134) +yaw_radians = 0.4 +footprint_m = Vector3(3, 2, 2) + +[sub_resource type="Resource" id="Resource_qgawl"] +script = ExtResource("1_n3xpu") +placement_id = &"debris_2" +kind = 3 +position_m = Vector3(-10, -48.518223, 139) +yaw_radians = 1.2 +footprint_m = Vector3(6, 2.5, 2.5) + +[sub_resource type="Resource" id="Resource_q0ace"] +script = ExtResource("1_n3xpu") +placement_id = &"debris_3" +kind = 3 +position_m = Vector3(-9, -53.75445, 146) +footprint_m = Vector3(2, 1.5, 2) + +[sub_resource type="Resource" id="Resource_hd5cl"] +script = ExtResource("1_n3xpu") +placement_id = &"debris_4" +kind = 3 +position_m = Vector3(-7, -58.142864, 152) +yaw_radians = 2.0 +footprint_m = Vector3(4, 1.5, 1.5) + +[sub_resource type="Resource" id="Resource_wy8hl"] +script = ExtResource("1_n3xpu") +placement_id = &"vessel_anchor" +kind = 5 +position_m = Vector3(0, -14, -250) + +[sub_resource type="Resource" id="Resource_e72vy"] +script = ExtResource("1_n3xpu") +placement_id = &"echine" +position_m = Vector3(75, -20, -50) + +[sub_resource type="Resource" id="Resource_3n33i"] +script = ExtResource("1_n3xpu") +placement_id = &"deux_freres" +position_m = Vector3(-151, -18, -125) + +[sub_resource type="Resource" id="Resource_cdmnh"] +script = ExtResource("1_n3xpu") +placement_id = &"rides" +position_m = Vector3(0, -25, -230) + +[sub_resource type="Resource" id="Resource_u8dqp"] +script = ExtResource("1_n3xpu") +placement_id = &"saignee_entrance" +position_m = Vector3(-60, -34, -140) + +[sub_resource type="Resource" id="Resource_3uttw"] +script = ExtResource("1_n3xpu") +placement_id = &"rupture" +position_m = Vector3(40, -40, 125) + +[sub_resource type="Resource" id="Resource_xvesq"] +script = ExtResource("2_36hd6") +route_id = &"safe_ridge" +waypoints_m = PackedVector3Array(0, -14, -250, 70, -16, -200, 76, -16, -100, 78, -16, 0, 76, -18, 80, 40, -24, 105, 0, -26, 100, -14, -30, 98) +maximum_depth_m = 32.0 + +[sub_resource type="Resource" id="Resource_lcu7s"] +script = ExtResource("2_36hd6") +route_id = &"risky_groove" +waypoints_m = PackedVector3Array(0, -14, -250, -60, -20, -150, -50, -31, -100, -35, -33, -20, -22, -34, 60, -14, -33, 98) +maximum_depth_m = 48.0 + +[resource] +script = ExtResource("3_vix3p") +site_id = &"zone1_vigilant" +display_name = "Banc du Vigilant" +terrain = ExtResource("4_i0v8k") +playable_bounds_m = Rect2(-300, -300, 600, 600) +spawn_position_m = Vector3(0, -10, -250) +placements = Array[ExtResource("1_n3xpu")]([SubResource("Resource_rqt47"), SubResource("Resource_xcojm"), SubResource("Resource_yljal"), SubResource("Resource_aqgwj"), SubResource("Resource_aervf"), SubResource("Resource_qgawl"), SubResource("Resource_q0ace"), SubResource("Resource_hd5cl"), SubResource("Resource_wy8hl"), SubResource("Resource_e72vy"), SubResource("Resource_3n33i"), SubResource("Resource_cdmnh"), SubResource("Resource_u8dqp"), SubResource("Resource_3uttw")]) +routes = Array[ExtResource("2_36hd6")]([SubResource("Resource_xvesq"), SubResource("Resource_lcu7s")]) diff --git a/content/sites/zone1_vigilant_terrain.res b/content/sites/zone1_vigilant_terrain.res new file mode 100644 index 0000000..370bc29 Binary files /dev/null and b/content/sites/zone1_vigilant_terrain.res differ diff --git a/docs/briefs/captures/zone1_vigilant_terrain_preview.png b/docs/briefs/captures/zone1_vigilant_terrain_preview.png new file mode 100644 index 0000000..7792a35 Binary files /dev/null and b/docs/briefs/captures/zone1_vigilant_terrain_preview.png differ diff --git a/docs/briefs/captures/zone1_vigilant_terrain_preview.png.import b/docs/briefs/captures/zone1_vigilant_terrain_preview.png.import new file mode 100644 index 0000000..bdb1095 --- /dev/null +++ b/docs/briefs/captures/zone1_vigilant_terrain_preview.png.import @@ -0,0 +1,40 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://bseks0pf7k0t4" +path="res://.godot/imported/zone1_vigilant_terrain_preview.png-c84b076e243497f30f994bef5c7d9142.ctex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://docs/briefs/captures/zone1_vigilant_terrain_preview.png" +dest_files=["res://.godot/imported/zone1_vigilant_terrain_preview.png-c84b076e243497f30f994bef5c7d9142.ctex"] + +[params] + +compress/mode=0 +compress/high_quality=false +compress/lossy_quality=0.7 +compress/uastc_level=0 +compress/rdo_quality_loss=0.0 +compress/hdr_compression=1 +compress/normal_map=0 +compress/channel_pack=0 +mipmaps/generate=false +mipmaps/limit=-1 +roughness/mode=0 +roughness/src_normal="" +process/channel_remap/red=0 +process/channel_remap/green=1 +process/channel_remap/blue=2 +process/channel_remap/alpha=3 +process/fix_alpha_border=true +process/premult_alpha=false +process/normal_map_invert_y=false +process/hdr_as_srgb=false +process/hdr_clamp_exposure=false +process/size_limit=0 +detect_3d/compress_to=1 diff --git a/scripts/quality_gate.sh b/scripts/quality_gate.sh index c3583bf..bbf8b54 100755 --- a/scripts/quality_gate.sh +++ b/scripts/quality_gate.sh @@ -131,6 +131,14 @@ printf 'Quality gate: site baker unit tests\n' 2>&1 | tee "$QUALITY_TEMP_DIRECTORY/site-baker-unit-console.log" check_godot_log "site baker unit tests" "$QUALITY_TEMP_DIRECTORY/site-baker-unit-console.log" +printf 'Quality gate: site content contract tests\n' +"$GODOT_EXECUTABLE" --headless \ + --log-file "$QUALITY_TEMP_DIRECTORY/site-content-contract.log" \ + --path . \ + --script tests/contract/run_site_content_tests.gd \ + 2>&1 | tee "$QUALITY_TEMP_DIRECTORY/site-content-contract-console.log" +check_godot_log "site content contract tests" "$QUALITY_TEMP_DIRECTORY/site-content-contract-console.log" + printf 'Quality gate: production foundation integration\n' "$GODOT_EXECUTABLE" --headless \ --log-file "$QUALITY_TEMP_DIRECTORY/foundation-integration.log" \ diff --git a/tests/contract/run_site_content_tests.gd b/tests/contract/run_site_content_tests.gd new file mode 100644 index 0000000..8c5ac9b --- /dev/null +++ b/tests/contract/run_site_content_tests.gd @@ -0,0 +1,142 @@ +extends SceneTree + +const SonarMaterial = preload("res://game/domain/sonar/sonar_material.gd") +const DiveSiteDefinition = preload("res://game/domain/sites/dive_site_definition.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 DiveDifficultyProfile = preload("res://game/domain/rules/dive_difficulty_profile.gd") +const DiveUpgradeEffects = preload("res://game/domain/rules/dive_upgrade_effects.gd") +const DiveAccessibilityAdjustments = preload( + "res://game/domain/rules/dive_accessibility_adjustments.gd" +) +const DiveRulesResolver = preload("res://game/domain/rules/dive_rules_resolver.gd") + +const SITE_PATH := "res://content/sites/zone1_vigilant.tres" +const ROUTE_CLEARANCE_M := 3.0 +const MINIMUM_LANDMARK_SPACING_M := 60.0 +const TEST_COUNT := 30 + +var _failures: Array[String] = [] + + +func _init() -> void: + _test_zone1_content() + + if _failures.is_empty(): + print("Site content contract tests: %d passed, 0 failed." % TEST_COUNT) + quit(0) + else: + for failure: String in _failures: + push_error(failure) + print("Site content contract tests: %d failed." % _failures.size()) + quit(1) + + +func _test_zone1_content() -> void: + var definition := load(SITE_PATH) as DiveSiteDefinition + _expect(definition != null, "zone 1 definition loads") + if definition == null: + return + _expect(definition.validation_error().is_empty(), "zone 1 definition is valid: %s" % definition.validation_error()) + var field := definition.height_field() + if field == null: + return + var rules = DiveRulesResolver.new().resolve( + DiveDifficultyProfile.standard(), + DiveUpgradeEffects.new(), + DiveAccessibilityAdjustments.new() + ) + var certified_m: float = rules.certified_depth_m + var critical_m: float = rules.critical_depth_m + + _expect(definition.playable_bounds_m.is_equal_approx(Rect2(-300.0, -300.0, 600.0, 600.0)), "playable zone is 600 x 600 m") + _expect(field.minimum_elevation_m() >= -88.5 and field.maximum_elevation_m() <= -19.5, "terrain stays between 20 m and 88 m deep") + _expect(field.cell_size_m() == 1.5 and field.columns() == 513 and field.rows() == 513, "terrain grid is 513 x 513 cells of 1.5 m") + + var targets := definition.placements_of_kind(SitePlacement.Kind.WRECK_TARGET) + var decoys := definition.placements_of_kind(SitePlacement.Kind.WRECK_DECOY) + var items := definition.placements_of_kind(SitePlacement.Kind.SECONDARY_ITEM) + var anchors := definition.placements_of_kind(SitePlacement.Kind.VESSEL_ANCHOR) + var debris := definition.placements_of_kind(SitePlacement.Kind.DEBRIS) + var landmarks := definition.placements_of_kind(SitePlacement.Kind.LANDMARK) + _expect(targets.size() == 1, "exactly one target wreck") + _expect(decoys.size() == 2, "exactly two decoy wrecks") + _expect(items.size() == 1, "exactly one secondary item") + _expect(anchors.size() == 1, "exactly one vessel anchor") + _expect(debris.size() >= 3, "a debris trail of at least three pieces") + _expect(landmarks.size() >= 4, "at least four named landmarks") + + for wreck: SitePlacement in targets + decoys: + _expect(-wreck.position_m.y <= certified_m, "%s lies above the certified depth" % wreck.placement_id) + _expect(_rests_on_terrain(field, wreck), "%s rests on or in the seabed" % wreck.placement_id) + if items.size() == 1: + var item_depth := -items[0].position_m.y + _expect(item_depth > certified_m and item_depth <= critical_m, "secondary item is between 100 %% and 115 %% of the certified depth (%.1f m)" % item_depth) + _expect(_rests_on_terrain(field, items[0]), "secondary item rests on the seabed") + for piece: SitePlacement in debris: + if -piece.position_m.y < 45.0 or -piece.position_m.y > 66.0 or not _rests_on_terrain(field, piece): + _failures.append("debris %s is off the slope or floating" % piece.placement_id) + _expect(true, "debris pieces checked individually") + + if anchors.size() == 1: + var seabed_under_anchor := -field.elevation_at(anchors[0].position_m.x, anchors[0].position_m.z) + _expect(seabed_under_anchor >= 20.0 and seabed_under_anchor <= 30.0, "vessel anchors over 20-30 m of seabed") + var spawn := definition.spawn_position_m + _expect(spawn.y - field.elevation_at(spawn.x, spawn.z) >= 8.0, "spawn has at least 8 m of water under the keel") + _expect(is_equal_approx(-spawn.y, 10.0), "spawn is 10 m deep") + + var safe := definition.route(&"safe_ridge") + var risky := definition.route(&"risky_groove") + _expect(safe != null and safe.maximum_depth_m == 32.0, "safe route is declared with a 32 m ceiling") + _expect(risky != null and risky.maximum_depth_m == 48.0, "risky route is declared with a 48 m ceiling") + if safe != null and risky != null and targets.size() == 1: + _expect(_route_clears_terrain(field, safe), "safe route clears the terrain by %.0f m" % ROUTE_CLEARANCE_M) + _expect(_route_clears_terrain(field, risky), "risky route clears the terrain by %.0f m" % ROUTE_CLEARANCE_M) + var target := targets[0].position_m + _expect( + _xz_distance(safe.waypoints_m[-1], target) <= 25.0 and _xz_distance(risky.waypoints_m[-1], target) <= 25.0, + "both routes end within 25 m of the target" + ) + _expect(_route_length(risky) < _route_length(safe) * 0.9, "risky route is shorter than the safe route") + + var spaced := true + for i in landmarks.size(): + for j in range(i + 1, landmarks.size()): + if _xz_distance(landmarks[i].position_m, landmarks[j].position_m) < MINIMUM_LANDMARK_SPACING_M: + spaced = false + _expect(spaced, "landmarks are at least 60 m apart") + + +func _rests_on_terrain(field: TerrainHeightField, placement: SitePlacement) -> bool: + var seabed := field.elevation_at(placement.position_m.x, placement.position_m.z) + var bottom := placement.position_m.y - placement.footprint_m.y * 0.5 + return bottom >= seabed - placement.footprint_m.y and bottom <= seabed + 1.0 + + +func _route_clears_terrain(field: TerrainHeightField, route: SiteRoute) -> bool: + for i in range(route.waypoints_m.size() - 1): + var a := route.waypoints_m[i] + var b := route.waypoints_m[i + 1] + var steps := maxi(int(ceilf(a.distance_to(b) / 2.0)), 1) + for step in range(steps + 1): + var point := a.lerp(b, float(step) / float(steps)) + if point.y - field.elevation_at(point.x, point.z) < ROUTE_CLEARANCE_M: + return false + return true + + +func _route_length(route: SiteRoute) -> float: + var length := 0.0 + for i in range(route.waypoints_m.size() - 1): + length += route.waypoints_m[i].distance_to(route.waypoints_m[i + 1]) + return length + + +func _xz_distance(a: Vector3, b: Vector3) -> float: + return Vector2(a.x, a.z).distance_to(Vector2(b.x, b.z)) + + +func _expect(condition: bool, label: String) -> void: + if not condition: + _failures.append(label) diff --git a/tools/sites/zone1_vigilant_recipe.gd b/tools/sites/zone1_vigilant_recipe.gd new file mode 100644 index 0000000..63a1e0b --- /dev/null +++ b/tools/sites/zone1_vigilant_recipe.gd @@ -0,0 +1,143 @@ +class_name Zone1VigilantRecipe +extends RefCounted + +const SiteTerrainBaker = preload("res://tools/site_terrain_baker.gd") +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 DiveSiteDefinition = preload("res://game/domain/sites/dive_site_definition.gd") +const SitePlacement = preload("res://game/domain/sites/site_placement.gd") +const SiteRoute = preload("res://game/domain/sites/site_route.gd") + +# Groove axis (la Saignée): north entrance to the shelf break. +const GROOVE_START := Vector2(-60.0, -140.0) +const GROOVE_END := Vector2(-10.0, 120.0) +# Ridge axis (l'Échine). Starts south of the vessel so its crest never rises above 20 m. +const RIDGE_START := Vector2(70.0, -150.0) +const RIDGE_END := Vector2(80.0, 90.0) +const BREAK_Z := 125.0 + + +static func recipe() -> SiteTerrainBaker.Recipe: + var result := SiteTerrainBaker.Recipe.new() + result.columns = 513 + result.rows = 513 + result.cell_size_m = 1.5 + result.origin_x_m = -384.0 + result.origin_z_m = -384.0 + result.floor_elevation_m = -88.0 + result.rock_slope_degrees = 20.0 + result.rock_reflectivity = 0.72 + result.sand_reflectivity = 0.45 + result.primitives = [ + # Plateau: 24 m deep at the vessel (z = -300), 6 m deeper over 400 m toward the south. + SiteTerrainBaker.SlopedPlane.new(24.0, 0.0, -0.015, 0.0, -300.0), + # L'Échine: long north-south rock ridge, 6 m high, flanks steeper than 20°. + SiteTerrainBaker.Ridge.new(RIDGE_START, RIDGE_END, 12.0, 6.0, 25.0), + # Les Deux Frères: twin pinnacles, 20 m apart, 6 m tall (crests stay below 20 m deep). + SiteTerrainBaker.Blob.new(Vector2(-160.0, -130.0), 8.4, 8.4, 6.0), + SiteTerrainBaker.Blob.new(Vector2(-142.0, -121.0), 8.4, 8.4, 6.0), + # La Saignée: 16 m wide groove, 9 m deep, steep walls, the risky route. + SiteTerrainBaker.Groove.new(GROOVE_START, GROOVE_END, 8.0, 9.0, 4.0), + # Basin and patch reef for the steel coaster decoy, east of the ridge. + SiteTerrainBaker.Blob.new(Vector2(140.0, 40.0), 30.0, 30.0, -8.0), + SiteTerrainBaker.Blob.new(Vector2(125.0, 30.0), 7.7, 7.7, 2.5), + SiteTerrainBaker.Blob.new(Vector2(155.0, 55.0), 7.7, 7.7, 2.5), + SiteTerrainBaker.Blob.new(Vector2(135.0, 60.0), 7.7, 7.7, 2.5), + # Shelf break (la rupture) then the drop-off (le tombant), clamped at -88 m. + SiteTerrainBaker.Step.new(Vector2(0.0, BREAK_Z), Vector2(0.0, 1.0), 16.0, 10.0, 0.7), + # Scree cones at the foot of the drop-off. + SiteTerrainBaker.Blob.new(Vector2(-40.0, 200.0), 6.3, 6.3, 2.0), + SiteTerrainBaker.Blob.new(Vector2(-5.0, 205.0), 6.3, 6.3, 2.0), + SiteTerrainBaker.Blob.new(Vector2(25.0, 198.0), 6.3, 6.3, 2.0), + # Les Rides: sand ripples near the vessel only. + SiteTerrainBaker.Ripples.new(Vector2(1.0, 0.35), 9.0, 0.45, Vector2(0.0, -220.0), 90.0), + # Deterministic roughness everywhere. + SiteTerrainBaker.Roughness.new(1933, 0.02, 0.6), + ] + result.material_patches = [ + SiteTerrainBaker.MaterialPatch.new(Vector2(140.0, 40.0), 28.0, SonarMaterial.Value.ROCK), + ] + return result + + +static func definition(terrain: SiteTerrainData) -> DiveSiteDefinition: + var field := TerrainHeightField.from_data(terrain) + var result := DiveSiteDefinition.new() + result.site_id = &"zone1_vigilant" + result.display_name = "Banc du Vigilant" + result.terrain = terrain + result.playable_bounds_m = Rect2(-300.0, -300.0, 600.0, 600.0) + result.spawn_position_m = Vector3(0.0, -10.0, -250.0) + result.spawn_yaw_radians = 0.0 + result.placements = [ + _on_seabed(field, &"prospection_vessel", SitePlacement.Kind.WRECK_TARGET, + Vector2(-12.0, 108.0), 0.35, Vector3(28.0, 8.0, 9.0), 2.0), + _on_seabed(field, &"decoy_yacht", SitePlacement.Kind.WRECK_DECOY, + Vector2(-40.0, -200.0), 1.1, Vector3(12.0, 3.0, 4.0), 0.5), + _on_seabed(field, &"decoy_coaster", SitePlacement.Kind.WRECK_DECOY, + Vector2(140.0, 40.0), -0.6, Vector3(26.0, 6.0, 8.0), 1.2), + _on_seabed(field, &"secondary_item", SitePlacement.Kind.SECONDARY_ITEM, + Vector2(-6.0, 157.0), 0.0, Vector3(1.0, 1.0, 1.0), 0.0), + _on_seabed(field, &"debris_1", SitePlacement.Kind.DEBRIS, + Vector2(-12.0, 134.0), 0.4, Vector3(3.0, 2.0, 2.0), 0.3), + _on_seabed(field, &"debris_2", SitePlacement.Kind.DEBRIS, + Vector2(-10.0, 139.0), 1.2, Vector3(6.0, 2.5, 2.5), 0.4), + _on_seabed(field, &"debris_3", SitePlacement.Kind.DEBRIS, + Vector2(-9.0, 146.0), 0.0, Vector3(2.0, 1.5, 2.0), 0.2), + _on_seabed(field, &"debris_4", SitePlacement.Kind.DEBRIS, + Vector2(-7.0, 152.0), 2.0, Vector3(4.0, 1.5, 1.5), 0.3), + _marker(&"vessel_anchor", SitePlacement.Kind.VESSEL_ANCHOR, Vector3(0.0, -14.0, -250.0)), + _marker(&"echine", SitePlacement.Kind.LANDMARK, Vector3(75.0, -20.0, -50.0)), + _marker(&"deux_freres", SitePlacement.Kind.LANDMARK, Vector3(-151.0, -18.0, -125.0)), + _marker(&"rides", SitePlacement.Kind.LANDMARK, Vector3(0.0, -25.0, -230.0)), + _marker(&"saignee_entrance", SitePlacement.Kind.LANDMARK, Vector3(-60.0, -34.0, -140.0)), + _marker(&"rupture", SitePlacement.Kind.LANDMARK, Vector3(40.0, -40.0, 125.0)), + ] + result.routes = [ + _route(&"safe_ridge", 32.0, [ + Vector3(0.0, -14.0, -250.0), Vector3(70.0, -16.0, -200.0), Vector3(76.0, -16.0, -100.0), + Vector3(78.0, -16.0, 0.0), Vector3(76.0, -18.0, 80.0), Vector3(40.0, -24.0, 105.0), + Vector3(0.0, -26.0, 100.0), Vector3(-14.0, -30.0, 98.0), + ]), + _route(&"risky_groove", 48.0, [ + Vector3(0.0, -14.0, -250.0), Vector3(-60.0, -20.0, -150.0), Vector3(-50.0, -31.0, -100.0), + Vector3(-35.0, -33.0, -20.0), Vector3(-22.0, -34.0, 60.0), Vector3(-14.0, -33.0, 98.0), + ]), + ] + return result + + +static func _on_seabed( + field: TerrainHeightField, + id: StringName, + kind: SitePlacement.Kind, + xz: Vector2, + yaw_radians: float, + footprint_m: Vector3, + burial_m: float +) -> SitePlacement: + var placement := SitePlacement.new() + placement.placement_id = id + placement.kind = kind + placement.footprint_m = footprint_m + placement.yaw_radians = yaw_radians + var seabed := field.elevation_at(xz.x, xz.y) + placement.position_m = Vector3(xz.x, seabed + footprint_m.y * 0.5 - burial_m, xz.y) + return placement + + +static func _marker(id: StringName, kind: SitePlacement.Kind, position_m: Vector3) -> SitePlacement: + var placement := SitePlacement.new() + placement.placement_id = id + placement.kind = kind + placement.position_m = position_m + return placement + + +static func _route(id: StringName, maximum_depth_m: float, waypoints: Array) -> SiteRoute: + var route := SiteRoute.new() + route.route_id = id + route.maximum_depth_m = maximum_depth_m + route.waypoints_m = PackedVector3Array(waypoints) + return route