From b6f6b53a70947983b987ea616e031672b98dea9d Mon Sep 17 00:00:00 2001 From: David Date: Tue, 6 Oct 2026 07:59:50 +0200 Subject: [PATCH] Render bathymetric sonar surface --- .../cockpit/cockpit_interior.tscn | 7 +- .../cockpit/cockpit_material_palette.gd | 2 - .../cockpit/cockpit_sonar_console.gd | 22 +-- game/presentation/sonar/sonar_display.gd | 12 ++ .../sonar/sonar_relief_mesh_builder.gd | 151 +++++++++++++++ .../sonar/sonar_relief_renderer.gd | 175 +++++++----------- .../run_vertical_slice_foundation.gd | 71 ++++++- 7 files changed, 305 insertions(+), 135 deletions(-) create mode 100644 game/presentation/sonar/sonar_relief_mesh_builder.gd diff --git a/game/presentation/cockpit/cockpit_interior.tscn b/game/presentation/cockpit/cockpit_interior.tscn index fcfe6e1..6910937 100644 --- a/game/presentation/cockpit/cockpit_interior.tscn +++ b/game/presentation/cockpit/cockpit_interior.tscn @@ -388,11 +388,12 @@ mesh = SubResource("KnobMesh") material_override = SubResource("BrassMaterial") [node name="SonarMonitor" type="Node3D" parent="."] -position = Vector3(0.74, 0.42, -1.18) -rotation_degrees = Vector3(-5, -22, -2) +position = Vector3(0.54, 0.59, -1.04) +rotation_degrees = Vector3(-5, -42, -2) +scale = Vector3(0.6, 0.6, 0.6) [node name="Housing" type="MeshInstance3D" parent="SonarMonitor"] -scale = Vector3(0.5, 0.34, 0.1) +scale = Vector3(0.6, 0.37, 0.1) mesh = SubResource("UnitBoxMesh") material_override = SubResource("PaintedMaterial") diff --git a/game/presentation/cockpit/cockpit_material_palette.gd b/game/presentation/cockpit/cockpit_material_palette.gd index b3907f4..8532676 100644 --- a/game/presentation/cockpit/cockpit_material_palette.gd +++ b/game/presentation/cockpit/cockpit_material_palette.gd @@ -38,7 +38,6 @@ var indicator_green: StandardMaterial3D var indicator_amber: StandardMaterial3D var indicator_red: StandardMaterial3D var sonar_grid: StandardMaterial3D -var sonar_trace: StandardMaterial3D var warm_glass: StandardMaterial3D @@ -94,7 +93,6 @@ func _init() -> void: indicator_amber = _emissive_material(Color(1.0, 0.43, 0.035), 2.8) indicator_red = _emissive_material(Color(0.9, 0.045, 0.018), 2.8) sonar_grid = _emissive_material(Color(0.02, 0.20, 0.22), 0.55) - sonar_trace = _emissive_material(Color(0.12, 0.78, 0.78), 1.8) warm_glass = _warm_glass_material() diff --git a/game/presentation/cockpit/cockpit_sonar_console.gd b/game/presentation/cockpit/cockpit_sonar_console.gd index 39a6b57..aded011 100644 --- a/game/presentation/cockpit/cockpit_sonar_console.gd +++ b/game/presentation/cockpit/cockpit_sonar_console.gd @@ -16,7 +16,7 @@ func _init(materials: CockpitMaterialPalette) -> void: _materials = materials name = "SonarConsoleDetailing" _build_bezel() - _build_graticule() + _build_grid() _build_control_strip() _build_mounting_hardware() @@ -100,7 +100,7 @@ func _build_control_strip() -> void: _control_count += 1 -func _build_graticule() -> void: +func _build_grid() -> void: for x: float in [-0.145, -0.085, -0.025, 0.035, 0.095]: CockpitMeshFactory.add_box( "SonarGridVertical", @@ -117,24 +117,6 @@ func _build_graticule() -> void: Vector3(0.33, 0.0015, 0.002), _materials.sonar_grid ) - var trace_points: Array[Vector3] = [ - Vector3(-0.185, -0.035, 0.080), - Vector3(-0.135, -0.020, 0.080), - Vector3(-0.085, -0.055, 0.080), - Vector3(-0.030, -0.005, 0.080), - Vector3(0.025, -0.030, 0.080), - Vector3(0.075, 0.030, 0.080), - Vector3(0.125, 0.075, 0.080), - ] - for point_index in trace_points.size() - 1: - CockpitMeshFactory.add_cylinder_between( - "SonarReferenceTrace", - self, - trace_points[point_index], - trace_points[point_index + 1], - 0.0025, - _materials.sonar_trace - ) func _build_mounting_hardware() -> void: diff --git a/game/presentation/sonar/sonar_display.gd b/game/presentation/sonar/sonar_display.gd index 298024a..fdec735 100644 --- a/game/presentation/sonar/sonar_display.gd +++ b/game/presentation/sonar/sonar_display.gd @@ -53,6 +53,18 @@ func built_row_count() -> int: return _renderer.built_row_count() +func built_triangle_count() -> int: + return _renderer.built_triangle_count() + + +func built_grid_segment_count() -> int: + return _renderer.built_grid_segment_count() + + +func idle_prompt_visible() -> bool: + return _renderer.idle_prompt_visible() + + func status_text() -> String: return _status_label.text diff --git a/game/presentation/sonar/sonar_relief_mesh_builder.gd b/game/presentation/sonar/sonar_relief_mesh_builder.gd new file mode 100644 index 0000000..1ca823a --- /dev/null +++ b/game/presentation/sonar/sonar_relief_mesh_builder.gd @@ -0,0 +1,151 @@ +class_name SonarReliefMeshBuilder +extends RefCounted + +const SonarMaterial = preload("res://game/domain/sonar/sonar_material.gd") +const SonarSample = preload("res://game/domain/sonar/sonar_sample.gd") +const SonarScan = preload("res://game/domain/sonar/sonar_scan.gd") + +const COLOR_DEEP := Color("173f91") +const COLOR_LOW := Color("168fc1") +const COLOR_MIDDLE := Color("36b979") +const COLOR_HIGH := Color("d7d93d") +const COLOR_PEAK := Color("ee5734") +const COLOR_METAL := Color("ff6b22") + + +static func build_full_mesh( + scan: SonarScan, + material: StandardMaterial3D +) -> ArrayMesh: + var mesh := ArrayMesh.new() + for row in range(1, scan.samples.rows): + add_row_band(mesh, scan, row, material) + return mesh + + +static func add_row_band( + mesh: ArrayMesh, + scan: SonarScan, + row: int, + material: StandardMaterial3D +) -> int: + var vertices := PackedVector3Array() + var normals := PackedVector3Array() + var colors := PackedColorArray() + for column in range(scan.samples.columns - 1): + var top_left: SonarSample = scan.samples.sample_at(row - 1, column) + var top_right: SonarSample = scan.samples.sample_at(row - 1, column + 1) + var bottom_left: SonarSample = scan.samples.sample_at(row, column) + var bottom_right: SonarSample = scan.samples.sample_at(row, column + 1) + if not ( + top_left.observed + and top_right.observed + and bottom_left.observed + and bottom_right.observed + ): + continue + _append_triangle( + vertices, normals, colors, scan, + top_left, bottom_left, top_right + ) + _append_triangle( + vertices, normals, colors, scan, + top_right, bottom_left, bottom_right + ) + if vertices.is_empty(): + return 0 + var arrays: Array = [] + arrays.resize(Mesh.ARRAY_MAX) + arrays[Mesh.ARRAY_VERTEX] = vertices + arrays[Mesh.ARRAY_NORMAL] = normals + arrays[Mesh.ARRAY_COLOR] = colors + mesh.add_surface_from_arrays(Mesh.PRIMITIVE_TRIANGLES, arrays) + mesh.surface_set_material(mesh.get_surface_count() - 1, material) + return vertices.size() / 3 + + +static func add_grid_band( + mesh: ArrayMesh, + scan: SonarScan, + row: int, + material: StandardMaterial3D +) -> int: + var vertices := PackedVector3Array() + for column in scan.samples.columns: + if column % 6 != 0 and column != scan.samples.columns - 1: + continue + _append_grid_segment( + vertices, + scan.samples.sample_at(row - 1, column), + scan.samples.sample_at(row, column) + ) + if row == 1 or row % 4 == 0 or row == scan.samples.rows - 1: + for column in range(scan.samples.columns - 1): + _append_grid_segment( + vertices, + scan.samples.sample_at(row, column), + scan.samples.sample_at(row, column + 1) + ) + if vertices.is_empty(): + return 0 + var arrays: Array = [] + arrays.resize(Mesh.ARRAY_MAX) + arrays[Mesh.ARRAY_VERTEX] = vertices + mesh.add_surface_from_arrays(Mesh.PRIMITIVE_LINES, arrays) + mesh.surface_set_material(mesh.get_surface_count() - 1, material) + return vertices.size() / 2 + + +static func _append_grid_segment( + vertices: PackedVector3Array, + a: SonarSample, + b: SonarSample +) -> void: + if not a.observed or not b.observed: + return + vertices.append(a.position_m + a.normal * 0.08) + vertices.append(b.position_m + b.normal * 0.08) + + +static func _append_triangle( + vertices: PackedVector3Array, + normals: PackedVector3Array, + colors: PackedColorArray, + scan: SonarScan, + a: SonarSample, + b: SonarSample, + c: SonarSample +) -> void: + for sample: SonarSample in [a, b, c]: + vertices.append(sample.position_m) + normals.append(sample.normal) + colors.append(_sample_color(scan, sample)) + + +static func _sample_color(scan: SonarScan, sample: SonarSample) -> Color: + if sample.material == SonarMaterial.Value.METAL: + return COLOR_METAL + var height := inverse_lerp( + scan.minimum_elevation_m, + scan.maximum_elevation_m, + sample.elevation_m + ) + var color: Color + if height <= 0.28: + color = COLOR_DEEP.lerp(COLOR_LOW, height / 0.28) + elif height <= 0.52: + color = COLOR_LOW.lerp(COLOR_MIDDLE, (height - 0.28) / 0.24) + elif height <= 0.76: + color = COLOR_MIDDLE.lerp(COLOR_HIGH, (height - 0.52) / 0.24) + else: + color = COLOR_HIGH.lerp(COLOR_PEAK, (height - 0.76) / 0.24) + var light_direction := Vector3(-0.35, 0.82, -0.45).normalized() + var slope_light := clampf(sample.normal.dot(light_direction), 0.0, 1.0) + var shade := lerpf(0.68, 1.0, slope_light) + var lit_color := color.lerp(Color.WHITE, sample.intensity * 0.08) + return Color( + lit_color.r * shade, + lit_color.g * shade, + lit_color.b * shade, + lit_color.a + ) diff --git a/game/presentation/sonar/sonar_relief_renderer.gd b/game/presentation/sonar/sonar_relief_renderer.gd index 70361dc..8dc4f10 100644 --- a/game/presentation/sonar/sonar_relief_renderer.gd +++ b/game/presentation/sonar/sonar_relief_renderer.gd @@ -1,25 +1,24 @@ class_name BathymetricSonarRenderer extends SubViewport -const SonarMaterial = preload("res://game/domain/sonar/sonar_material.gd") -const SonarSample = preload("res://game/domain/sonar/sonar_sample.gd") const SonarScan = preload("res://game/domain/sonar/sonar_scan.gd") - -const COLOR_DEEP := Color("173f91") -const COLOR_LOW := Color("168fc1") -const COLOR_MIDDLE := Color("36b979") -const COLOR_HIGH := Color("d7d93d") -const COLOR_PEAK := Color("ee5734") -const COLOR_METAL := Color("ff6b22") +const SonarReliefMeshBuilder = preload( + "res://game/presentation/sonar/sonar_relief_mesh_builder.gd" +) var _current_mesh_instance: MeshInstance3D +var _current_grid_instance: MeshInstance3D var _previous_mesh_instance: MeshInstance3D var _contact_root: Node3D +var _idle_prompt: Label var _current_material: StandardMaterial3D +var _grid_material: StandardMaterial3D var _previous_material: StandardMaterial3D var _current_scan_id: StringName = &"" var _previous_scan_id: StringName = &"" var _built_current_rows := 0 +var _built_current_triangle_count := 0 +var _built_current_grid_segment_count := 0 func _ready() -> void: @@ -38,10 +37,14 @@ func present( ) -> void: if current_scan == null: return + _idle_prompt.visible = false if current_scan.scan_id != _current_scan_id: _current_scan_id = current_scan.scan_id _current_mesh_instance.mesh = ArrayMesh.new() + _current_grid_instance.mesh = ArrayMesh.new() _built_current_rows = 0 + _built_current_triangle_count = 0 + _built_current_grid_segment_count = 0 _rebuild_contacts(current_scan) _build_current_rows(current_scan, clampi(visible_rows, 0, current_scan.samples.rows)) _update_contact_visibility(current_scan, visible_rows) @@ -54,7 +57,7 @@ func present( _previous_mesh_instance.visible = previous_opacity > 0.0 if previous_scan.scan_id != _previous_scan_id: _previous_scan_id = previous_scan.scan_id - _previous_mesh_instance.mesh = _build_full_mesh( + _previous_mesh_instance.mesh = SonarReliefMeshBuilder.build_full_mesh( previous_scan, _previous_material ) @@ -69,6 +72,18 @@ func built_row_count() -> int: return _built_current_rows +func built_triangle_count() -> int: + return _built_current_triangle_count + + +func built_grid_segment_count() -> int: + return _built_current_grid_segment_count + + +func idle_prompt_visible() -> bool: + return _idle_prompt != null and _idle_prompt.visible + + func visible_contact_count() -> int: var count := 0 for child: Node in _contact_root.get_children(): @@ -89,11 +104,16 @@ func _build_render_world() -> void: add_child(environment_node) _current_material = _relief_material(false) + _grid_material = _surface_grid_material() _previous_material = _relief_material(true) _current_mesh_instance = MeshInstance3D.new() _current_mesh_instance.name = "CurrentRelief" _current_mesh_instance.mesh = ArrayMesh.new() add_child(_current_mesh_instance) + _current_grid_instance = MeshInstance3D.new() + _current_grid_instance.name = "CurrentReliefGrid" + _current_grid_instance.mesh = ArrayMesh.new() + add_child(_current_grid_instance) _previous_mesh_instance = MeshInstance3D.new() _previous_mesh_instance.name = "PreviousRelief" _previous_mesh_instance.mesh = ArrayMesh.new() @@ -112,7 +132,7 @@ func _build_render_world() -> void: camera.far = 240.0 camera.position = Vector3(0.0, 65.0, -30.0) add_child(camera) - camera.look_at(Vector3(0.0, -10.0, 60.0), Vector3.UP) + camera.look_at(Vector3(0.0, -38.0, 58.0), Vector3.UP) camera.current = true var key_light := DirectionalLight3D.new() @@ -124,6 +144,22 @@ func _build_render_world() -> void: fill_light.rotation_degrees = Vector3(-28.0, 145.0, 18.0) fill_light.light_energy = 0.38 add_child(fill_light) + _build_idle_prompt() + + +func _build_idle_prompt() -> void: + _idle_prompt = Label.new() + _idle_prompt.name = "IdlePrompt" + _idle_prompt.position = Vector2(180.0, 238.0) + _idle_prompt.size = Vector2(600.0, 64.0) + _idle_prompt.text = "SONAR PRÊT · F POUR ÉMETTRE" + _idle_prompt.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER + _idle_prompt.vertical_alignment = VERTICAL_ALIGNMENT_CENTER + _idle_prompt.add_theme_font_size_override("font_size", 26) + _idle_prompt.add_theme_color_override("font_color", Color(0.38, 0.88, 0.86)) + _idle_prompt.add_theme_color_override("font_outline_color", Color(0.0, 0.04, 0.05)) + _idle_prompt.add_theme_constant_override("outline_size", 6) + add_child(_idle_prompt) func _relief_material(transparent: bool) -> StandardMaterial3D: @@ -137,108 +173,31 @@ func _relief_material(transparent: bool) -> StandardMaterial3D: return material +func _surface_grid_material() -> StandardMaterial3D: + var material := StandardMaterial3D.new() + material.shading_mode = BaseMaterial3D.SHADING_MODE_UNSHADED + material.transparency = BaseMaterial3D.TRANSPARENCY_ALPHA + material.albedo_color = Color(0.32, 0.95, 0.91, 0.34) + material.emission_enabled = true + material.emission = Color(0.18, 0.78, 0.76) + material.emission_energy_multiplier = 1.35 + return material + + func _build_current_rows(scan: SonarScan, visible_rows: int) -> void: var mesh := _current_mesh_instance.mesh as ArrayMesh + var grid_mesh := _current_grid_instance.mesh as ArrayMesh while _built_current_rows < visible_rows: if _built_current_rows > 0: - _add_row_band(mesh, scan, _built_current_rows, _current_material) + _built_current_triangle_count += SonarReliefMeshBuilder.add_row_band( + mesh, scan, _built_current_rows, _current_material + ) + _built_current_grid_segment_count += SonarReliefMeshBuilder.add_grid_band( + grid_mesh, scan, _built_current_rows, _grid_material + ) _built_current_rows += 1 -func _build_full_mesh( - scan: SonarScan, - material: StandardMaterial3D -) -> ArrayMesh: - var mesh := ArrayMesh.new() - for row in range(1, scan.samples.rows): - _add_row_band(mesh, scan, row, material) - return mesh - - -func _add_row_band( - mesh: ArrayMesh, - scan: SonarScan, - row: int, - material: StandardMaterial3D -) -> void: - var vertices := PackedVector3Array() - var normals := PackedVector3Array() - var colors := PackedColorArray() - for column in range(scan.samples.columns - 1): - var top_left: SonarSample = scan.samples.sample_at(row - 1, column) - var top_right: SonarSample = scan.samples.sample_at(row - 1, column + 1) - var bottom_left: SonarSample = scan.samples.sample_at(row, column) - var bottom_right: SonarSample = scan.samples.sample_at(row, column + 1) - if not ( - top_left.observed - and top_right.observed - and bottom_left.observed - and bottom_right.observed - ): - continue - _append_triangle( - vertices, normals, colors, scan, - top_left, bottom_left, top_right - ) - _append_triangle( - vertices, normals, colors, scan, - top_right, bottom_left, bottom_right - ) - if vertices.is_empty(): - return - var arrays: Array = [] - arrays.resize(Mesh.ARRAY_MAX) - arrays[Mesh.ARRAY_VERTEX] = vertices - arrays[Mesh.ARRAY_NORMAL] = normals - arrays[Mesh.ARRAY_COLOR] = colors - mesh.add_surface_from_arrays(Mesh.PRIMITIVE_TRIANGLES, arrays) - mesh.surface_set_material(mesh.get_surface_count() - 1, material) - - -func _append_triangle( - vertices: PackedVector3Array, - normals: PackedVector3Array, - colors: PackedColorArray, - scan: SonarScan, - a: SonarSample, - b: SonarSample, - c: SonarSample -) -> void: - for sample: SonarSample in [a, b, c]: - vertices.append(sample.position_m) - normals.append(sample.normal) - colors.append(_sample_color(scan, sample)) - - -func _sample_color(scan: SonarScan, sample: SonarSample) -> Color: - if sample.material == SonarMaterial.Value.METAL: - return COLOR_METAL - var height := inverse_lerp( - scan.minimum_elevation_m, - scan.maximum_elevation_m, - sample.elevation_m - ) - var color: Color - if height <= 0.28: - color = COLOR_DEEP.lerp(COLOR_LOW, height / 0.28) - elif height <= 0.52: - color = COLOR_LOW.lerp(COLOR_MIDDLE, (height - 0.28) / 0.24) - elif height <= 0.76: - color = COLOR_MIDDLE.lerp(COLOR_HIGH, (height - 0.52) / 0.24) - else: - color = COLOR_HIGH.lerp(COLOR_PEAK, (height - 0.76) / 0.24) - var light_direction := Vector3(-0.35, 0.82, -0.45).normalized() - var slope_light := clampf(sample.normal.dot(light_direction), 0.0, 1.0) - var shade := lerpf(0.68, 1.0, slope_light) - var lit_color := color.lerp(Color.WHITE, sample.intensity * 0.08) - return Color( - lit_color.r * shade, - lit_color.g * shade, - lit_color.b * shade, - lit_color.a - ) - - func _rebuild_contacts(scan: SonarScan) -> void: for child: Node in _contact_root.get_children(): _contact_root.remove_child(child) @@ -253,9 +212,9 @@ func _rebuild_contacts(scan: SonarScan) -> void: marker.set_meta("forward_distance_m", contact.center_m.z) var material := StandardMaterial3D.new() material.shading_mode = BaseMaterial3D.SHADING_MODE_UNSHADED - material.albedo_color = COLOR_METAL + material.albedo_color = SonarReliefMeshBuilder.COLOR_METAL material.emission_enabled = true - material.emission = COLOR_METAL + material.emission = SonarReliefMeshBuilder.COLOR_METAL material.emission_energy_multiplier = 4.0 marker.material_override = material _contact_root.add_child(marker) diff --git a/tests/integration/run_vertical_slice_foundation.gd b/tests/integration/run_vertical_slice_foundation.gd index d47bd11..adbf33b 100644 --- a/tests/integration/run_vertical_slice_foundation.gd +++ b/tests/integration/run_vertical_slice_foundation.gd @@ -19,7 +19,7 @@ const ProductionInputAdapter = preload( "res://game/infrastructure/input/production_input_adapter.gd" ) -const TEST_COUNT := 69 +const TEST_COUNT := 78 var _failures: Array[String] = [] @@ -125,6 +125,28 @@ func _test_vehicle_slice(composition: VerticalSliceRoot) -> void: _expect(instruments.get_node("%WindowFrame") is MeshInstance3D, "cockpit has a physical porthole frame") _expect(instruments.get_node("%Dashboard") is Node3D, "cockpit has a physical dashboard") _expect(instruments.get_node("%SonarScreen") is MeshInstance3D, "cockpit reserves a physical sonar display") + var sonar_monitor := instruments.get_node("SonarMonitor") as Node3D + var sonar_screen := instruments.get_node("%SonarScreen") as MeshInstance3D + var sonar_housing := instruments.get_node("SonarMonitor/Housing") as MeshInstance3D + var cockpit_camera := camera.get_node("Camera") as Camera3D + var electrical_bank := detailing.get_node("SideEquipment/ElectricalBank") as Node3D + _expect(sonar_monitor.position.x > 0.5, "cockpit sonar is mounted on the right side") + _expect( + _sonar_screen_faces_camera(sonar_screen, cockpit_camera), + "right-side sonar display faces the pilot" + ) + _expect( + _sonar_screen_is_ahead_of_bank(sonar_screen, electrical_bank), + "right-side electrical equipment does not hide the sonar display" + ) + _expect( + _sonar_screen_clears_housing(sonar_screen, sonar_housing), + "sonar texture clears its housing without depth overlap" + ) + _expect( + instruments.find_children("SonarReferenceTrace", "MeshInstance3D", true).is_empty(), + "cockpit sonar does not invent a decorative profile" + ) _expect(detailing.pressure_hull_rib_count() >= 6, "cockpit exposes reinforced pressure-hull structure") _expect(detailing.pipe_segment_count() >= 13, "cockpit exposes a complete life-support pipe run") _expect(absf(detailing.battery_needle_degrees()) > 100.0, "analog battery gauge follows the dive snapshot") @@ -148,6 +170,7 @@ func _test_vehicle_slice(composition: VerticalSliceRoot) -> void: instruments.sonar_texture() == sonar_display.sonar_texture(), "physical and expanded sonar views share one texture" ) + _expect(sonar_display.idle_prompt_visible(), "idle sonar explains how to emit a scan") adapter.sonar_scan_requested.emit() var first_scan = sonar_service.current_scan() @@ -155,9 +178,12 @@ func _test_vehicle_slice(composition: VerticalSliceRoot) -> void: _expect(first_scan.samples.observed_count() > 1000, "reference acquisition captures dense bathymetry") _expect(first_scan.contacts.size() >= 1, "metal calibration target becomes a distinct contact") _expect("RELEVÉ EN COURS" in sonar_display.status_text(), "sonar display reports progressive reveal") - for frame in 8: + _expect(not sonar_display.idle_prompt_visible(), "sonar data replaces the idle instruction") + for frame in 18: await physics_frame _expect(sonar_display.built_row_count() > 0, "renderer reveals near rows incrementally") + _expect(sonar_display.built_triangle_count() >= 90, "sonar renders a dense relief surface, not a line") + _expect(sonar_display.built_grid_segment_count() > 0, "sonar overlays a perspective grid on the relief") adapter.sonar_view_toggle_requested.emit() _expect(sonar_display.is_expanded(), "sonar can expand without pausing the dive") adapter.sonar_view_toggle_requested.emit() @@ -276,6 +302,47 @@ func _pressed_key(keycode: Key) -> InputEventKey: return event +func _sonar_screen_faces_camera( + sonar_screen: MeshInstance3D, + cockpit_camera: Camera3D +) -> bool: + if sonar_screen == null or cockpit_camera == null: + return false + var direction_to_camera := ( + cockpit_camera.global_position - sonar_screen.global_position + ).normalized() + return sonar_screen.global_basis.z.normalized().dot(direction_to_camera) > 0.85 + + +func _sonar_screen_is_ahead_of_bank( + sonar_screen: MeshInstance3D, + electrical_bank: Node3D +) -> bool: + if sonar_screen == null or electrical_bank == null: + return false + for child: Node in electrical_bank.get_children(): + if child is MeshInstance3D and child.name.begins_with("JunctionBox"): + if sonar_screen.global_position.z <= (child as MeshInstance3D).global_position.z: + return false + return true + + +func _sonar_screen_clears_housing( + sonar_screen: MeshInstance3D, + sonar_housing: MeshInstance3D +) -> bool: + if sonar_screen == null or sonar_housing == null: + return false + var housing_box := sonar_housing.mesh as BoxMesh + if housing_box == null: + return false + var housing_front_z := ( + sonar_housing.position.z + + housing_box.size.z * sonar_housing.scale.z * 0.5 + ) + return sonar_screen.position.z >= housing_front_z + 0.005 + + func _binding_for(adapter: ProductionInputAdapter, action_id: StringName) -> String: for binding in adapter.binding_snapshots(): if binding.action_id == action_id: