# target_simulator/gui/waypoint_editor_window.py """ A modal dialog window for creating or editing a single Waypoint. """ import tkinter as tk from tkinter import ttk, messagebox from typing import Optional, Tuple import math from target_simulator.core.models import ( Waypoint, ManeuverType, TurnDirection, KNOTS_TO_FPS, FPS_TO_KNOTS, G_IN_FPS2, NM_TO_FT, ) class WaypointEditorWindow(tk.Toplevel): """A dialog for entering and editing data for a single waypoint.""" def __init__( self, master, is_first_waypoint: bool = False, waypoint_to_edit: Optional[Waypoint] = None, previous_waypoint_end_pos_ft: Tuple[float, float, float] = (0.0, 0.0, 0.0), ): super().__init__(master) self.transient(master) self.grab_set() self.title("Edit Waypoint" if waypoint_to_edit else "Add Waypoint") if is_first_waypoint: self.title("Set Initial Position") self.resizable(False, False) self.is_first_waypoint = is_first_waypoint self.previous_waypoint_end_pos_ft = previous_waypoint_end_pos_ft self.result_waypoint: Optional[Waypoint] = None self._is_updating_turn_def = False # Flag to prevent update loops self._setup_variables() # Add traces to variables for automatic updates self.ftp_duration_var.trace_add("write", self._update_resultant_speed) self.ftp_range_var.trace_add("write", self._update_resultant_speed) self.ftp_az_var.trace_add("write", self._update_resultant_speed) self.ftp_alt_var.trace_add("write", self._update_resultant_speed) self._create_widgets() if waypoint_to_edit: self._load_waypoint_data(waypoint_to_edit) self._on_maneuver_type_change() self._center_window() # Trigger initial calculation for resultant speed self.after(50, self._update_resultant_speed) self.protocol("WM_DELETE_WINDOW", self._on_cancel) self.wait_window(self) def _setup_variables(self): """Initializes all tk Variables.""" self.maneuver_type_var = tk.StringVar() self.ftp_duration_var = tk.DoubleVar(value=10.0) self.ftp_range_var = tk.DoubleVar(value=20.0) self.ftp_az_var = tk.DoubleVar(value=0.0) self.ftp_alt_var = tk.DoubleVar(value=10000.0) self.ftp_vel_var = tk.DoubleVar(value=300.0) self.ftp_hdg_var = tk.DoubleVar(value=180.0) self.resultant_speed_kn_var = tk.StringVar(value="Calculating...") self.ffd_duration_var = tk.DoubleVar(value=10.0) self.ffd_vel_var = tk.DoubleVar(value=300.0) self.ffd_hdg_var = tk.DoubleVar(value=90.0) self.ffd_alt_var = tk.DoubleVar(value=10000.0) self.dm_duration_var = tk.DoubleVar(value=10.0) self.dm_speed_kn_var = tk.DoubleVar(value=300.0) self.dm_lon_accel_g_var = tk.DoubleVar(value=0.0) self.dm_ver_accel_g_var = tk.DoubleVar(value=0.0) self.dm_turn_def_var = tk.StringVar(value="g_force") self.dm_lat_accel_g_var = tk.DoubleVar(value=1.0) self.dm_turn_rate_var = tk.DoubleVar(value=3.0) self.dm_turn_dir_var = tk.StringVar(value=TurnDirection.RIGHT.value) def _load_waypoint_data(self, wp: Waypoint): """Populates the fields with data from an existing waypoint.""" self.maneuver_type_var.set(wp.maneuver_type.value) if wp.maneuver_type == ManeuverType.FLY_TO_POINT: self.ftp_duration_var.set(wp.duration_s or 10.0) self.ftp_range_var.set(wp.target_range_nm or 0.0) self.ftp_az_var.set(wp.target_azimuth_deg or 0.0) self.ftp_alt_var.set(wp.target_altitude_ft or 10000.0) self.ftp_vel_var.set((wp.target_velocity_fps or 0.0) * FPS_TO_KNOTS) self.ftp_hdg_var.set(wp.target_heading_deg or 0.0) elif wp.maneuver_type == ManeuverType.FLY_FOR_DURATION: self.ffd_duration_var.set(wp.duration_s or 10.0) self.ffd_vel_var.set((wp.target_velocity_fps or 0.0) * FPS_TO_KNOTS) self.ffd_hdg_var.set(wp.target_heading_deg or 0.0) self.ffd_alt_var.set(wp.target_altitude_ft or 10000.0) elif wp.maneuver_type == ManeuverType.DYNAMIC_MANEUVER: self.dm_duration_var.set(wp.duration_s or 10.0) self.dm_speed_kn_var.set((wp.maneuver_speed_fps or 300.0) * FPS_TO_KNOTS) self.dm_lon_accel_g_var.set(wp.longitudinal_acceleration_g or 0.0) self.dm_lat_accel_g_var.set(wp.lateral_acceleration_g or 0.0) self.dm_ver_accel_g_var.set(wp.vertical_acceleration_g or 0.0) if wp.turn_direction: self.dm_turn_dir_var.set(wp.turn_direction.value) # Sync the turn rate spinbox from the loaded G value self._update_turn_rate_from_g() def _center_window(self): self.update_idletasks() parent = self.master x = parent.winfo_x() + (parent.winfo_width() // 2) - (self.winfo_width() // 2) y = parent.winfo_y() + (parent.winfo_height() // 2) - (self.winfo_height() // 2) self.geometry(f"+{x}+{y}") def _create_widgets(self): main_frame = ttk.Frame(self, padding=10) main_frame.pack(fill=tk.BOTH, expand=True) type_frame = ttk.Frame(main_frame) type_frame.pack(fill=tk.X, pady=(0, 10)) ttk.Label(type_frame, text="Maneuver Type:").pack(side=tk.LEFT) self.maneuver_combo = ttk.Combobox( type_frame, textvariable=self.maneuver_type_var, values=[m.value for m in ManeuverType], state="readonly", ) self.maneuver_combo.pack(side=tk.LEFT, expand=True, fill=tk.X, padx=5) self.maneuver_combo.bind("<>", self._on_maneuver_type_change) self.fly_to_point_frame = self._create_fly_to_point_frame(main_frame) self.fly_for_duration_frame = self._create_fly_for_duration_frame(main_frame) self.dynamic_maneuver_frame = self._create_dynamic_maneuver_frame(main_frame) button_frame = ttk.Frame(main_frame) button_frame.pack(fill=tk.X, pady=(15, 0), side=tk.BOTTOM) ttk.Button(button_frame, text="OK", command=self._on_ok).pack( side=tk.RIGHT, padx=5 ) ttk.Button(button_frame, text="Cancel", command=self._on_cancel).pack( side=tk.RIGHT ) if self.is_first_waypoint: self.maneuver_type_var.set(ManeuverType.FLY_TO_POINT.value) self.maneuver_combo.config(state=tk.DISABLED) else: self.maneuver_type_var.set(ManeuverType.FLY_FOR_DURATION.value) def _create_fly_to_point_frame(self, parent) -> ttk.Frame: frame = ttk.LabelFrame(parent, text="Position and State", padding=10) frame.columnconfigure(1, weight=1) # Duration ttk.Label(frame, text="Duration to Point (s):").grid( row=0, column=0, sticky=tk.W, pady=2 ) ttk.Spinbox( frame, from_=0.1, to=3600 * 24, textvariable=self.ftp_duration_var, width=10 ).grid(row=0, column=1, sticky=tk.EW, pady=2) # Range ttk.Label(frame, text="Target Range (NM):").grid( row=1, column=0, sticky=tk.W, pady=2 ) ttk.Spinbox( frame, from_=0, to=1000, textvariable=self.ftp_range_var, width=10 ).grid(row=1, column=1, sticky=tk.EW, pady=2) # Azimuth ttk.Label(frame, text="Target Azimuth (°):").grid( row=2, column=0, sticky=tk.W, pady=2 ) ttk.Spinbox( frame, from_=-180, to=180, textvariable=self.ftp_az_var, width=10 ).grid(row=2, column=1, sticky=tk.EW, pady=2) # Altitude ttk.Label(frame, text="Target Altitude (ft):").grid( row=3, column=0, sticky=tk.W, pady=2 ) ttk.Spinbox( frame, from_=-1000, to=80000, textvariable=self.ftp_alt_var, width=10 ).grid(row=3, column=1, sticky=tk.EW, pady=2) # Resultant Speed (read-only) ttk.Label(frame, text="Resultant Speed (kn):").grid( row=4, column=0, sticky=tk.W, pady=5 ) ttk.Entry( frame, textvariable=self.resultant_speed_kn_var, state="readonly" ).grid(row=4, column=1, sticky=tk.EW, pady=5) if self.is_first_waypoint: # Separator ttk.Separator(frame, orient=tk.HORIZONTAL).grid( row=5, column=0, columnspan=2, sticky="ew", pady=8 ) ttk.Label(frame, text="Initial Velocity (kn):").grid( row=6, column=0, sticky=tk.W, pady=2 ) ttk.Spinbox( frame, from_=0, to=2000, textvariable=self.ftp_vel_var, width=10 ).grid(row=6, column=1, sticky=tk.EW, pady=2) ttk.Label(frame, text="Initial Heading (°):").grid( row=7, column=0, sticky=tk.W, pady=2 ) ttk.Spinbox( frame, from_=0, to=359.9, format="%.1f", increment=0.1, textvariable=self.ftp_hdg_var, width=10, ).grid(row=7, column=1, sticky=tk.EW, pady=2) return frame def _update_resultant_speed(self, *args): """Calculates and displays the speed required to reach the destination in time.""" if self.is_first_waypoint: self.resultant_speed_kn_var.set("N/A (Initial Position)") return try: duration_s = self.ftp_duration_var.get() if duration_s <= 0: self.resultant_speed_kn_var.set("Infinite (duration <= 0)") return range_nm = self.ftp_range_var.get() az_deg = self.ftp_az_var.get() alt_ft = self.ftp_alt_var.get() # Convert destination polar coordinates (relative to origin) to Cartesian az_rad = math.radians(az_deg) dest_x_ft = range_nm * NM_TO_FT * math.sin(az_rad) dest_y_ft = range_nm * NM_TO_FT * math.cos(az_rad) dest_z_ft = alt_ft # Get start position from the end of the previous waypoint start_x, start_y, start_z = self.previous_waypoint_end_pos_ft # Calculate 3D distance to travel dx = dest_x_ft - start_x dy = dest_y_ft - start_y dz = dest_z_ft - start_z distance_ft = math.sqrt(dx**2 + dy**2 + dz**2) # Calculate speed speed_fps = distance_ft / duration_s speed_knots = speed_fps * FPS_TO_KNOTS self.resultant_speed_kn_var.set(f"{speed_knots:.2f}") except (ValueError, tk.TclError): # This can happen if the entry is temporarily empty during typing self.resultant_speed_kn_var.set("Calculating...") def _create_fly_for_duration_frame(self, parent) -> ttk.Frame: frame = ttk.LabelFrame(parent, text="Constant State Flight", padding=10) frame.columnconfigure(1, weight=1) ttk.Label(frame, text="Duration (s):").grid( row=0, column=0, sticky=tk.W, pady=2 ) ttk.Spinbox( frame, from_=0.1, to=3600 * 24, textvariable=self.ffd_duration_var, width=10 ).grid(row=0, column=1, sticky=tk.EW, pady=2) ttk.Label(frame, text="Constant Velocity (kn):").grid( row=1, column=0, sticky=tk.W, pady=2 ) ttk.Spinbox( frame, from_=0, to=2000, textvariable=self.ffd_vel_var, width=10 ).grid(row=1, column=1, sticky=tk.EW, pady=2) ttk.Label(frame, text="Constant Heading (°):").grid( row=2, column=0, sticky=tk.W, pady=2 ) ttk.Spinbox( frame, from_=0, to=359.9, format="%.1f", increment=0.1, textvariable=self.ffd_hdg_var, width=10, ).grid(row=2, column=1, sticky=tk.EW, pady=2) ttk.Label(frame, text="Constant Altitude (ft):").grid( row=3, column=0, sticky=tk.W, pady=2 ) ttk.Spinbox( frame, from_=-1000, to=80000, textvariable=self.ffd_alt_var, width=10 ).grid(row=3, column=1, sticky=tk.EW, pady=2) return frame def _create_dynamic_maneuver_frame(self, parent) -> ttk.Frame: frame = ttk.LabelFrame(parent, text="Dynamic Maneuver Controls", padding=10) frame.columnconfigure(1, weight=1) ttk.Label(frame, text="Duration (s):").grid( row=0, column=0, sticky=tk.W, pady=2 ) ttk.Spinbox( frame, from_=0.1, to=3600 * 24, textvariable=self.dm_duration_var, width=10 ).grid(row=0, column=1, sticky=tk.EW, pady=2) ttk.Label(frame, text="Maneuver Speed (kn):").grid( row=1, column=0, sticky=tk.W, pady=2 ) speed_spinbox = ttk.Spinbox( frame, from_=0, to=2000, textvariable=self.dm_speed_kn_var, width=10, command=self._update_g_from_turn_rate, ) speed_spinbox.grid(row=1, column=1, sticky=tk.EW, pady=2) turn_frame = ttk.LabelFrame(frame, text="Turn Definition", padding=5) turn_frame.grid(row=2, column=0, columnspan=2, sticky=tk.EW, pady=5) g_force_rb = ttk.Radiobutton( turn_frame, text="By G-Force", variable=self.dm_turn_def_var, value="g_force", command=self._on_turn_definition_change, ) g_force_rb.grid(row=0, column=0, sticky=tk.W) self.dm_lat_g_spinbox = ttk.Spinbox( turn_frame, from_=0, to=9, format="%.2f", increment=0.1, textvariable=self.dm_lat_accel_g_var, width=8, command=self._update_turn_rate_from_g, ) self.dm_lat_g_spinbox.grid(row=0, column=1, padx=5) turn_rate_rb = ttk.Radiobutton( turn_frame, text="By Turn Rate (°/s)", variable=self.dm_turn_def_var, value="turn_rate", command=self._on_turn_definition_change, ) turn_rate_rb.grid(row=1, column=0, sticky=tk.W) self.dm_turn_rate_spinbox = ttk.Spinbox( turn_frame, from_=0, to=30, format="%.2f", increment=0.1, textvariable=self.dm_turn_rate_var, width=8, command=self._update_g_from_turn_rate, ) self.dm_turn_rate_spinbox.grid(row=1, column=1, padx=5) ttk.Label(turn_frame, text="Direction:").grid( row=0, column=2, sticky=tk.W, padx=(10, 2) ) ttk.Combobox( turn_frame, textvariable=self.dm_turn_dir_var, values=[d.value for d in TurnDirection], state="readonly", width=7, ).grid(row=0, column=3, sticky=tk.W) self._on_turn_definition_change() ttk.Label(frame, text="Longitudinal Accel (g):").grid( row=3, column=0, sticky=tk.W, pady=2 ) ttk.Spinbox( frame, from_=-5, to=10, format="%.2f", increment=0.1, textvariable=self.dm_lon_accel_g_var, width=10, ).grid(row=3, column=1, sticky=tk.EW, pady=2) ttk.Label(frame, text="Vertical Accel (g):").grid( row=4, column=0, sticky=tk.W, pady=2 ) ttk.Spinbox( frame, from_=-5, to=5, format="%.2f", increment=0.1, textvariable=self.dm_ver_accel_g_var, width=10, ).grid(row=4, column=1, sticky=tk.EW, pady=2) return frame def _update_g_from_turn_rate(self): if self._is_updating_turn_def: return self._is_updating_turn_def = True try: rate_deg_s = self.dm_turn_rate_var.get() speed_kn = self.dm_speed_kn_var.get() if speed_kn > 0: accel_fps2 = math.radians(rate_deg_s) * (speed_kn * KNOTS_TO_FPS) g_load = accel_fps2 / G_IN_FPS2 self.dm_lat_accel_g_var.set(round(g_load, 2)) except (ValueError, tk.TclError): pass finally: self._is_updating_turn_def = False def _update_turn_rate_from_g(self): if self._is_updating_turn_def: return self._is_updating_turn_def = True try: g_load = self.dm_lat_accel_g_var.get() speed_kn = self.dm_speed_kn_var.get() speed_fps = speed_kn * KNOTS_TO_FPS if speed_fps > 0: accel_fps2 = g_load * G_IN_FPS2 rate_rad_s = accel_fps2 / speed_fps self.dm_turn_rate_var.set(round(math.degrees(rate_rad_s), 2)) except (ValueError, tk.TclError): pass finally: self._is_updating_turn_def = False def _on_turn_definition_change(self): if self.dm_turn_def_var.get() == "g_force": self.dm_lat_g_spinbox.config(state=tk.NORMAL) self.dm_turn_rate_spinbox.config(state=tk.DISABLED) self._update_turn_rate_from_g() else: self.dm_lat_g_spinbox.config(state=tk.DISABLED) self.dm_turn_rate_spinbox.config(state=tk.NORMAL) self._update_g_from_turn_rate() def _on_maneuver_type_change(self, event=None): try: m_type = ManeuverType(self.maneuver_type_var.get()) except ValueError: return self.fly_to_point_frame.pack_forget() self.fly_for_duration_frame.pack_forget() self.dynamic_maneuver_frame.pack_forget() if m_type == ManeuverType.FLY_TO_POINT: self.fly_to_point_frame.pack(fill=tk.BOTH, expand=True) elif m_type == ManeuverType.FLY_FOR_DURATION: self.fly_for_duration_frame.pack(fill=tk.BOTH, expand=True) elif m_type == ManeuverType.DYNAMIC_MANEUVER: self.dynamic_maneuver_frame.pack(fill=tk.BOTH, expand=True) def _on_ok(self): try: m_type = ManeuverType(self.maneuver_type_var.get()) wp = Waypoint(maneuver_type=m_type) if m_type == ManeuverType.FLY_TO_POINT: ( wp.duration_s, wp.target_range_nm, wp.target_azimuth_deg, wp.target_altitude_ft, ) = ( self.ftp_duration_var.get(), self.ftp_range_var.get(), self.ftp_az_var.get(), self.ftp_alt_var.get(), ) if self.is_first_waypoint: wp.target_velocity_fps, wp.target_heading_deg = ( self.ftp_vel_var.get() * KNOTS_TO_FPS, self.ftp_hdg_var.get(), ) elif m_type == ManeuverType.FLY_FOR_DURATION: ( wp.duration_s, wp.target_velocity_fps, wp.target_heading_deg, wp.target_altitude_ft, ) = ( self.ffd_duration_var.get(), self.ffd_vel_var.get() * KNOTS_TO_FPS, self.ffd_hdg_var.get(), self.ffd_alt_var.get(), ) elif m_type == ManeuverType.DYNAMIC_MANEUVER: ( wp.duration_s, wp.maneuver_speed_fps, wp.longitudinal_acceleration_g, wp.vertical_acceleration_g, wp.turn_direction, ) = ( self.dm_duration_var.get(), self.dm_speed_kn_var.get() * KNOTS_TO_FPS, self.dm_lon_accel_g_var.get(), self.dm_ver_accel_g_var.get(), TurnDirection(self.dm_turn_dir_var.get()), ) if self.dm_turn_def_var.get() == "g_force": wp.lateral_acceleration_g = self.dm_lat_accel_g_var.get() else: self._update_g_from_turn_rate() wp.lateral_acceleration_g = self.dm_lat_accel_g_var.get() self.result_waypoint = wp self.destroy() except (ValueError, tk.TclError) as e: messagebox.showerror( "Invalid Input", f"Please enter valid numbers.\nError: {e}", parent=self ) def _on_cancel(self): self.result_waypoint = None self.destroy()