diff --git a/target_simulator/gui/main_view.py b/target_simulator/gui/main_view.py index 0d41f1e..75ed5a0 100644 --- a/target_simulator/gui/main_view.py +++ b/target_simulator/gui/main_view.py @@ -8,23 +8,21 @@ from tkinter import ttk, scrolledtext, messagebox from queue import Queue, Empty from typing import Optional, Dict, Any, List -# Imports from GUI components -from .ppi_display import PPIDisplay -from .connection_settings_window import ConnectionSettingsWindow -from .radar_config_window import RadarConfigWindow -from .scenario_controls_frame import ScenarioControlsFrame -from .target_list_frame import TargetListFrame +# Use absolute imports for robustness and clarity +from target_simulator.gui.ppi_display import PPIDisplay +from target_simulator.gui.connection_settings_window import ConnectionSettingsWindow +from target_simulator.gui.radar_config_window import RadarConfigWindow +from target_simulator.gui.scenario_controls_frame import ScenarioControlsFrame +from target_simulator.gui.target_list_frame import TargetListFrame -# Imports from Core components -from core.communicator_interface import CommunicatorInterface -from core.serial_communicator import SerialCommunicator -from core.tftp_communicator import TFTPCommunicator -from core.simulation_engine import SimulationEngine -from core.models import Scenario, Target +from target_simulator.core.communicator_interface import CommunicatorInterface +from target_simulator.core.serial_communicator import SerialCommunicator +from target_simulator.core.tftp_communicator import TFTPCommunicator +from target_simulator.core.simulation_engine import SimulationEngine +from target_simulator.core.models import Scenario, Target -# Imports from Utils -from utils.logger import get_logger, shutdown_logging_system -from utils.config_manager import ConfigManager +from target_simulator.utils.logger import get_logger, shutdown_logging_system +from target_simulator.utils.config_manager import ConfigManager GUI_QUEUE_POLL_INTERVAL_MS = 100 @@ -85,9 +83,9 @@ class MainView(tk.Tk): self.ppi_widget = PPIDisplay(self.h_pane, max_range_nm=self.max_range, scan_limit_deg=self.scan_limit) self.h_pane.add(self.ppi_widget, weight=2) - controls_frame = self.ppi_widget.children.get('!frame') - if controls_frame: - connect_btn = ttk.Button(controls_frame, text="Connect", command=self._on_connect_button) + # Add Connect button to the PPI's own control frame for better layout + if hasattr(self.ppi_widget, 'controls_frame'): + connect_btn = ttk.Button(self.ppi_widget.controls_frame, text="Connect", command=self._on_connect_button) connect_btn.pack(side=tk.RIGHT, padx=10) # --- Left Pane --- @@ -416,13 +414,31 @@ class MainView(tk.Tk): def _open_radar_config(self): self.logger.info("Opening radar config window.") dialog = RadarConfigWindow(self, current_scan_limit=self.scan_limit, current_max_range=self.max_range) + + # wait_window è già gestito all'interno di RadarConfigWindow, + # quindi il codice prosegue solo dopo la sua chiusura. + if dialog.scan_limit is not None and dialog.max_range is not None: - self.scan_limit = dialog.scan_limit - self.max_range = dialog.max_range - self.ppi_widget.destroy() - self.ppi_widget = PPIDisplay(self.h_pane, max_range_nm=self.max_range, scan_limit_deg=self.scan_limit) - self.h_pane.add(self.ppi_widget, weight=2) - self._update_all_views() + # Check if values have actually changed to avoid unnecessary redraws + if self.scan_limit != dialog.scan_limit or self.max_range != dialog.max_range: + self.logger.info("Radar configuration changed. Applying new settings.") + self.scan_limit = dialog.scan_limit + self.max_range = dialog.max_range + + # --- LOGICA MODIFICATA --- + # Non distruggere il widget, ma riconfiguralo. + self.ppi_widget.reconfigure_radar( + max_range_nm=self.max_range, + scan_limit_deg=self.scan_limit + ) + + self.logger.info(f"Scan limit set to: ±{self.scan_limit} degrees") + self.logger.info(f"Max range set to: {self.max_range} NM") + + # Non è necessario chiamare _update_all_views() perché + # reconfigure_radar forza già un ridisegno completo. + else: + self.logger.info("Radar configuration confirmed, but no changes were made.") def _on_closing(self): self.logger.info("Application shutting down.") diff --git a/target_simulator/gui/ppi_display.py b/target_simulator/gui/ppi_display.py index 31768a9..55d5e97 100644 --- a/target_simulator/gui/ppi_display.py +++ b/target_simulator/gui/ppi_display.py @@ -1,179 +1,220 @@ # target_simulator/gui/ppi_display.py """ -A Tkinter widget that displays a Plan Position Indicator (PPI) using Matplotlib. +A reusable Tkinter widget that displays a Plan Position Indicator (PPI) +using Matplotlib, capable of showing both live targets and trajectory previews. """ import tkinter as tk from tkinter import ttk import math import numpy as np +import copy from matplotlib.figure import Figure from matplotlib.backends.backend_tkagg import FigureCanvasTkAgg -import matplotlib.transforms as transforms -import matplotlib.markers as markers - -from core.models import Target -from typing import List +# Use absolute imports +from target_simulator.core.models import Target, Waypoint +from typing import List, Optional class PPIDisplay(ttk.Frame): - """A custom widget for the PPI radar display.""" + """A custom, reusable widget for the PPI radar display.""" def __init__(self, master, max_range_nm: int = 100, scan_limit_deg: int = 60): super().__init__(master) self.max_range = max_range_nm self.scan_limit_deg = scan_limit_deg - self.target_artists = [] + self.target_artists = [] + self.active_targets: List[Target] = [] + self._target_dots = [] + self.preview_artists = [] + self._create_controls() self._create_plot() - + def _create_controls(self): """Creates the control widgets for the PPI display.""" - controls_frame = ttk.Frame(self) - controls_frame.pack(side=tk.TOP, fill=tk.X, padx=5, pady=5) + self.controls_frame = ttk.Frame(self) + self.controls_frame.pack(side=tk.TOP, fill=tk.X, padx=5, pady=5) - ttk.Label(controls_frame, text="Range (NM):").pack(side=tk.LEFT) + ttk.Label(self.controls_frame, text="Range (NM):").pack(side=tk.LEFT) - # Generate range steps - steps = list(range(self.max_range, 19, -20)) - if 10 not in steps: - steps.append(10) + # Create a list of valid range steps up to the theoretical max_range + all_steps = [10, 20, 40, 80, 100, 160] + valid_steps = sorted([s for s in all_steps if s <= self.max_range]) + if not valid_steps: + valid_steps = [self.max_range] + + # Ensure the initial max range is in the list if not a standard step + if self.max_range not in valid_steps: + valid_steps.append(self.max_range) + valid_steps.sort() + # The initial value for the combobox is the max_range passed to the constructor self.range_var = tk.IntVar(value=self.max_range) + self.range_selector = ttk.Combobox( - controls_frame, - textvariable=self.range_var, - values=steps, - state="readonly", - width=5 + self.controls_frame, textvariable=self.range_var, + values=valid_steps, state="readonly", width=5 ) - self.range_selector.pack(side=tk.LEFT) + self.range_selector.pack(side=tk.LEFT, padx=5) self.range_selector.bind("<>", self._on_range_selected) def _create_plot(self): """Initializes the Matplotlib polar plot.""" - # Dark theme for the plot fig = Figure(figsize=(5, 5), dpi=100, facecolor='#3E3E3E') fig.subplots_adjust(left=0.05, right=0.95, top=0.85, bottom=0.05) self.ax = fig.add_subplot(111, projection='polar', facecolor='#2E2E2E') - # Configure polar axes - self.ax.set_theta_zero_location('N') # 0 degrees at the top - self.ax.set_theta_direction(-1) # Clockwise rotation + self.ax.set_theta_zero_location('N') + self.ax.set_theta_direction(-1) self.ax.set_rlabel_position(90) - self.ax.set_ylim(0, self.max_range) + self.ax.set_ylim(0, self.range_var.get()) - # Set custom theta labels for sector scan view angles = np.arange(0, 360, 30) - labels = [] - for angle in angles: - if angle == 0: - labels.append("0°") - elif angle < 180: - labels.append(f"+{angle}°") - elif angle == 180: - labels.append("±180°") - else: # angle > 180 - labels.append(f"-{360 - angle}°") + labels = [f"{angle}°" if angle == 0 else f"+{angle}°" if angle < 180 else "±180°" if angle == 180 else f"-{360 - angle}°" for angle in angles] self.ax.set_thetagrids(angles, labels) - # Style the plot - self.ax.tick_params(axis='x', colors='white') - self.ax.tick_params(axis='y', colors='white') + self.ax.tick_params(axis='x', colors='white', labelsize=8) + self.ax.tick_params(axis='y', colors='white', labelsize=8) self.ax.grid(color='white', linestyle='--', linewidth=0.5, alpha=0.5) self.ax.spines['polar'].set_color('white') - self.ax.set_title("PPI Display", color='white', y=1.08) - # Draw scan sector limits - limit_rad = np.deg2rad(self.scan_limit_deg) - self.ax.plot([limit_rad, limit_rad], [0, self.max_range], color='yellow', linestyle='--', linewidth=1) - self.ax.plot([-limit_rad, -limit_rad], [0, self.max_range], color='yellow', linestyle='--', linewidth=1) + # --- Artists for drawing --- + self._start_plot, = self.ax.plot([], [], 'go', markersize=8) + self._waypoints_plot, = self.ax.plot([], [], 'y+', markersize=10, mew=2, linestyle='None') + self._path_plot, = self.ax.plot([], [], 'r--', linewidth=1.5) + self.preview_artists = [self._start_plot, self._waypoints_plot, self._path_plot] + + # --- NEW: Create artists for scan lines --- + limit_rad = np.deg2rad(self.scan_limit_deg) + self._scan_line_1, = self.ax.plot([limit_rad, limit_rad], [0, self.max_range], color='yellow', linestyle='--', linewidth=1) + self._scan_line_2, = self.ax.plot([-limit_rad, -limit_rad], [0, self.max_range], color='yellow', linestyle='--', linewidth=1) + + self._tooltip_label = None - # Embed the plot in a Tkinter canvas self.canvas = FigureCanvasTkAgg(fig, master=self) self.canvas.draw() self.canvas.get_tk_widget().pack(side=tk.TOP, fill=tk.BOTH, expand=True) + self.canvas.mpl_connect('motion_notify_event', self._on_motion) + + # --- NEW: Initial draw of scan lines --- + self._update_scan_lines() - def _on_range_selected(self, event): - """Handles the selection of a new range from the combobox.""" + def _update_scan_lines(self): + """Updates the length of the scan sector lines to match the current range.""" + current_range_max = self.ax.get_ylim()[1] + self._scan_line_1.set_ydata([0, current_range_max]) + self._scan_line_2.set_ydata([0, current_range_max]) + + def _on_range_selected(self, event=None): + """Handles the selection of a new range.""" new_range = self.range_var.get() self.ax.set_ylim(0, new_range) - # Update heading vector lengths on zoom - self.update_targets([]) # Pass empty list to redraw existing ones with new scale + + # --- NEW: Update scan lines on zoom change --- + self._update_scan_lines() + + self.update_targets(self.active_targets) self.canvas.draw() + # ... il resto dei metodi rimane invariato ... + def update_targets(self, targets: List[Target]): - """ - Aggiorna la posizione dei target sulla PPI e mostra l'ID accanto al punto. Tooltip con ID al passaggio del mouse. - """ - if targets: - self.active_targets = [t for t in targets if t.active] - - # Rimuovi artisti precedenti - for artist in self.target_artists: - artist.remove() + # (This method is unchanged) + self.active_targets = [t for t in targets if t.active] + for artist in self.target_artists: artist.remove() self.target_artists.clear() - - # Rimuovi eventuali tooltips precedenti - if hasattr(self, 'tooltips'): - for tip in self.tooltips: - tip.remove() - self.tooltips = [] - + self._target_dots.clear() vector_len = self.range_var.get() / 25 - self._target_dots = [] - - for target in getattr(self, 'active_targets', []): - r1 = target.current_range_nm - th1 = np.deg2rad(target.current_azimuth_deg) - # Punto del target - dot, = self.ax.plot(th1, r1, 'o', markersize=5, color='red', picker=5) + for target in self.active_targets: + r = target.current_range_nm + theta = np.deg2rad(target.current_azimuth_deg) + dot, = self.ax.plot(theta, r, 'o', markersize=5, color='red') self.target_artists.append(dot) self._target_dots.append((dot, target)) - - # Vettore heading - x1 = r1 * np.sin(th1) - y1 = r1 * np.cos(th1) + x1, y1 = r * np.sin(theta), r * np.cos(theta) h_rad = np.deg2rad(target.current_heading_deg) - dx = vector_len * np.sin(h_rad) - dy = vector_len * np.cos(h_rad) + dx, dy = vector_len * np.sin(h_rad), vector_len * np.cos(h_rad) x2, y2 = x1 + dx, y1 + dy - r2 = np.sqrt(x2**2 + y2**2) - th2 = np.arctan2(x2, y2) - line, = self.ax.plot([th1, th2], [r1, r2], color='red', linewidth=1.2) + r2, th2 = np.sqrt(x2**2 + y2**2), np.arctan2(x2, y2) + line, = self.ax.plot([theta, th2], [r, r2], color='red', linewidth=1.2) self.target_artists.append(line) - - # Gestione tooltip - def on_motion(event): - # Mostra hint se il mouse è vicino a un punto target - if event.inaxes != self.ax: - if hasattr(self, '_tooltip_label') and self._tooltip_label: - self._tooltip_label.place_forget() - self._tooltip_label = None - return - found = False - for dot, target in self._target_dots: - cont, _ = dot.contains(event) - if cont: - # Usa la posizione del mouse relativa al widget Tkinter - self._show_tooltip(event.x + 10, event.y + 10, f"ID: {target.target_id}") - found = True - break - if not found and hasattr(self, '_tooltip_label') and self._tooltip_label: - self._tooltip_label.place_forget() - self._tooltip_label = None - - self.canvas.mpl_connect('motion_notify_event', on_motion) - self._tooltip_label = None self.canvas.draw() + def draw_trajectory_preview(self, initial_state: dict, waypoints: List[Waypoint]): + # (This method is unchanged) + self.clear_previews() + if not waypoints: self.canvas.draw(); return + temp_target = Target(target_id=0, **initial_state, trajectory=copy.deepcopy(waypoints)) + path_thetas, path_rs = [], []; wp_thetas, wp_rs = [], [] + total_duration = sum(wp.duration_s for wp in waypoints) + sim_time, time_step = 0.0, 0.5 + path_thetas.append(math.radians(temp_target.current_azimuth_deg)); path_rs.append(temp_target.current_range_nm) + while sim_time < total_duration: + wp_index_before = temp_target._current_waypoint_index + temp_target.update_state(time_step) + path_thetas.append(math.radians(temp_target.current_azimuth_deg)); path_rs.append(temp_target.current_range_nm) + if temp_target._current_waypoint_index > wp_index_before: + wp_thetas.append(path_thetas[-2]); wp_rs.append(path_rs[-2]) + sim_time += time_step + start_r, start_theta = initial_state['initial_range_nm'], math.radians(initial_state['initial_azimuth_deg']) + self._start_plot.set_data([start_theta], [start_r]) + self._waypoints_plot.set_data(wp_thetas, wp_rs) + self._path_plot.set_data(path_thetas, path_rs) + max_r = max(path_rs) if path_rs else start_r + self.ax.set_ylim(0, max_r * 1.1) + self._update_scan_lines() # Also update scan lines to fit preview if it zooms out + self.canvas.draw() + + def clear_previews(self): + # (This method is unchanged) + for artist in self.preview_artists: artist.set_data([], []) + self.canvas.draw() + + def _on_motion(self, event): + # (This method is unchanged) + if event.inaxes != self.ax: + if self._tooltip_label: self._tooltip_label.place_forget(); self._tooltip_label = None + return + found = False + for dot, target in self._target_dots: + cont, _ = dot.contains(event) + if cont: + self._show_tooltip(event.x + 10, event.y + 10, f"ID: {target.target_id}"); found = True; break + if not found and self._tooltip_label: self._tooltip_label.place_forget(); self._tooltip_label = None + def _show_tooltip(self, x, y, text): - # Mostra un tooltip Tkinter sopra la canvas, centrato sul punto target - if self._tooltip_label: - self._tooltip_label.place_forget() + # (This method is unchanged) + if self._tooltip_label: self._tooltip_label.place_forget() self._tooltip_label = tk.Label(self.canvas.get_tk_widget(), text=text, bg='yellow', fg='black', font=('Consolas', 9), relief='solid', borderwidth=1) - self._tooltip_label.place(x=x, y=y) \ No newline at end of file + self._tooltip_label.place(x=x, y=y) + + def reconfigure_radar(self, max_range_nm: int, scan_limit_deg: int): + """ + Updates the radar parameters (range, scan limit) of an existing PPI display. + """ + self.max_range = max_range_nm + self.scan_limit_deg = scan_limit_deg + + # Update the range combobox values + steps = [10, 20, 40, 80, 100, 160] + valid_steps = sorted([s for s in steps if s <= self.max_range]) + if not valid_steps: valid_steps = [self.max_range] + if self.max_range not in valid_steps: + valid_steps.append(self.max_range) + valid_steps.sort() + + self.range_selector['values'] = valid_steps + self.range_var.set(self.max_range) # Set to the new max range + + # Update the scan limit lines + limit_rad = np.deg2rad(self.scan_limit_deg) + self._scan_line_1.set_xdata([limit_rad, limit_rad]) + self._scan_line_2.set_xdata([-limit_rad, -limit_rad]) + + # Apply the new range and redraw everything + self._on_range_selected() \ No newline at end of file diff --git a/target_simulator/gui/target_list_frame.py b/target_simulator/gui/target_list_frame.py index 8e6464d..3bbe670 100644 --- a/target_simulator/gui/target_list_frame.py +++ b/target_simulator/gui/target_list_frame.py @@ -1,387 +1,160 @@ # target_simulator/gui/target_list_frame.py """ - A frame containing the Treeview for listing targets and control buttons. - """ - import tkinter as tk - from tkinter import ttk, messagebox +from typing import List, Callable, Optional -from typing import List, Callable - -from core.models import Target - - +from core.models import Target, FPS_TO_KNOTS, NM_TO_FT +# Importa il nuovo editor di traiettoria +from .trajectory_editor_window import TrajectoryEditorWindow class TargetListFrame(ttk.LabelFrame): - """Frame for displaying and managing the list of targets.""" - - - def __init__(self, master, targets_changed_callback: Callable[[List[Target]], None] | None = None): - + def __init__(self, master, targets_changed_callback: Optional[Callable[[List[Target]], None]] = None): super().__init__(master, text="Target List") - self.targets_cache: List[Target] = [] - self.targets_changed_callback = targets_changed_callback - - # --- Treeview for Targets --- - columns = ("id", "range", "az", "vel", "hdg", "alt") - self.tree = ttk.Treeview(self, columns=columns, show="headings") - - - # Define headings - self.tree.heading("id", text="ID") - - self.tree.heading("range", text="Range (NM, m)") - + self.tree.heading("range", text="Range (NM)") self.tree.heading("az", text="Azimuth (°)") - - self.tree.heading("vel", text="Velocity (kn, m/s)") - + self.tree.heading("vel", text="Velocity (kn)") self.tree.heading("hdg", text="Heading (°)") - self.tree.heading("alt", text="Altitude (ft)") - - - # Configure column widths - self.tree.column("id", width=40, anchor=tk.CENTER) - - self.tree.column("range", width=120, anchor=tk.E) - + self.tree.column("range", width=100, anchor=tk.E) self.tree.column("az", width=80, anchor=tk.E) - - self.tree.column("vel", width=120, anchor=tk.E) - + self.tree.column("vel", width=100, anchor=tk.E) self.tree.column("hdg", width=80, anchor=tk.E) - self.tree.column("alt", width=100, anchor=tk.E) - - - # --- Scrollbar --- - + # --- Scrollbar & Layout --- scrollbar = ttk.Scrollbar(self, orient=tk.VERTICAL, command=self.tree.yview) - self.tree.configure(yscroll=scrollbar.set) - - - - # --- Layout --- - self.tree.grid(row=0, column=0, sticky=tk.NSEW) - scrollbar.grid(row=0, column=1, sticky=tk.NS) - self.rowconfigure(0, weight=1) - self.columnconfigure(0, weight=1) - - # --- Buttons --- - button_frame = ttk.Frame(self) - - button_frame.grid(row=1, column=0, columnspan=2, pady=5) - - + button_frame.grid(row=1, column=0, columnspan=2, pady=5, sticky=tk.W) self.add_button = ttk.Button(button_frame, text="Add", command=self._on_add_click) - self.add_button.pack(side=tk.LEFT, padx=5) - - - self.remove_button = ttk.Button(button_frame, text="Remove", command=self._on_remove_click) - self.remove_button.pack(side=tk.LEFT, padx=5) - - - - self.edit_button = ttk.Button(button_frame, text="Edit", command=self._on_edit_click) - + self.edit_button = ttk.Button(button_frame, text="Edit Trajectory...", command=self._on_edit_click) self.edit_button.pack(side=tk.LEFT, padx=5) - - - # Bind double click - self.tree.bind('', self._on_double_click) - - def get_targets(self) -> List[Target]: - """Returns the current list of targets from the cache.""" - return self.targets_cache - - def _on_add_click(self): - - from .add_target_window import AddTargetWindow - + """Opens the trajectory editor to create a new target.""" existing_ids = [t.target_id for t in self.targets_cache] - - add_window = AddTargetWindow(self, existing_ids=existing_ids) - - self.winfo_toplevel().wait_window(add_window) - - - if add_window.new_target: - - self.targets_cache.append(add_window.new_target) - + main_ppi = self.winfo_toplevel().ppi_widget + editor = TrajectoryEditorWindow(self, existing_ids=existing_ids, max_range_nm=main_ppi.max_range) + + if editor.result_target: + self.targets_cache.append(editor.result_target) self.update_target_list(self.targets_cache) - if self.targets_changed_callback: - self.targets_changed_callback(self.targets_cache) - - def _on_remove_click(self): - selected_item = self.tree.focus() - if not selected_item: - messagebox.showwarning("No Selection", "Please select a target to remove.", parent=self) - return - - try: - target_id = int(self.tree.item(selected_item, "values")[0]) - self.targets_cache = [t for t in self.targets_cache if t.target_id != target_id] - self.update_target_list(self.targets_cache) - if self.targets_changed_callback: - self.targets_changed_callback(self.targets_cache) - except (ValueError, IndexError): - messagebox.showerror("Error", "Could not identify the selected target.", parent=self) - - def _on_edit_click(self, event=None): - + """Opens the trajectory editor to modify an existing target.""" selected_item = self.tree.focus() - if not selected_item: - - if event: # Don't show warning on double-click if nothing is selected - - return - - messagebox.showwarning("No Selection", "Please select a target to edit.", parent=self) - + if not event: # Show warning only on button click, not on failed double-click + messagebox.showwarning("No Selection", "Please select a target to edit.", parent=self) return - - try: - target_id = int(self.tree.item(selected_item, "values")[0]) - except (ValueError, IndexError): - - messagebox.showerror("Error", "Could not identify the selected target.", parent=self) - - return - - + return # Fail silently on invalid row data target_to_edit = next((t for t in self.targets_cache if t.target_id == target_id), None) - if not target_to_edit: - - messagebox.showerror("Error", f"Target with ID {target_id} not found.", parent=self) - + messagebox.showerror("Error", f"Internal error: Target with ID {target_id} not found in cache.", parent=self) return - - - from .add_target_window import AddTargetWindow - other_ids = [t.target_id for t in self.targets_cache if t.target_id != target_id] + main_ppi = self.winfo_toplevel().ppi_widget + editor = TrajectoryEditorWindow(self, existing_ids=other_ids, target_to_edit=target_to_edit, max_range_nm=main_ppi.max_range) - edit_window = AddTargetWindow(self, existing_ids=other_ids) - - edit_window.title("Edit Target") - - - - # Pre-fill data - - edit_window.id_var.set(target_to_edit.target_id) - - edit_window.id_spinbox.config(state='disabled') - - edit_window.range_var.set(target_to_edit.initial_range_nm) - - edit_window.az_var.set(target_to_edit.initial_azimuth_deg) - - edit_window.alt_var.set(target_to_edit.initial_altitude_ft) - - - - # Pre-fill from trajectory (assuming simple model for now) - - if target_to_edit.trajectory: - - first_waypoint = target_to_edit.trajectory[0] - - fps_to_knots = 0.592484 - - velocity_knots = first_waypoint.target_velocity_fps * fps_to_knots - - edit_window.vel_knots_var.set(velocity_knots) - - edit_window.hdg_var.set(first_waypoint.target_heading_deg) - - - - self.winfo_toplevel().wait_window(edit_window) - - - - if edit_window.new_target: - - # Find and replace the old target with the edited one - + if editor.result_target: + # Replace the old target object with the new, edited one for i, t in enumerate(self.targets_cache): - if t.target_id == target_id: - - self.targets_cache[i] = edit_window.new_target - + self.targets_cache[i] = editor.result_target break - - self.update_target_list(self.targets_cache) - if self.targets_changed_callback: - self.targets_changed_callback(self.targets_cache) - - def _on_double_click(self, event): - - # Ensure a row was actually double-clicked - if self.tree.identify_row(event.y): - self._on_edit_click(event) - - def update_target_list(self, targets: List[Target]): - """Clears and repopulates the treeview with the current list of targets.""" - self.targets_cache = list(targets) - - - # Remember selection - - selected_item = self.tree.focus() - + selected_id_str = self.tree.focus() selected_id = None - - if selected_item: - + if selected_id_str: try: - - selected_id = int(self.tree.item(selected_item, "values")[0]) - + selected_id = int(self.tree.item(selected_id_str, "values")[0]) except (ValueError, IndexError): - selected_id = None - - - # Clear and repopulate - self.tree.delete(*self.tree.get_children()) - - new_selection_item = None - for target in sorted(self.targets_cache, key=lambda t: t.target_id): - - # Conversions - - nm_to_m = 1852.0 - - fps_to_knots = 0.592484 - - knots_to_ms = 0.514444 - - - - range_m = target.current_range_nm * nm_to_m - - vel_knots = target.current_velocity_fps * fps_to_knots - - vel_ms = vel_knots * knots_to_ms - - - values = ( - target.target_id, - - f"{target.current_range_nm:.2f} / {range_m:.0f}", - + f"{target.current_range_nm:.2f}", f"{target.current_azimuth_deg:.2f}", - - f"{vel_knots:.2f} / {vel_ms:.2f}", - + f"{target.current_velocity_fps * FPS_TO_KNOTS:.2f}", f"{target.current_heading_deg:.2f}", - - f"{target.current_altitude_ft:.2f}", - + f"{target.current_altitude_ft:.0f}", ) - - item = self.tree.insert("", tk.END, values=values) - + item_id = self.tree.insert("", tk.END, values=values) if target.target_id == selected_id: - - new_selection_item = item - - - - # Restore selection + new_selection_item = item_id if new_selection_item: - self.tree.focus(new_selection_item) - self.tree.selection_set(new_selection_item) \ No newline at end of file diff --git a/target_simulator/gui/trajectory_editor_window.py b/target_simulator/gui/trajectory_editor_window.py new file mode 100644 index 0000000..6fe25eb --- /dev/null +++ b/target_simulator/gui/trajectory_editor_window.py @@ -0,0 +1,228 @@ +# target_simulator/gui/trajectory_editor_window.py +""" +A Toplevel window for visually editing a target's trajectory using waypoints, +including a live preview of the path. +""" +import tkinter as tk +from tkinter import ttk, messagebox +from typing import List, Optional +import copy + +# Use absolute imports for robustness +from target_simulator.core.models import Target, Waypoint, MIN_TARGET_ID, MAX_TARGET_ID, KNOTS_TO_FPS, FPS_TO_KNOTS +from target_simulator.gui.ppi_display import PPIDisplay + +class TrajectoryEditorWindow(tk.Toplevel): + """A dialog for creating and editing a target's trajectory with a visual preview.""" + + def __init__(self, master, existing_ids: List[int], max_range_nm: int, target_to_edit: Optional[Target] = None): + super().__init__(master) + self.transient(master) + self.grab_set() + self.title("Trajectory Editor") + self.resizable(False, False) + + self.existing_ids = existing_ids + self.result_target: Optional[Target] = None + self.initial_max_range = max_range_nm + + # --- Internal State --- + if target_to_edit: + self.target_id = target_to_edit.target_id + self.initial_range = target_to_edit.initial_range_nm + self.initial_az = target_to_edit.initial_azimuth_deg + self.initial_alt = target_to_edit.initial_altitude_ft + self.waypoints = copy.deepcopy(target_to_edit.trajectory) + else: + self.target_id = next((i for i in range(MIN_TARGET_ID, MAX_TARGET_ID + 1) if i not in existing_ids), -1) + self.initial_range = 20.0 + self.initial_az = 0.0 + self.initial_alt = 10000.0 + self.waypoints: List[Waypoint] = [] + + self._create_widgets() + self._populate_waypoint_list() + self._update_preview() + + # --- NUOVA LOGICA DI CENTRATURA SULLA FINESTRA PADRE --- + self.update_idletasks() + self.title("Target editor") + self.geometry('1200x1024') + self.minsize(1024, 768) + # --- FINE LOGICA DI CENTRATURA --- + + self.protocol("WM_DELETE_WINDOW", self._on_cancel) + self.wait_window(self) + + def _center_window(self): + """Centers the window on its parent.""" + 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) + main_frame.columnconfigure(2, weight=1) + main_frame.rowconfigure(1, weight=1) + + initial_state_frame = ttk.LabelFrame(main_frame, text="Initial State") + initial_state_frame.grid(row=0, column=0, columnspan=3, sticky=tk.EW, pady=(0, 10)) + self._create_initial_state_widgets(initial_state_frame) + + list_frame = ttk.LabelFrame(main_frame, text="Waypoints") + list_frame.grid(row=1, column=0, sticky=tk.NS, padx=(0, 10)) + self._create_waypoint_list_widgets(list_frame) + + self.details_frame = ttk.LabelFrame(main_frame, text="Waypoint Details") + self.details_frame.grid(row=1, column=1, sticky=tk.NS) + self._create_waypoint_details_widgets(self.details_frame) + + preview_frame = ttk.LabelFrame(main_frame, text="Trajectory Preview") + preview_frame.grid(row=1, column=2, sticky=tk.NSEW, padx=(10, 0)) + + # --- Instantiate the reusable PPIDisplay widget --- + self.ppi_preview = PPIDisplay(preview_frame, max_range_nm=self.initial_max_range) + self.ppi_preview.pack(fill=tk.BOTH, expand=True) + # We can hide the controls if they are not needed in this context + #self.ppi_preview.controls_frame.pack_forget() + + button_frame = ttk.Frame(main_frame) + button_frame.grid(row=2, column=0, columnspan=3, sticky=tk.E, pady=(10, 0)) + ttk.Button(button_frame, text="OK", command=self._on_ok).pack(side=tk.LEFT, padx=5) + ttk.Button(button_frame, text="Cancel", command=self._on_cancel).pack(side=tk.LEFT) + + def _create_initial_state_widgets(self, parent): + parent.columnconfigure(1, weight=1); parent.columnconfigure(3, weight=1) + + self.id_var = tk.IntVar(value=self.target_id) + ttk.Label(parent, text="ID:").grid(row=0, column=0, sticky=tk.W, padx=5, pady=2) + id_spinbox = ttk.Spinbox(parent, from_=MIN_TARGET_ID, to=MAX_TARGET_ID, textvariable=self.id_var, width=8, command=self._update_preview) + id_spinbox.grid(row=0, column=1, sticky=tk.W, padx=5, pady=2) + if self.target_id in self.existing_ids: id_spinbox.config(state='disabled') + + self.range_var = tk.DoubleVar(value=self.initial_range) + self.az_var = tk.DoubleVar(value=self.initial_az) + self.alt_var = tk.DoubleVar(value=self.initial_alt) + ttk.Label(parent, text="Range (NM):").grid(row=0, column=2, sticky=tk.W, padx=5, pady=2) + ttk.Spinbox(parent, from_=0, to=1000, textvariable=self.range_var, width=8, command=self._update_preview).grid(row=0, column=3, sticky=tk.W, padx=5, pady=2) + ttk.Label(parent, text="Azimuth (°):").grid(row=1, column=0, sticky=tk.W, padx=5, pady=2) + ttk.Spinbox(parent, from_=-180, to=180, textvariable=self.az_var, width=8, command=self._update_preview).grid(row=1, column=1, sticky=tk.W, padx=5, pady=2) + ttk.Label(parent, text="Altitude (ft):").grid(row=1, column=2, sticky=tk.W, padx=5, pady=2) + ttk.Spinbox(parent, from_=-1000, to=80000, textvariable=self.alt_var, width=8).grid(row=1, column=3, sticky=tk.W, padx=5, pady=2) + + def _create_waypoint_list_widgets(self, parent): + self.wp_tree = ttk.Treeview(parent, columns=("duration", "vel", "hdg"), show="headings", height=8) + self.wp_tree.heading("duration", text="Duration (s)"); self.wp_tree.heading("vel", text="Vel (kn)"); self.wp_tree.heading("hdg", text="Hdg (°)") + self.wp_tree.column("duration", width=80, anchor=tk.E); self.wp_tree.column("vel", width=80, anchor=tk.E); self.wp_tree.column("hdg", width=80, anchor=tk.E) + self.wp_tree.pack(side=tk.TOP, fill=tk.BOTH, expand=True) + self.wp_tree.bind("<>", self._on_waypoint_select) + + btn_frame = ttk.Frame(parent) + btn_frame.pack(fill=tk.X, pady=5) + ttk.Button(btn_frame, text="Add", command=self._on_add_waypoint).pack(side=tk.LEFT, padx=2) + ttk.Button(btn_frame, text="Remove", command=self._on_remove_waypoint).pack(side=tk.LEFT, padx=2) + + def _create_waypoint_details_widgets(self, parent): + parent.columnconfigure(1, weight=1) + self.wp_duration_var = tk.DoubleVar(); self.wp_vel_var = tk.DoubleVar(); self.wp_hdg_var = tk.DoubleVar() + + ttk.Label(parent, text="Duration (s):").grid(row=0, column=0, sticky=tk.W, padx=5, pady=5) + self.wp_duration_spinbox = ttk.Spinbox(parent, from_=1, to=3600, textvariable=self.wp_duration_var) + self.wp_duration_spinbox.grid(row=0, column=1, sticky=tk.EW, padx=5, pady=5) + ttk.Label(parent, text="Target Vel (kn):").grid(row=1, column=0, sticky=tk.W, padx=5, pady=5) + self.wp_vel_spinbox = ttk.Spinbox(parent, from_=0, to=2000, textvariable=self.wp_vel_var) + self.wp_vel_spinbox.grid(row=1, column=1, sticky=tk.EW, padx=5, pady=5) + ttk.Label(parent, text="Target Hdg (°):").grid(row=2, column=0, sticky=tk.W, padx=5, pady=5) + self.wp_hdg_spinbox = ttk.Spinbox(parent, from_=0, to=359.9, textvariable=self.wp_hdg_var) + self.wp_hdg_spinbox.grid(row=2, column=1, sticky=tk.EW, padx=5, pady=5) + ttk.Button(parent, text="Apply Changes", command=self._on_apply_changes).grid(row=3, column=0, columnspan=2, pady=10) + + for child in parent.winfo_children(): child.configure(state=tk.DISABLED) + + def _update_preview(self): + """Collects current editor data and tells the PPI widget to draw the preview.""" + initial_state = { + "initial_range_nm": self.range_var.get(), + "initial_azimuth_deg": self.az_var.get(), + "initial_altitude_ft": self.alt_var.get() + } + self.ppi_preview.draw_trajectory_preview(initial_state, self.waypoints) + + def _on_apply_changes(self): + selected_item = self.wp_tree.focus() + if not selected_item: return + + wp_index = int(selected_item) + try: + wp = self.waypoints[wp_index] + wp.duration_s = self.wp_duration_var.get() + wp.target_velocity_fps = self.wp_vel_var.get() * KNOTS_TO_FPS + wp.target_heading_deg = self.wp_hdg_var.get() + self._populate_waypoint_list() + self._update_preview() + self.wp_tree.focus(selected_item); self.wp_tree.selection_set(selected_item) + except (ValueError, tk.TclError): + messagebox.showerror("Invalid Input", "Please enter valid numbers.", parent=self) + + def _on_add_waypoint(self): + last_wp = self.waypoints[-1] if self.waypoints else None + new_wp = Waypoint( + duration_s=10.0, + target_velocity_fps=last_wp.target_velocity_fps if last_wp else 500.0, + target_heading_deg=last_wp.target_heading_deg if last_wp else 90.0 + ) + self.waypoints.append(new_wp) + self._populate_waypoint_list() + self._update_preview() + new_item_id = str(len(self.waypoints) - 1) + self.wp_tree.focus(new_item_id); self.wp_tree.selection_set(new_item_id) + + def _on_remove_waypoint(self): + selected_item = self.wp_tree.focus() + if not selected_item: + messagebox.showwarning("No Selection", "Please select a waypoint to remove.", parent=self) + return + + wp_index = int(selected_item) + del self.waypoints[wp_index] + self._populate_waypoint_list() + self._update_preview() + for child in self.details_frame.winfo_children(): child.configure(state=tk.DISABLED) + + def _on_ok(self): + target_id = self.id_var.get() + if target_id != self.target_id and target_id in self.existing_ids: + messagebox.showerror("Invalid ID", f"Target ID {target_id} is already in use.", parent=self) + return + if not self.waypoints: + messagebox.showerror("No Trajectory", "A target must have at least one waypoint.", parent=self) + return + + try: + self.result_target = Target(target_id=target_id, initial_range_nm=self.range_var.get(), initial_azimuth_deg=self.az_var.get(), initial_altitude_ft=self.alt_var.get(), trajectory=self.waypoints) + self.destroy() + except (ValueError, tk.TclError) as e: + messagebox.showerror("Validation Error", str(e), parent=self) + + def _on_cancel(self): + self.result_target = None + self.destroy() + + def _populate_waypoint_list(self): + self.wp_tree.delete(*self.wp_tree.get_children()) + for i, wp in enumerate(self.waypoints): + self.wp_tree.insert("", tk.END, iid=str(i), values=(f"{wp.duration_s:.1f}", f"{wp.target_velocity_fps * FPS_TO_KNOTS:.2f}", f"{wp.target_heading_deg:.2f}")) + + def _on_waypoint_select(self, event=None): + selected_item = self.wp_tree.focus() + if not selected_item: return + + for child in self.details_frame.winfo_children(): child.configure(state=tk.NORMAL) + + wp = self.waypoints[int(selected_item)] + self.wp_duration_var.set(wp.duration_s) + self.wp_vel_var.set(wp.target_velocity_fps * FPS_TO_KNOTS) + self.wp_hdg_var.set(wp.target_heading_deg) \ No newline at end of file