[2/3] Improve join point placement.

This commit is contained in:
Dan Albert 2021-07-15 18:49:07 -07:00
parent e03d710d53
commit d444d716f5
5 changed files with 243 additions and 35 deletions

View File

@ -1 +1,2 @@
from .ipzonegeometry import IpZoneGeometry from .ipzonegeometry import IpZoneGeometry
from .joinzonegeometry import JoinZoneGeometry

View File

@ -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)

View File

@ -20,7 +20,7 @@ from dcs.unit import Unit
from shapely.geometry import Point as ShapelyPoint from shapely.geometry import Point as ShapelyPoint
from game.data.doctrine import Doctrine from game.data.doctrine import Doctrine
from game.flightplan import IpZoneGeometry from game.flightplan import IpZoneGeometry, JoinZoneGeometry
from game.theater import ( from game.theater import (
Airfield, Airfield,
ControlPoint, ControlPoint,
@ -949,20 +949,22 @@ class FlightPlanBuilder:
def regenerate_package_waypoints(self) -> None: def regenerate_package_waypoints(self) -> None:
from gen.ato import PackageWaypoints from gen.ato import PackageWaypoints
package_airfield = self.package_airfield()
# Start by picking the best IP for the attack. # Start by picking the best IP for the attack.
ingress_point = IpZoneGeometry( ingress_point = IpZoneGeometry(
self.package.target.position, self.package.target.position,
self.package_airfield().position, package_airfield.position,
self.coalition, self.coalition,
).find_best_ip() ).find_best_ip()
# Pick the join point based on the best route to the IP. join_point = JoinZoneGeometry(
join_point = self.preferred_join_point(ingress_point) self.package.target.position,
if join_point is None: package_airfield.position,
# The entire path to the target is threatened. Use the fallback behavior for ingress_point,
# now. self.coalition,
self.legacy_package_waypoints_impl(ingress_point) self.theater,
return ).find_best_join_point()
# And the split point based on the best route from the IP. Since that's no # 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. # different than the best route *to* the IP, this is the same as the join point.

View File

@ -12,6 +12,7 @@ from shapely.geometry import LineString, Point as ShapelyPoint, Polygon, MultiPo
from game import Game from game import Game
from game.dcs.groundunittype import GroundUnitType from game.dcs.groundunittype import GroundUnitType
from game.flightplan import JoinZoneGeometry
from game.navmesh import NavMesh, NavMeshPoly from game.navmesh import NavMesh, NavMeshPoly
from game.profiling import logged_duration from game.profiling import logged_duration
from game.theater import ( 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] 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): class ControlPointJs(QObject):
nameChanged = Signal() nameChanged = Signal()
blueChanged = Signal() blueChanged = Signal()
@ -822,16 +829,20 @@ class IpZonesJs(QObject):
def permissibleZone(self) -> list[LeafletPoly]: def permissibleZone(self) -> list[LeafletPoly]:
return self._permissible_zone return self._permissible_zone
@Property(list, notify=permissibleZoneChanged) @Property(list, notify=safeZoneChanged)
def safeZone(self) -> list[LeafletPoly]: def safeZone(self) -> list[LeafletPoly]:
return self._safe_zone return self._safe_zone
@classmethod
def empty(cls) -> IpZonesJs:
return IpZonesJs([], [], [], [])
@classmethod @classmethod
def for_flight(cls, flight: Flight, game: Game) -> IpZonesJs: def for_flight(cls, flight: Flight, game: Game) -> IpZonesJs:
if not ENABLE_EXPENSIVE_DEBUG_TOOLS:
return IpZonesJs.empty()
target = flight.package.target target = flight.package.target
home = flight.departure home = flight.departure
if not ENABLE_EXPENSIVE_DEBUG_TOOLS or target == home:
return IpZonesJs([], [], [], [])
geometry = IpZoneGeometry(target.position, home.position, game.blue) geometry = IpZoneGeometry(target.position, home.position, game.blue)
return IpZonesJs( return IpZonesJs(
shapely_to_leaflet_polys(geometry.home_bubble, game.theater), 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): class MapModel(QObject):
cleared = Signal() cleared = Signal()
@ -855,6 +933,7 @@ class MapModel(QObject):
mapZonesChanged = Signal() mapZonesChanged = Signal()
unculledZonesChanged = Signal() unculledZonesChanged = Signal()
ipZonesChanged = Signal() ipZonesChanged = Signal()
joinZonesChanged = Signal()
def __init__(self, game_model: GameModel) -> None: def __init__(self, game_model: GameModel) -> None:
super().__init__() super().__init__()
@ -871,7 +950,8 @@ class MapModel(QObject):
self._navmeshes = NavMeshJs([], []) self._navmeshes = NavMeshJs([], [])
self._map_zones = MapZonesJs([], [], []) self._map_zones = MapZonesJs([], [], [])
self._unculled_zones = [] 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 self._selected_flight_index: Optional[Tuple[int, int]] = None
GameUpdateSignal.get_instance().game_loaded.connect(self.on_game_load) GameUpdateSignal.get_instance().game_loaded.connect(self.on_game_load)
GameUpdateSignal.get_instance().flight_paths_changed.connect(self.reset_atos) GameUpdateSignal.get_instance().flight_paths_changed.connect(self.reset_atos)
@ -895,7 +975,7 @@ class MapModel(QObject):
self._navmeshes = NavMeshJs([], []) self._navmeshes = NavMeshJs([], [])
self._map_zones = MapZonesJs([], [], []) self._map_zones = MapZonesJs([], [], [])
self._unculled_zones = [] self._unculled_zones = []
self._ip_zones = IpZonesJs([], [], [], []) self._ip_zones = IpZonesJs.empty()
self.cleared.emit() self.cleared.emit()
def set_package_selection(self, index: int) -> None: 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._flights_in_ato(self.game.red.ato, blue=False)
self.flightsChanged.emit() self.flightsChanged.emit()
selected_flight = self._get_selected_flight() selected_flight = self._get_selected_flight()
if selected_flight is not None: if selected_flight is None:
self._ip_zones = IpZonesJs.for_flight(selected_flight, self.game) self._ip_zones = IpZonesJs.empty()
self._join_zones = JoinZonesJs.empty()
else: 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.ipZonesChanged.emit()
self.joinZonesChanged.emit()
@Property(list, notify=flightsChanged) @Property(list, notify=flightsChanged)
def flights(self) -> List[FlightJs]: def flights(self) -> List[FlightJs]:
@ -1121,6 +1204,10 @@ class MapModel(QObject):
def ipZones(self) -> IpZonesJs: def ipZones(self) -> IpZonesJs:
return self._ip_zones return self._ip_zones
@Property(JoinZonesJs, notify=joinZonesChanged)
def joinZones(self) -> JoinZonesJs:
return self._join_zones
@property @property
def game(self) -> Game: def game(self) -> Game:
if self.game_model.game is None: if self.game_model.game is None:

View File

@ -198,10 +198,8 @@ const exclusionZones = L.layerGroup();
const seaZones = L.layerGroup(); const seaZones = L.layerGroup();
const unculledZones = L.layerGroup(); const unculledZones = L.layerGroup();
const homeBubble = L.layerGroup(); const ipZones = L.layerGroup();
const ipBubble = L.layerGroup(); const joinZones = L.layerGroup().addTo(map);
const permissibleZone = L.layerGroup();
const safeZone = L.layerGroup();
const debugControlGroups = { const debugControlGroups = {
"Blue Threat Zones": { "Blue Threat Zones": {
@ -232,11 +230,9 @@ const debugControlGroups = {
}; };
if (ENABLE_EXPENSIVE_DEBUG_TOOLS) { if (ENABLE_EXPENSIVE_DEBUG_TOOLS) {
debugControlGroups["IP Zones"] = { debugControlGroups["Waypoint Zones"] = {
"Home bubble": homeBubble, "IP Zones": ipZones,
"IP bubble": ipBubble, "Join Zones": joinZones,
"Permissible zone": permissibleZone,
"Safe zone": safeZone,
}; };
} }
@ -287,7 +283,12 @@ L.control
L.control L.control
.groupedLayers(null, debugControlGroups, { .groupedLayers(null, debugControlGroups, {
position: "topleft", position: "topleft",
exclusiveGroups: ["Blue Threat Zones", "Red Threat Zones", "Navmeshes"], exclusiveGroups: [
"Blue Threat Zones",
"Red Threat Zones",
"Navmeshes",
"Waypoint Zones",
],
groupCheckboxes: true, groupCheckboxes: true,
}) })
.addTo(map); .addTo(map);
@ -308,6 +309,7 @@ new QWebChannel(qt.webChannelTransport, function (channel) {
game.mapZonesChanged.connect(drawMapZones); game.mapZonesChanged.connect(drawMapZones);
game.unculledZonesChanged.connect(drawUnculledZones); game.unculledZonesChanged.connect(drawUnculledZones);
game.ipZonesChanged.connect(drawIpZones); game.ipZonesChanged.connect(drawIpZones);
game.joinZonesChanged.connect(drawJoinZones);
}); });
function recenterMap(center) { function recenterMap(center) {
@ -992,34 +994,65 @@ function drawUnculledZones() {
} }
function drawIpZones() { function drawIpZones() {
homeBubble.clearLayers(); ipZones.clearLayers();
ipBubble.clearLayers();
permissibleZone.clearLayers();
safeZone.clearLayers();
L.polygon(game.ipZones.homeBubble, { L.polygon(game.ipZones.homeBubble, {
color: Colors.Highlight, color: Colors.Highlight,
fillOpacity: 0.1, fillOpacity: 0.1,
interactive: false, interactive: false,
}).addTo(homeBubble); }).addTo(ipZones);
L.polygon(game.ipZones.ipBubble, { L.polygon(game.ipZones.ipBubble, {
color: "#bb89ff", color: "#bb89ff",
fillOpacity: 0.1, fillOpacity: 0.1,
interactive: false, interactive: false,
}).addTo(ipBubble); }).addTo(ipZones);
L.polygon(game.ipZones.permissibleZone, { L.polygon(game.ipZones.permissibleZone, {
color: "#ffffff", color: "#ffffff",
fillOpacity: 0.1, fillOpacity: 0.1,
interactive: false, interactive: false,
}).addTo(permissibleZone); }).addTo(ipZones);
L.polygon(game.ipZones.safeZone, { L.polygon(game.ipZones.safeZone, {
color: Colors.Green, color: Colors.Green,
fillOpacity: 0.1, fillOpacity: 0.1,
interactive: false, 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() { function drawInitialMap() {
@ -1034,6 +1067,7 @@ function drawInitialMap() {
drawMapZones(); drawMapZones();
drawUnculledZones(); drawUnculledZones();
drawIpZones(); drawIpZones();
drawJoinZones();
} }
function clearAllLayers() { function clearAllLayers() {