diff --git a/game/flightplan/__init__.py b/game/flightplan/__init__.py index 51dce4a3..42c4e3e9 100644 --- a/game/flightplan/__init__.py +++ b/game/flightplan/__init__.py @@ -1 +1,2 @@ from .ipzonegeometry import IpZoneGeometry +from .joinzonegeometry import JoinZoneGeometry diff --git a/game/flightplan/joinzonegeometry.py b/game/flightplan/joinzonegeometry.py new file mode 100644 index 00000000..39c7d640 --- /dev/null +++ b/game/flightplan/joinzonegeometry.py @@ -0,0 +1,84 @@ +from __future__ import annotations + +from typing import TYPE_CHECKING + +import shapely.ops +from dcs import Point +from shapely.geometry import Point as ShapelyPoint, Polygon + +from game.theater import ConflictTheater +from game.utils import nautical_miles + +if TYPE_CHECKING: + from game.coalition import Coalition + + +class JoinZoneGeometry: + """Defines the zones used for finding optimal join point placement. + + The zones themselves are stored in the class rather than just the resulting join + point so that the zones can be drawn in the map for debugging purposes. + """ + + def __init__( + self, + target: Point, + home: Point, + ip: Point, + coalition: Coalition, + theater: ConflictTheater, + ) -> None: + # Normal join placement is based on the path from home to the IP. If no path is + # found it means that the target is on a direct path. In that case we instead + # want to enforce that the join point is: + # + # * Not closer to the target than the IP. + # * Not too close to the home airfield. + # * Not threatened. + # * A minimum distance from the IP. + # * Not too sharp a turn at the ingress point. + self.ip = ShapelyPoint(ip.x, ip.y) + self.threat_zone = coalition.opponent.threat_zone.all + self.home = ShapelyPoint(home.x, home.y) + + self.ip_bubble = self.ip.buffer(coalition.doctrine.join_distance.meters) + + ip_distance = ip.distance_to_point(target) + self.target_bubble = ShapelyPoint(target.x, target.y).buffer(ip_distance) + + # The minimum distance between the home location and the IP. + min_distance_from_home = nautical_miles(5) + + self.home_bubble = self.home.buffer(min_distance_from_home.meters) + + self.excluded_zone = shapely.ops.unary_union( + [self.home_bubble, self.ip_bubble, self.target_bubble, self.threat_zone] + ) + + ip_heading = target.heading_between_point(ip) + + # Arbitrarily large since this is later constrained by the map boundary, and + # we'll be picking a location close to the IP anyway. Just used to avoid real + # distance calculations to project to the map edge. + large_distance = nautical_miles(400).meters + turn_limit = 40 + ip_limit_ccw = ip.point_from_heading(ip_heading - turn_limit, large_distance) + ip_limit_cw = ip.point_from_heading(ip_heading + turn_limit, large_distance) + + ip_direction_limit_wedge = Polygon( + [ + (ip.x, ip.y), + (ip_limit_ccw.x, ip_limit_ccw.y), + (ip_limit_cw.x, ip_limit_cw.y), + ] + ) + + self.permissible_line = ( + coalition.nav_mesh.map_bounds(theater) + .intersection(ip_direction_limit_wedge) + .intersection(self.excluded_zone.boundary) + ) + + def find_best_join_point(self) -> Point: + join, _ = shapely.ops.nearest_points(self.permissible_line, self.home) + return Point(join.x, join.y) diff --git a/gen/flights/flightplan.py b/gen/flights/flightplan.py index 13295d01..5c1c0820 100644 --- a/gen/flights/flightplan.py +++ b/gen/flights/flightplan.py @@ -20,7 +20,7 @@ from dcs.unit import Unit from shapely.geometry import Point as ShapelyPoint from game.data.doctrine import Doctrine -from game.flightplan import IpZoneGeometry +from game.flightplan import IpZoneGeometry, JoinZoneGeometry from game.theater import ( Airfield, ControlPoint, @@ -949,20 +949,22 @@ class FlightPlanBuilder: def regenerate_package_waypoints(self) -> None: from gen.ato import PackageWaypoints + package_airfield = self.package_airfield() + # Start by picking the best IP for the attack. ingress_point = IpZoneGeometry( self.package.target.position, - self.package_airfield().position, + package_airfield.position, self.coalition, ).find_best_ip() - # Pick the join point based on the best route to the IP. - join_point = self.preferred_join_point(ingress_point) - if join_point is None: - # The entire path to the target is threatened. Use the fallback behavior for - # now. - self.legacy_package_waypoints_impl(ingress_point) - return + join_point = JoinZoneGeometry( + self.package.target.position, + package_airfield.position, + ingress_point, + self.coalition, + self.theater, + ).find_best_join_point() # And the split point based on the best route from the IP. Since that's no # different than the best route *to* the IP, this is the same as the join point. diff --git a/qt_ui/widgets/map/mapmodel.py b/qt_ui/widgets/map/mapmodel.py index c2e6706a..09bc6022 100644 --- a/qt_ui/widgets/map/mapmodel.py +++ b/qt_ui/widgets/map/mapmodel.py @@ -12,6 +12,7 @@ from shapely.geometry import LineString, Point as ShapelyPoint, Polygon, MultiPo from game import Game from game.dcs.groundunittype import GroundUnitType +from game.flightplan import JoinZoneGeometry from game.navmesh import NavMesh, NavMeshPoly from game.profiling import logged_duration from game.theater import ( @@ -82,6 +83,12 @@ def shapely_to_leaflet_polys( return [shapely_poly_to_leaflet_points(poly, theater) for poly in polys] +def shapely_line_to_leaflet_points( + line: LineString, theater: ConflictTheater +) -> list[LeafletLatLon]: + return [theater.point_to_ll(Point(x, y)).as_list() for x, y in line.coords] + + class ControlPointJs(QObject): nameChanged = Signal() blueChanged = Signal() @@ -822,16 +829,20 @@ class IpZonesJs(QObject): def permissibleZone(self) -> list[LeafletPoly]: return self._permissible_zone - @Property(list, notify=permissibleZoneChanged) + @Property(list, notify=safeZoneChanged) def safeZone(self) -> list[LeafletPoly]: return self._safe_zone + @classmethod + def empty(cls) -> IpZonesJs: + return IpZonesJs([], [], [], []) + @classmethod def for_flight(cls, flight: Flight, game: Game) -> IpZonesJs: + if not ENABLE_EXPENSIVE_DEBUG_TOOLS: + return IpZonesJs.empty() target = flight.package.target home = flight.departure - if not ENABLE_EXPENSIVE_DEBUG_TOOLS or target == home: - return IpZonesJs([], [], [], []) geometry = IpZoneGeometry(target.position, home.position, game.blue) return IpZonesJs( shapely_to_leaflet_polys(geometry.home_bubble, game.theater), @@ -841,6 +852,73 @@ class IpZonesJs(QObject): ) +class JoinZonesJs(QObject): + homeBubbleChanged = Signal() + targetBubbleChanged = Signal() + ipBubbleChanged = Signal() + excludedZoneChanged = Signal() + permissibleLineChanged = Signal() + + def __init__( + self, + home_bubble: list[LeafletPoly], + target_bubble: list[LeafletPoly], + ip_bubble: list[LeafletPoly], + excluded_zone: list[LeafletPoly], + permissible_line: list[LeafletLatLon], + ) -> None: + super().__init__() + self._home_bubble = home_bubble + self._target_bubble = target_bubble + self._ip_bubble = ip_bubble + self._excluded_zone = excluded_zone + self._permissible_line = permissible_line + + @Property(list, notify=homeBubbleChanged) + def homeBubble(self) -> list[LeafletPoly]: + return self._home_bubble + + @Property(list, notify=targetBubbleChanged) + def targetBubble(self) -> list[LeafletPoly]: + return self._target_bubble + + @Property(list, notify=ipBubbleChanged) + def ipBubble(self) -> list[LeafletPoly]: + return self._ip_bubble + + @Property(list, notify=excludedZoneChanged) + def excludedZone(self) -> list[LeafletPoly]: + return self._excluded_zone + + @Property(list, notify=permissibleLineChanged) + def permissibleLine(self) -> list[LeafletLatLon]: + return self._permissible_line + + @classmethod + def empty(cls) -> JoinZonesJs: + return JoinZonesJs([], [], [], [], []) + + @classmethod + def for_flight(cls, flight: Flight, game: Game) -> JoinZonesJs: + if not ENABLE_EXPENSIVE_DEBUG_TOOLS: + return JoinZonesJs.empty() + target = flight.package.target + home = flight.departure + if flight.package.waypoints is None: + return JoinZonesJs.empty() + ip = flight.package.waypoints.ingress + geometry = JoinZoneGeometry( + target.position, home.position, ip, game.blue, game.theater + ) + return JoinZonesJs( + shapely_to_leaflet_polys(geometry.home_bubble, game.theater), + shapely_to_leaflet_polys(geometry.target_bubble, game.theater), + shapely_to_leaflet_polys(geometry.ip_bubble, game.theater), + shapely_to_leaflet_polys(geometry.excluded_zone, game.theater), + shapely_line_to_leaflet_points(geometry.permissible_line, game.theater), + ) + + class MapModel(QObject): cleared = Signal() @@ -855,6 +933,7 @@ class MapModel(QObject): mapZonesChanged = Signal() unculledZonesChanged = Signal() ipZonesChanged = Signal() + joinZonesChanged = Signal() def __init__(self, game_model: GameModel) -> None: super().__init__() @@ -871,7 +950,8 @@ class MapModel(QObject): self._navmeshes = NavMeshJs([], []) self._map_zones = MapZonesJs([], [], []) self._unculled_zones = [] - self._ip_zones = IpZonesJs([], [], [], []) + self._ip_zones = IpZonesJs.empty() + self._join_zones = JoinZonesJs.empty() self._selected_flight_index: Optional[Tuple[int, int]] = None GameUpdateSignal.get_instance().game_loaded.connect(self.on_game_load) GameUpdateSignal.get_instance().flight_paths_changed.connect(self.reset_atos) @@ -895,7 +975,7 @@ class MapModel(QObject): self._navmeshes = NavMeshJs([], []) self._map_zones = MapZonesJs([], [], []) self._unculled_zones = [] - self._ip_zones = IpZonesJs([], [], [], []) + self._ip_zones = IpZonesJs.empty() self.cleared.emit() def set_package_selection(self, index: int) -> None: @@ -984,11 +1064,14 @@ class MapModel(QObject): ) + self._flights_in_ato(self.game.red.ato, blue=False) self.flightsChanged.emit() selected_flight = self._get_selected_flight() - if selected_flight is not None: - self._ip_zones = IpZonesJs.for_flight(selected_flight, self.game) + if selected_flight is None: + self._ip_zones = IpZonesJs.empty() + self._join_zones = JoinZonesJs.empty() else: - self._ip_zones = IpZonesJs([], [], [], []) + self._ip_zones = IpZonesJs.for_flight(selected_flight, self.game) + self._join_zones = JoinZonesJs.for_flight(selected_flight, self.game) self.ipZonesChanged.emit() + self.joinZonesChanged.emit() @Property(list, notify=flightsChanged) def flights(self) -> List[FlightJs]: @@ -1121,6 +1204,10 @@ class MapModel(QObject): def ipZones(self) -> IpZonesJs: return self._ip_zones + @Property(JoinZonesJs, notify=joinZonesChanged) + def joinZones(self) -> JoinZonesJs: + return self._join_zones + @property def game(self) -> Game: if self.game_model.game is None: diff --git a/resources/ui/map/map.js b/resources/ui/map/map.js index e56a9fc0..87e4ca7e 100644 --- a/resources/ui/map/map.js +++ b/resources/ui/map/map.js @@ -198,10 +198,8 @@ const exclusionZones = L.layerGroup(); const seaZones = L.layerGroup(); const unculledZones = L.layerGroup(); -const homeBubble = L.layerGroup(); -const ipBubble = L.layerGroup(); -const permissibleZone = L.layerGroup(); -const safeZone = L.layerGroup(); +const ipZones = L.layerGroup(); +const joinZones = L.layerGroup().addTo(map); const debugControlGroups = { "Blue Threat Zones": { @@ -232,11 +230,9 @@ const debugControlGroups = { }; if (ENABLE_EXPENSIVE_DEBUG_TOOLS) { - debugControlGroups["IP Zones"] = { - "Home bubble": homeBubble, - "IP bubble": ipBubble, - "Permissible zone": permissibleZone, - "Safe zone": safeZone, + debugControlGroups["Waypoint Zones"] = { + "IP Zones": ipZones, + "Join Zones": joinZones, }; } @@ -287,7 +283,12 @@ L.control L.control .groupedLayers(null, debugControlGroups, { position: "topleft", - exclusiveGroups: ["Blue Threat Zones", "Red Threat Zones", "Navmeshes"], + exclusiveGroups: [ + "Blue Threat Zones", + "Red Threat Zones", + "Navmeshes", + "Waypoint Zones", + ], groupCheckboxes: true, }) .addTo(map); @@ -308,6 +309,7 @@ new QWebChannel(qt.webChannelTransport, function (channel) { game.mapZonesChanged.connect(drawMapZones); game.unculledZonesChanged.connect(drawUnculledZones); game.ipZonesChanged.connect(drawIpZones); + game.joinZonesChanged.connect(drawJoinZones); }); function recenterMap(center) { @@ -992,34 +994,65 @@ function drawUnculledZones() { } function drawIpZones() { - homeBubble.clearLayers(); - ipBubble.clearLayers(); - permissibleZone.clearLayers(); - safeZone.clearLayers(); + ipZones.clearLayers(); L.polygon(game.ipZones.homeBubble, { color: Colors.Highlight, fillOpacity: 0.1, interactive: false, - }).addTo(homeBubble); + }).addTo(ipZones); L.polygon(game.ipZones.ipBubble, { color: "#bb89ff", fillOpacity: 0.1, interactive: false, - }).addTo(ipBubble); + }).addTo(ipZones); L.polygon(game.ipZones.permissibleZone, { color: "#ffffff", fillOpacity: 0.1, interactive: false, - }).addTo(permissibleZone); + }).addTo(ipZones); L.polygon(game.ipZones.safeZone, { color: Colors.Green, fillOpacity: 0.1, interactive: false, - }).addTo(safeZone); + }).addTo(ipZones); +} + +function drawJoinZones() { + joinZones.clearLayers(); + + L.polygon(game.joinZones.homeBubble, { + color: Colors.Highlight, + fillOpacity: 0.1, + interactive: false, + }).addTo(joinZones); + + L.polygon(game.joinZones.targetBubble, { + color: "#bb89ff", + fillOpacity: 0.1, + interactive: false, + }).addTo(joinZones); + + L.polygon(game.joinZones.ipBubble, { + color: "#ffffff", + fillOpacity: 0.1, + interactive: false, + }).addTo(joinZones); + + L.polygon(game.joinZones.excludedZone, { + color: "#ffa500", + fillOpacity: 0.2, + stroke: false, + interactive: false, + }).addTo(joinZones); + + L.polyline(game.joinZones.permissibleLine, { + color: Colors.Green, + interactive: false, + }).addTo(joinZones); } function drawInitialMap() { @@ -1034,6 +1067,7 @@ function drawInitialMap() { drawMapZones(); drawUnculledZones(); drawIpZones(); + drawJoinZones(); } function clearAllLayers() {